From 199ef93a9d3a649381f6cef78b341de392bd0d0c Mon Sep 17 00:00:00 2001 From: Sarav Date: Tue, 27 Oct 2026 12:29:34 +0530 Subject: [PATCH 1/5] fix(auth): `auth logout` removes the saved Altimate gateway key `altimate-code auth logout` for `altimate-backend` only dropped the `auth.json` entry. Browser sign-in also writes the key to `~/.altimate/altimate.json`, which the provider loader and every workspace call read first, so the signed-out user's key kept working. - Call `AltimateApi.clearCredentials()` when logging out of `altimate-backend` - List `altimate-backend` in the logout picker when only `altimate.json` exists (TUI paste dialog), so it can be signed out from the CLI - TUI `/logout` also removes the `auth.json` entry, so a pasted `url::instance::key` no longer keeps the provider loader's fallback live - Test both the browser sign-in and file-only cases Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/cli/cmd/providers.ts | 14 ++++ .../tui/altimate/provider-credentials.tsx | 2 + .../test/cli/providers-logout.test.ts | 65 +++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index f77b4d0d13..147ebde6d0 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,11 @@ 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. + yield* cliTry("Failed to remove Altimate credential: ", () => AltimateApi.clearCredentials()) + } // 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..6ea43b2b2b 100644 --- a/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx +++ b/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx @@ -87,6 +87,8 @@ function show(api: TuiPluginApi) { async function logout(api: TuiPluginApi) { try { await AltimateApi.clearCredentials() + // 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 }) await api.client.instance.dispose() api.ui.toast({ variant: "success", message: "Signed out of Altimate LLM Gateway" }) } catch (err) { diff --git a/packages/opencode/test/cli/providers-logout.test.ts b/packages/opencode/test/cli/providers-logout.test.ts index ef80e4e3ea..3e97374e1c 100644 --- a/packages/opencode/test/cli/providers-logout.test.ts +++ b/packages/opencode/test/cli/providers-logout.test.ts @@ -97,4 +97,69 @@ 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 }, + ) + + 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(() => + fs.stat(altimatePath).then( + () => true, + () => false, + ), + ), + ).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(() => + fs.stat(altimatePath).then( + () => true, + () => false, + ), + ), + ).toBe(false) + expect(JSON.parse(yield* Effect.promise(() => fs.readFile(authPath, "utf8")))).toEqual({ anthropic }) + }), + 120_000, + ) }) From a345963022c702b013d8bdd8ce065dcb55b0a913 Mon Sep 17 00:00:00 2001 From: Sarav Date: Tue, 27 Oct 2026 12:35:46 +0530 Subject: [PATCH 2/5] chore(auth): tidy logout test and document gateway sign-out - Factor the repeated `altimate.json` existence check in the logout test into a helper - Document `altimate providers logout altimate-backend` and TUI `/logout` in the provider docs Co-Authored-By: Claude Opus 5.5 --- docs/docs/configure/providers.md | 2 ++ .../test/cli/providers-logout.test.ts | 23 ++++++------------- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index 38ee4c2dd5..6622079995 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 removes the saved key from `~/.altimate/altimate.json` and the auth store, so workspace requests stop until you sign in 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/test/cli/providers-logout.test.ts b/packages/opencode/test/cli/providers-logout.test.ts index 3e97374e1c..04a8570975 100644 --- a/packages/opencode/test/cli/providers-logout.test.ts +++ b/packages/opencode/test/cli/providers-logout.test.ts @@ -116,6 +116,11 @@ describe("providers logout", () => { }), { 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 })) @@ -133,14 +138,7 @@ describe("providers logout", () => { }) opencode.expectExit(logout, 0, "providers logout altimate-backend") expect(logout.stdout).toContain("Logout successful") - expect( - yield* Effect.promise(() => - fs.stat(altimatePath).then( - () => true, - () => false, - ), - ), - ).toBe(false) + 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. @@ -150,14 +148,7 @@ describe("providers logout", () => { }) opencode.expectExit(fileOnlyLogout, 0, "providers logout file-only altimate-backend") expect(fileOnlyLogout.stdout).toContain("Logout successful") - expect( - yield* Effect.promise(() => - fs.stat(altimatePath).then( - () => true, - () => false, - ), - ), - ).toBe(false) + expect(yield* Effect.promise(() => altimateFileExists())).toBe(false) expect(JSON.parse(yield* Effect.promise(() => fs.readFile(authPath, "utf8")))).toEqual({ anthropic }) }), 120_000, From 2a54c9e28af6687520c005d820a351e79a1fc33a Mon Sep 17 00:00:00 2001 From: Sarav Date: Wed, 28 Oct 2026 12:37:13 +0530 Subject: [PATCH 3/5] fix(auth): sign-out also removes datamate MCP entries carrying the gateway key Standalone `datamate add` writes `Authorization: Bearer ` into MCP entries in `altimate-code.json`, so new sessions kept connecting as the signed-out user after `auth logout`. - Add `AltimateApi.signOut(projectDir)`: removes MCP entries in the global and project config whose `Authorization` header is exactly the saved key, then deletes `~/.altimate/altimate.json` - CLI `auth logout altimate-backend` and TUI `/logout` both use it and report the removed entries - CLI error names the credential file and says to delete it manually - TUI `/logout` passes `throwOnError` so a failed auth-store removal is reported instead of showing "Signed out" - Docs: Windows path, removed MCP entries, running sessions need a restart - Test MCP entry removal (project + global, unrelated entry kept) Co-Authored-By: Claude Opus 5.5 --- docs/docs/configure/providers.md | 2 +- packages/opencode/src/altimate/api/client.ts | 25 +++++++- packages/opencode/src/cli/cmd/providers.ts | 7 ++- .../tui/altimate/provider-credentials.tsx | 7 ++- .../test/cli/providers-logout.test.ts | 58 +++++++++++++++++++ 5 files changed, 93 insertions(+), 6 deletions(-) diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index 6622079995..ab8dda7375 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -43,7 +43,7 @@ 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 removes the saved key from `~/.altimate/altimate.json` and the auth store, so workspace requests stop until you sign in again. +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 147ebde6d0..4327023714 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -650,7 +650,12 @@ export const ProvidersLogoutCommand = effectCmd({ 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. - yield* cliTry("Failed to remove Altimate credential: ", () => AltimateApi.clearCredentials()) + 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)) diff --git a/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx b/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx index 6ea43b2b2b..1c11e58a62 100644 --- a/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx +++ b/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx @@ -86,11 +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 }) + 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 04a8570975..c9b19e6d7b 100644 --- a/packages/opencode/test/cli/providers-logout.test.ts +++ b/packages/opencode/test/cli/providers-logout.test.ts @@ -153,4 +153,62 @@ describe("providers logout", () => { }), 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, + ) }) From ff2134cf463288fb7779da8a5e2c99ae0318e6bc Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 29 Oct 2026 07:27:32 +0530 Subject: [PATCH 4/5] fix(auth): sign-out scans configs up to the git root and fails closed on an unreadable key - `signOut` checks every project config from the current directory up to the git root, where `datamate add` writes entries, so logging out from a subdirectory no longer misses them - If `altimate.json` exists but its key can't be read (corrupt file, unresolved `${env:...}`), sign-out fails and keeps the file instead of deleting the only copy of the key and leaving MCP entries that can never be matched - CLI error now says to fix the cause and run logout again, rather than to delete `altimate.json` by hand (which would orphan the MCP entries) - Docs: describe matching by `Authorization` header, the git-root scan, and the unreadable-key failure - Unit tests for the subdirectory, unreadable-key, and no-credential cases Co-Authored-By: Claude Opus 5.5 --- docs/docs/configure/providers.md | 4 +- packages/opencode/src/altimate/api/client.ts | 53 +++++++++--- packages/opencode/src/cli/cmd/providers.ts | 7 +- .../opencode/test/altimate/sign-out.test.ts | 84 +++++++++++++++++++ 4 files changed, 130 insertions(+), 18 deletions(-) create mode 100644 packages/opencode/test/altimate/sign-out.test.ts diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index ab8dda7375..7ae8b41584 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -30,7 +30,7 @@ Each provider has a key in the `provider` object: ## Altimate LLM Gateway -Managed LLM access with dynamic routing across Sonnet 4.6, Opus 4.6, GPT-5.4, GPT-5.3, and more. No API keys to manage — 10M tokens free to get started. +Managed LLM access with dynamic routing across Sonnet 4.6, Opus 4.6, GPT-5.4, GPT-5.3, and more. No API keys to manage — 1M tokens free to get started. ```json { @@ -43,7 +43,7 @@ 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. +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 global config and the project config from the current directory up to the git root. 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 8f96509bc6..e383537bc2 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -3,7 +3,13 @@ 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" +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" @@ -160,22 +166,43 @@ export namespace AltimateApi { await rm(credentialsPath(), { force: true }) } + /** Directories whose project config a session in `directory` loads: `directory` up to the git root, + * or `directory` alone outside git. Matches where `datamate add` writes project entries. */ + async function projectConfigDirs(directory: string): Promise { + const dirs: string[] = [] + for (let dir = directory; ; dir = path.dirname(dir)) { + dirs.push(dir) + if (await Filesystem.exists(path.join(dir, ".git"))) return dirs + if (path.dirname(dir) === dir) return [directory] + } + } + /** 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. */ + * 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> { - const key = await getCredentials().then( - (creds) => creds.altimateApiKey, - () => undefined, - ) + 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 }> = [] - 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 }) - } + 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() diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 4327023714..c631091518 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -650,9 +650,10 @@ export const ProvidersLogoutCommand = effectCmd({ 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()), + // 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.") 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..afad5569ca --- /dev/null +++ b/packages/opencode/test/altimate/sign-out.test.ts @@ -0,0 +1,84 @@ +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("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") } }) + }) +}) From 4d07adee364c11e0189b1f1657f41d6cbfc9b4e0 Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 29 Oct 2026 07:31:16 +0530 Subject: [PATCH 5/5] fix(auth): sign-out scans the same project configs the loader reads - Outside git the loader's worktree is "/", so it reads config from every parent directory; `signOut` now walks to the filesystem root in that case instead of checking only the starting directory - Always include `~/.altimate-code`, which the loader reads for every session - Unit test for the outside-git and `~/.altimate-code` cases; docs updated Co-Authored-By: Claude Opus 5.5 --- docs/docs/configure/providers.md | 2 +- packages/opencode/src/altimate/api/client.ts | 9 ++++----- .../opencode/test/altimate/sign-out.test.ts | 20 +++++++++++++++++++ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index 7ae8b41584..7dac03ff9a 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -43,7 +43,7 @@ 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 global config and the project config from the current directory up to the git root. 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. +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 e383537bc2..178b777036 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -166,14 +166,13 @@ export namespace AltimateApi { await rm(credentialsPath(), { force: true }) } - /** Directories whose project config a session in `directory` loads: `directory` up to the git root, - * or `directory` alone outside git. Matches where `datamate add` writes project entries. */ + /** 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: string[] = [] + const dirs = [Global.Path.home] for (let dir = directory; ; dir = path.dirname(dir)) { dirs.push(dir) - if (await Filesystem.exists(path.join(dir, ".git"))) return dirs - if (path.dirname(dir) === dir) return [directory] + if (path.dirname(dir) === dir || (await Filesystem.exists(path.join(dir, ".git")))) return [...new Set(dirs)] } } diff --git a/packages/opencode/test/altimate/sign-out.test.ts b/packages/opencode/test/altimate/sign-out.test.ts index afad5569ca..6fb1ad4224 100644 --- a/packages/opencode/test/altimate/sign-out.test.ts +++ b/packages/opencode/test/altimate/sign-out.test.ts @@ -60,6 +60,26 @@ describe("AltimateApi.signOut", () => { 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(), {