Skip to content

Commit ff2134c

Browse files
saravmajesticclaude
andcommitted
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 <[email protected]>
1 parent e396409 commit ff2134c

4 files changed

Lines changed: 130 additions & 18 deletions

File tree

‎docs/docs/configure/providers.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Each provider has a key in the `provider` object:
3030

3131
## Altimate LLM Gateway
3232

33-
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.
33+
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.
3434

3535
```json
3636
{
@@ -43,7 +43,7 @@ Managed LLM access with dynamic routing across Sonnet 4.6, Opus 4.6, GPT-5.4, GP
4343

4444
For pricing, security, and data handling details, see the [Altimate LLM Gateway guide](https://help.altimate.ai/datamates/user-guide/components/llm-gateway/).
4545

46-
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.
46+
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.
4747

4848
!!! tip "Automatic model selection"
4949
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.

‎packages/opencode/src/altimate/api/client.ts‎

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import path from "path"
33
import { rm } from "node:fs/promises"
44
import { Global } from "../../global"
55
import { Filesystem } from "../../util/filesystem"
6-
import { findAllConfigPaths, listMcpInConfig, readMcpEntryFromDisk, removeMcpFromConfig } from "../../mcp/config"
6+
import {
7+
findGlobalConfigPaths,
8+
findProjectConfigPaths,
9+
listMcpInConfig,
10+
readMcpEntryFromDisk,
11+
removeMcpFromConfig,
12+
} from "../../mcp/config"
713
import { InstallationVersion } from "@opencode-ai/core/installation/version"
814

915
const DEFAULT_MCP_URL = "https://mcpserver.getaltimate.com/sse"
@@ -160,22 +166,43 @@ export namespace AltimateApi {
160166
await rm(credentialsPath(), { force: true })
161167
}
162168

169+
/** Directories whose project config a session in `directory` loads: `directory` up to the git root,
170+
* or `directory` alone outside git. Matches where `datamate add` writes project entries. */
171+
async function projectConfigDirs(directory: string): Promise<string[]> {
172+
const dirs: string[] = []
173+
for (let dir = directory; ; dir = path.dirname(dir)) {
174+
dirs.push(dir)
175+
if (await Filesystem.exists(path.join(dir, ".git"))) return dirs
176+
if (path.dirname(dir) === dir) return [directory]
177+
}
178+
}
179+
163180
/** Sign out of the gateway: remove MCP entries that carry the saved key, then delete the credential file.
164181
* Standalone `datamate add` writes the key into MCP headers (`buildMcpConfig`); matching the exact key
165-
* leaves user-authored entries alone. Only the global config and `projectDir` are searched. */
182+
* leaves user-authored entries alone. Searches the global config and every project config a session in
183+
* `projectDir` loads. Fails without deleting the file when the key can't be read: the file holds the
184+
* only copy, and without it leftover MCP entries can never be matched. */
166185
export async function signOut(projectDir: string): Promise<Array<{ name: string; path: string }>> {
167-
const key = await getCredentials().then(
168-
(creds) => creds.altimateApiKey,
169-
() => undefined,
170-
)
186+
if (!(await isConfigured())) return []
187+
let key: string
188+
try {
189+
key = (await getCredentials()).altimateApiKey
190+
} catch (err) {
191+
throw new Error(
192+
`could not read the saved key from ${credentialsPath()} (${err instanceof Error ? err.message : err}); ` +
193+
"fix the file and sign out again so MCP servers that carry the key are removed too",
194+
)
195+
}
196+
const configPaths = [
197+
...(await Promise.all((await projectConfigDirs(projectDir)).map(findProjectConfigPaths))).flat(),
198+
...(await findGlobalConfigPaths(Global.Path.config)),
199+
]
171200
const removed: Array<{ name: string; path: string }> = []
172-
if (key) {
173-
for (const configPath of await findAllConfigPaths(projectDir, Global.Path.config)) {
174-
for (const name of await listMcpInConfig(configPath)) {
175-
const entry = await readMcpEntryFromDisk(name, configPath)
176-
if (entry?.type !== "remote" || entry.headers?.Authorization !== `Bearer ${key}`) continue
177-
if (await removeMcpFromConfig(name, configPath)) removed.push({ name, path: configPath })
178-
}
201+
for (const configPath of configPaths) {
202+
for (const name of await listMcpInConfig(configPath)) {
203+
const entry = await readMcpEntryFromDisk(name, configPath)
204+
if (entry?.type !== "remote" || entry.headers?.Authorization !== `Bearer ${key}`) continue
205+
if (await removeMcpFromConfig(name, configPath)) removed.push({ name, path: configPath })
179206
}
180207
}
181208
await clearCredentials()

‎packages/opencode/src/cli/cmd/providers.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -650,9 +650,10 @@ export const ProvidersLogoutCommand = effectCmd({
650650
if (provider === "altimate-backend") {
651651
// Browser sign-in persists the gateway key to ~/.altimate/altimate.json, which the provider
652652
// loader and every workspace call read first, so sign-out must remove it too.
653-
const removed = yield* cliTry(
654-
`Failed to sign out of Altimate (if ${AltimateApi.credentialsPath()} remains, delete it manually): `,
655-
() => AltimateApi.signOut(process.cwd()),
653+
// On failure the saved key and the auth-store entry are both kept, so fixing the cause and
654+
// re-running logout can still match and remove every copy of the key.
655+
const removed = yield* cliTry("Failed to sign out of Altimate; fix the cause below and run logout again: ", () =>
656+
AltimateApi.signOut(process.cwd()),
656657
)
657658
for (const entry of removed) yield* Prompt.log.info(`Removed MCP server "${entry.name}" from ${entry.path}`)
658659
if (removed.length > 0) yield* Prompt.log.info("Add the datamate again after signing in to restore it.")
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
2+
import path from "path"
3+
import os from "os"
4+
import fsp from "fs/promises"
5+
6+
import { AltimateApi } from "../../src/altimate/api/client"
7+
8+
const tmpRoot = path.join(os.tmpdir(), "sign-out-test-" + process.pid + "-" + Math.random().toString(36).slice(2))
9+
const originalTestHome = process.env.OPENCODE_TEST_HOME
10+
11+
const datamateEntry = (key: string) => ({
12+
type: "remote",
13+
url: "https://mcp.test",
14+
oauth: false,
15+
headers: { Authorization: `Bearer ${key}`, "x-datamate-id": "1", "x-tenant": "tenant" },
16+
})
17+
18+
async function writeJson(file: string, value: unknown) {
19+
await fsp.mkdir(path.dirname(file), { recursive: true })
20+
await fsp.writeFile(file, JSON.stringify(value))
21+
}
22+
23+
const readJson = async (file: string) => JSON.parse(await fsp.readFile(file, "utf8"))
24+
25+
describe("AltimateApi.signOut", () => {
26+
const home = path.join(tmpRoot, "home")
27+
const outside = path.join(tmpRoot, "outside")
28+
const repo = path.join(outside, "repo")
29+
const subdir = path.join(repo, "models", "staging")
30+
const rootConfig = path.join(repo, ".altimate-code", "altimate-code.json")
31+
const outsideConfig = path.join(outside, "altimate-code.json")
32+
33+
beforeEach(async () => {
34+
process.env.OPENCODE_TEST_HOME = home
35+
await fsp.mkdir(path.join(repo, ".git"), { recursive: true })
36+
await fsp.mkdir(subdir, { recursive: true })
37+
})
38+
39+
afterEach(async () => {
40+
if (originalTestHome === undefined) delete process.env.OPENCODE_TEST_HOME
41+
else process.env.OPENCODE_TEST_HOME = originalTestHome
42+
await fsp.rm(tmpRoot, { recursive: true, force: true }).catch(() => {})
43+
})
44+
45+
test("from a subdirectory, removes entries in configs up to the git root and no further", async () => {
46+
await writeJson(AltimateApi.credentialsPath(), {
47+
altimateUrl: "https://api.test",
48+
altimateInstanceName: "tenant",
49+
altimateApiKey: "gateway-test-key",
50+
})
51+
await writeJson(rootConfig, { mcp: { "datamate-sales": datamateEntry("gateway-test-key") } })
52+
await writeJson(outsideConfig, { mcp: { "datamate-other": datamateEntry("gateway-test-key") } })
53+
54+
const removed = await AltimateApi.signOut(subdir)
55+
56+
expect(removed).toEqual([{ name: "datamate-sales", path: rootConfig }])
57+
expect(await readJson(rootConfig)).toEqual({ mcp: {} })
58+
// Above the git root, outside what a session in the repo loads.
59+
expect(await readJson(outsideConfig)).toEqual({ mcp: { "datamate-other": datamateEntry("gateway-test-key") } })
60+
expect(await AltimateApi.isConfigured()).toBe(false)
61+
})
62+
63+
test("keeps the credential file and MCP entries when the saved key can't be read", async () => {
64+
delete process.env.SIGN_OUT_TEST_MISSING_KEY
65+
await writeJson(AltimateApi.credentialsPath(), {
66+
altimateUrl: "https://api.test",
67+
altimateInstanceName: "tenant",
68+
altimateApiKey: "${env:SIGN_OUT_TEST_MISSING_KEY}",
69+
})
70+
await writeJson(rootConfig, { mcp: { "datamate-sales": datamateEntry("gateway-test-key") } })
71+
72+
await expect(AltimateApi.signOut(subdir)).rejects.toThrow("could not read the saved key")
73+
74+
expect(await AltimateApi.isConfigured()).toBe(true)
75+
expect(await readJson(rootConfig)).toEqual({ mcp: { "datamate-sales": datamateEntry("gateway-test-key") } })
76+
})
77+
78+
test("is a no-op when there is no credential file", async () => {
79+
await writeJson(rootConfig, { mcp: { "datamate-sales": datamateEntry("gateway-test-key") } })
80+
81+
expect(await AltimateApi.signOut(subdir)).toEqual([])
82+
expect(await readJson(rootConfig)).toEqual({ mcp: { "datamate-sales": datamateEntry("gateway-test-key") } })
83+
})
84+
})

0 commit comments

Comments
 (0)