Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
28 changes: 27 additions & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ Install the official Bun release signed by Oven:
curl -fsSL https://bun.com/install | bash
```

**Why "Always Allow" does not work:** the Keychain ACL stored on each item is identity-based, so it matches any Oven-signed Bun anywhere on disk. A binary that is ad-hoc signed or has no team ID can never satisfy that requirement. Per [#3020](https://github.com/vybestack/llxprt-code/issues/3020), `change_acl` on these items has an empty application list, so clicking **Always Allow** is silently discarded — the prompt recurs on every credential read. Replacing the binary with Oven's signed build is the only durable fix.
**Why "Always Allow" does not work:** the Keychain ACL stored on each item is identity-based, so it matches any Oven-signed Bun anywhere on disk. A binary that is ad-hoc signed or has no team ID can never satisfy that requirement. The structural fact, confirmed in source: every LLxprt credential item is created through macOS `SecKeychainAddGenericPassword`, which takes no access-control parameter, so the item's ACL is the macOS default `SecAccess`. That default carries the required **owner** entry authorizing `change_acl` with an **empty** trusted-application list. Per Apple's documentation an empty list means no application is trusted to amend the ACL **without user confirmation** (unlike a null list, which means any application may). No layer of the dependency chain (`@napi-rs/keyring` 1.3.0 → `keyring-core` → `apple-native-keyring-store` 1.0.1 → `security-framework` 3.7.0) exposes any way to supply a different `SecAccess`, so LLxprt cannot construct or repair the ACL of these items from TypeScript. The exact `securityd` mechanism by which the observed "Always Allow" grant fails to persist is not established by this work — confirming it would require a native ACL inspection of the item immediately before and after a grant — but the symptom (the prompt recurs on every credential read) is consistent with it. Installing an Oven-signed Bun with a stable team identity clears the symptom in practice.

**Why it is a warning, not a hard failure:** a Bun that is ad-hoc signed or has no team identity runs LLxprt Code correctly in every respect except Keychain access, and some users keep no credentials in the Keychain at all. Failing closed would break those working setups. Skipping it and falling through to the bundled Bun would silently re-enable the npm-unlink failure mode that #2962 exists to prevent, so the launcher warns and continues to use the selected Bun.

Expand All @@ -167,6 +167,32 @@ codesign -dv --requirements - "$(command -v bun)" 2>&1

This check is macOS-only; Linux and Windows never key credential access on code identity.

##### Runtime diagnostic: "the grant is not being persisted"

If the launcher warning was not present at startup (for example, you started LLxprt Code from an Oven-signed Bun but the grant is still not sticking), LLxprt Code watches for the symptom itself: when the **same** credential has to be authorized interactively a second time in a session — after an earlier authorization for it had already completed — it prints a one-time notice to stderr explaining that the grant is not being persisted and that the prompt will keep recurring. This is a heuristic based on how long a successful credential read blocked, so a pathologically slow keychain can also trigger it. Credential access is **not** interrupted by this notice — the value is still read and returned, so your session keeps working. The notice fires at most once per process.

##### Recovery: disable the OS keyring

If you cannot install an Oven-signed Bun right now, you can route LLxprt's own SecureStore credentials through the encrypted file fallback and leave the Keychain alone:

```bash
export LLXPRT_DISABLE_OS_KEYRING=1
```

Add this to your shell profile so it persists; do not toggle it per-invocation (see the caveats below). Only the exact value `1` opts out. With it set:

- **SecureStore credentials** (named API keys and similar) are stored in the encrypted file store under your OS data directory (`~/Library/Application Support/llxprt-code/secure-store/` on macOS), using the same AES-256-GCM envelope as the automatic fallback.
- Existing Keychain items are **left in place** — nothing is deleted and nothing is migrated. You will re-authenticate once so the credential is written to the file store.

**Limitations of this escape hatch (read before relying on it):**

- **MCP server OAuth tokens are not covered.** MCP server OAuth uses `KeychainTokenStorage`, which requires the OS keyring and throws when it is unavailable. While this variable is set, MCP server OAuth is unavailable. This is the same pre-existing limitation that already applies on hosts with no keyring at all (headless Linux, containers); it is not specific to this variable.
- **Stores with `fallbackPolicy: 'deny'` still fail.** A store constructed with a deny policy raises `UNAVAILABLE` rather than writing a file. The opt-out deliberately does not defeat a deny policy.
- **The machine secret can change, and v:2 fallback files are tied to it.** When the machine secret lives only in the OS keyring, disabling the keyring makes machine-secret resolution fall through to the file path and generate a _different_ secret. Existing v:2 fallback files then fail to decrypt with a `CORRUPT` error, and files written while opted out likewise fail to decrypt after the variable is removed. This is a loud, visible failure (not silent), which is why the variable should be set persistently rather than toggled per-invocation.
- **Re-enabling reads the Keychain first.** After the variable is removed, `SecureStore.get()` consults the keyring before the fallback file, so a credential you re-authenticated while opted out is silently shadowed by the older Keychain value, and a delete performed while opted out leaves the Keychain item in place. This silent behavior is the other reason to treat the variable as persistent.

This is the interim escape hatch for [#3020](https://github.com/vybestack/llxprt-code/issues/3020). The full, settings-driven keyring opt-out is tracked in [#2928](https://github.com/vybestack/llxprt-code/issues/2928).

### Common Authentication Errors

**`Failed to login. Message: Request contains an invalid argument`**
Expand Down
5 changes: 5 additions & 0 deletions packages/storage/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ export {
isSecureStoreError,
isRuntimeReplacedError,
} from './secure-store/secure-store-errors.js';
export {
isKeychainGrantPersistenceBroken,
GRANT_NOT_PERSISTING_MESSAGE,
GRANT_NOT_PERSISTING_REMEDIATION,
} from './secure-store/keychain-grant-persistence.js';
export {
ProviderKeyStorage,
KEY_NAME_REGEX,
Expand Down
36 changes: 35 additions & 1 deletion packages/storage/src/secure-store/default-keyring-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { NullStorageLoggerImpl } from '../types/logger.js';
import { isRuntimeReplaced } from './runtime-identity.js';
import { assertRuntimeNotReplaced } from './runtime-replaced-errors.js';
import { verifyKeyringDelete } from './keyring-delete-verification.js';
import { recordAuthorizedKeyringRead } from './keychain-grant-persistence.js';
import { SecureStoreError } from './secure-store-errors.js';
import type { KeyringAdapter } from './secure-store.js';

Expand Down Expand Up @@ -62,6 +63,20 @@ function isOsKeyringDisabledForTests(): boolean {
return process.env[DISABLE_OS_KEYRING_ENV] === '1';
}

/**
* Production opt-out: when `LLXPRT_DISABLE_OS_KEYRING=1` the factory returns
* null and all credential traffic routes to the encrypted file fallback. This
* is the user-facing recovery lever for the discarded Keychain grant (issue
* #3020). Distinct from the test marker above, which exists for suite
* isolation: this one is shipped, documented, and leaves existing Keychain
* items untouched.
*/
const PROD_DISABLE_OS_KEYRING_ENV = 'LLXPRT_DISABLE_OS_KEYRING';

function isOsKeyringDisabled(): boolean {
return process.env[PROD_DISABLE_OS_KEYRING_ENV] === '1';
}

function isErrorWithCode(value: unknown): value is { code: string } {
return (
typeof value === 'object' &&
Expand Down Expand Up @@ -244,6 +259,9 @@ export async function createDefaultKeyringAdapter(): Promise<KeyringAdapter | nu
if (isOsKeyringDisabledForTests()) {
return null;
}
if (isOsKeyringDisabled()) {
return null;
}
try {
const module = await import('@napi-rs/keyring');
const keyring = resolveKeyringModule(module);
Expand All @@ -255,7 +273,23 @@ export async function createDefaultKeyringAdapter(): Promise<KeyringAdapter | nu
const adapter: KeyringAdapter = {
getPassword: async (service: string, account: string) => {
const entry = new kr.AsyncEntry(service, account);
return entry.getPassword();
// Issue #3020: time the native read with a monotonic clock
// (performance.now) so a repeatedly slow successful read of the
// SAME credential surfaces the discarded "Always Allow" grant.
// Only non-null reads are recorded; a rejection propagates
// untouched because the await throws before this record call is
// reached. The correlation key is an opaque Map key only — never
// logged or interpolated into any message.
const startedAt = performance.now();
const value = await entry.getPassword();
if (value !== null) {
recordAuthorizedKeyringRead({
credentialKey: `${service}\u0000${account}`,
startedAt,
endedAt: performance.now(),
});
}
return value;
},
setPassword: async (
service: string,
Expand Down
Loading
Loading