Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ CLI credentials are stored in the OS secret store:

- macOS: Keychain
- Windows: Credential Manager
- Linux: Secret Service / libsecret
- Linux: Secret Service / libsecret (not the in-memory kernel keyring)

If the keychain is unavailable (common on headless Linux), the CLI writes a
`0600` file under `$XDG_CONFIG_HOME/kody` (or `%APPDATA%\kody` on Windows,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"dependencies": {
"@inquirer/checkbox": "^5.2.2",
"@modelcontextprotocol/client": "2.0.0",
"@napi-rs/keyring": "^1.3.0",
"@napi-rs/keyring": "^2.1.0",
"add-mcp": "^2.4.0",
"ps-list": "^9.0.0"
},
Expand Down
6 changes: 4 additions & 2 deletions skills/kody/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ kody logout

The CLI opens a browser for Kody OAuth (PKCE + Client ID Metadata Documents).
If a browser cannot open, it prints the URL. Tokens (access + refresh) are
stored in the OS keychain on macOS, Windows, and Linux. Linux without Secret
Service falls back to a `0600` file under `$XDG_CONFIG_HOME/kody`.
stored in the OS keychain on macOS, Windows, and Linux (Secret Service).
Linux without Secret Service — including headless machines that only have an
in-memory kernel keyring — falls back to a `0600` file under
`$XDG_CONFIG_HOME/kody`.

Never ask the user to paste tokens into chat.

Expand Down
64 changes: 47 additions & 17 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ export type SecretBackend = {
delete(): boolean
}

/** Linux-only: require Secret Service. Ignored on macOS and Windows. */
export const secretServiceKeyringOptions = {
linux: { store: 'secret-service' },
} as const

export type StoreResolution = {
createKeyring?: (mcpUrl: string) => SecretBackend
fileStorePath?: (mcpUrl: string) => string
}

export function accountForMcpUrl(mcpUrl: string): string {
return `cli:${new URL(mcpUrl).origin}`
}
Expand Down Expand Up @@ -82,7 +92,11 @@ export function createFileBackend(path: string): SecretBackend {
}

export function createKeyringBackend(mcpUrl: string): SecretBackend {
const entry = new Entry(keyringService, accountForMcpUrl(mcpUrl))
const entry = new Entry(
keyringService,
accountForMcpUrl(mcpUrl),
secretServiceKeyringOptions,
)
return {
kind: 'keyring',
get() {
Expand All @@ -105,15 +119,24 @@ export function createKeyringBackend(mcpUrl: string): SecretBackend {
}
}

function fileBackendFor(
mcpUrl: string,
resolution?: StoreResolution,
): SecretBackend {
const path = resolution?.fileStorePath?.(mcpUrl) ?? fileStorePath(mcpUrl)
return createFileBackend(path)
}

export function resolveBackend(
mcpUrl: string,
preferred?: SecretBackend,
resolution?: StoreResolution,
): SecretBackend {
if (preferred) return preferred
try {
return createKeyringBackend(mcpUrl)
return (resolution?.createKeyring ?? createKeyringBackend)(mcpUrl)
} catch {
return createFileBackend(fileStorePath(mcpUrl))
return fileBackendFor(mcpUrl, resolution)
}
}

Expand All @@ -125,36 +148,42 @@ export function parseCredentials(raw: string): StoredCredentials {
return parsed
}

function readParsed(store: SecretBackend): StoredCredentials | null {
const raw = store.get()
if (!raw) return null
return parseCredentials(raw)
}

export function loadCredentials(
mcpUrl: string = defaultMcpUrl,
backend?: SecretBackend,
resolution?: StoreResolution,
): StoredCredentials | null {
const store = resolveBackend(mcpUrl, backend)
const store = resolveBackend(mcpUrl, backend, resolution)
try {
const raw = store.get()
if (!raw) return null
return parseCredentials(raw)
const loaded = readParsed(store)
if (loaded) return loaded
} catch (error) {
if (store.kind === 'keyring' && !backend) {
const fallback = createFileBackend(fileStorePath(mcpUrl))
const raw = fallback.get()
return raw ? parseCredentials(raw) : null
}
throw error
if (!(store.kind === 'keyring' && !backend)) throw error
}
if (store.kind === 'keyring' && !backend) {
return readParsed(fileBackendFor(mcpUrl, resolution))
}
return null
}

export function saveCredentials(
credentials: StoredCredentials,
backend?: SecretBackend,
resolution?: StoreResolution,
): { backend: SecretBackend } {
const store = resolveBackend(credentials.mcpUrl, backend)
const store = resolveBackend(credentials.mcpUrl, backend, resolution)
try {
store.set(JSON.stringify(credentials))
return { backend: store }
} catch (error) {
if (store.kind === 'keyring' && !backend) {
const fallback = createFileBackend(fileStorePath(credentials.mcpUrl))
const fallback = fileBackendFor(credentials.mcpUrl, resolution)
fallback.set(JSON.stringify(credentials))
return { backend: fallback }
}
Expand All @@ -165,16 +194,17 @@ export function saveCredentials(
export function deleteCredentials(
mcpUrl: string = defaultMcpUrl,
backend?: SecretBackend,
resolution?: StoreResolution,
): { deleted: boolean; backend: SecretBackend } {
const store = resolveBackend(mcpUrl, backend)
const store = resolveBackend(mcpUrl, backend, resolution)
let deleted = false
try {
deleted = store.delete()
} catch {
deleted = false
}
if (store.kind === 'keyring' && !backend) {
const file = createFileBackend(fileStorePath(mcpUrl))
const file = fileBackendFor(mcpUrl, resolution)
deleted = file.delete() || deleted
}
return { deleted, backend: store }
Expand Down
149 changes: 148 additions & 1 deletion test/store.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict'
import { chmodSync, mkdtempSync, statSync } from 'node:fs'
import { chmodSync, existsSync, mkdtempSync, readFileSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
Expand All @@ -11,6 +11,9 @@ import {
saveCredentials,
loadCredentials,
deleteCredentials,
resolveBackend,
secretServiceKeyringOptions,
type SecretBackend,
type StoredCredentials,
} from '../src/store.js'

Expand All @@ -27,6 +30,31 @@ const sample: StoredCredentials = {
scope: 'profile email',
}

function memoryKeyring(initial: string | null = null): SecretBackend & {
value: string | null
} {
const store: SecretBackend & { value: string | null } = {
kind: 'keyring',
value: initial,
get() {
return store.value
},
set(value: string) {
store.value = value
},
delete() {
const had = store.value !== null
store.value = null
return had
},
}
return store
}

function tempFilePath(): string {
return join(mkdtempSync(join(tmpdir(), 'kody-cli-')), 'credentials.json')
}

test('accountForMcpUrl is origin-scoped', () => {
assert.equal(accountForMcpUrl('https://kody.codes/mcp'), 'cli:https://kody.codes')
assert.equal(
Expand All @@ -40,6 +68,10 @@ test('fileStorePath uses XDG on linux-like homes', () => {
assert.match(path, /credentials-kody\.codes\.json$/)
})

test('Linux keyring is pinned to Secret Service so keyutils cannot silently win', () => {
assert.equal(secretServiceKeyringOptions.linux.store, 'secret-service')
})

test('file backend stores, loads, and deletes credentials', () => {
const dir = mkdtempSync(join(tmpdir(), 'kody-cli-'))
const backend = createFileBackend(join(dir, 'credentials.json'))
Expand All @@ -58,3 +90,118 @@ test('parseCredentials rejects invalid payloads', () => {
assert.throws(() => parseCredentials('{}'), /invalid/i)
assert.throws(() => parseCredentials('{"version":2}'), /invalid/i)
})

test('resolveBackend uses the file store when the keyring constructor throws', () => {
const path = tempFilePath()
const backend = resolveBackend(sample.mcpUrl, undefined, {
createKeyring() {
throw new Error('Secret Service is unavailable')
},
fileStorePath: () => path,
})
assert.equal(backend.kind, 'file')
assert.equal(backend.path, path)
})

test('saveCredentials writes the file when Secret Service is unavailable', () => {
const path = tempFilePath()
const saved = saveCredentials(sample, undefined, {
createKeyring() {
throw new Error('Secret Service is unavailable')
},
fileStorePath: () => path,
})
assert.equal(saved.backend.kind, 'file')
assert.equal(saved.backend.path, path)
assert.equal(existsSync(path), true)
if (process.platform !== 'win32') {
assert.equal(statSync(path).mode & 0o777, 0o600)
}
assert.deepEqual(loadCredentials(sample.mcpUrl, saved.backend), sample)
})

test('saveCredentials falls back to the file when keyring set throws', () => {
const path = tempFilePath()
const keyring: SecretBackend = {
kind: 'keyring',
get: () => null,
set() {
throw new Error('setPassword failed')
},
delete: () => false,
}
const saved = saveCredentials(sample, undefined, {
createKeyring: () => keyring,
fileStorePath: () => path,
})
assert.equal(saved.backend.kind, 'file')
assert.equal(JSON.parse(readFileSync(path, 'utf8')).accessToken, 'access-1')
})

test('loadCredentials falls back to the file when keyring credentials are invalid', () => {
const path = tempFilePath()
createFileBackend(path).set(JSON.stringify(sample))
const loaded = loadCredentials(sample.mcpUrl, undefined, {
createKeyring: () => memoryKeyring('{"version":1}'),
fileStorePath: () => path,
})
assert.deepEqual(loaded, sample)
})

test('loadCredentials surfaces invalid file credentials after a keyring miss', () => {
const path = tempFilePath()
createFileBackend(path).set('{"version":1}')
assert.throws(
() =>
loadCredentials(sample.mcpUrl, undefined, {
createKeyring: () => memoryKeyring(null),
fileStorePath: () => path,
}),
/invalid/i,
)
})

test('loadCredentials falls back to the file when the keyring returns null', () => {
const path = tempFilePath()
createFileBackend(path).set(JSON.stringify(sample))
const loaded = loadCredentials(sample.mcpUrl, undefined, {
createKeyring: () => memoryKeyring(null),
fileStorePath: () => path,
})
assert.deepEqual(loaded, sample)
})

test('loadCredentials prefers keyring credentials over a leftover file', () => {
const path = tempFilePath()
createFileBackend(path).set(
JSON.stringify({ ...sample, accessToken: 'file-access' }),
)
const loaded = loadCredentials(sample.mcpUrl, undefined, {
createKeyring: () =>
memoryKeyring(JSON.stringify({ ...sample, accessToken: 'keyring-access' })),
fileStorePath: () => path,
})
assert.equal(loaded?.accessToken, 'keyring-access')
})

test('loadCredentials does not use the file when a preferred backend returns null', () => {
const path = tempFilePath()
createFileBackend(path).set(JSON.stringify(sample))
const loaded = loadCredentials(sample.mcpUrl, memoryKeyring(null), {
fileStorePath: () => path,
})
assert.equal(loaded, null)
})

test('deleteCredentials removes both keyring and file copies', () => {
const path = tempFilePath()
const keyring = memoryKeyring(JSON.stringify(sample))
createFileBackend(path).set(JSON.stringify(sample))
const result = deleteCredentials(sample.mcpUrl, undefined, {
createKeyring: () => keyring,
fileStorePath: () => path,
})
assert.equal(result.deleted, true)
assert.equal(keyring.value, null)
assert.equal(existsSync(path), false)
})