Skip to content

fix(auth): share OS credentials across runtimes - #3423

Open
just-cameron wants to merge 16 commits into
mainfrom
overlord/fix-global-keychain-auth
Open

fix(auth): share OS credentials across runtimes#3423
just-cameron wants to merge 16 commits into
mainfrom
overlord/fix-global-keychain-auth

Conversation

@just-cameron

@just-cameron just-cameron commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Problem

Letta Code stores Cloud credentials with Bun.secrets, but the published CLI and listeners can run under Node. Node therefore treated secure storage as unavailable even when the same OS credential already existed, causing global installs and listeners to lose authentication.

This fixes credential availability at the storage boundary instead of changing the launcher runtime. Credential tests now use unique service names and never inspect live letta-code entries.

Approach

src/utils/secrets.ts now selects a SecretBackend at runtime:

  • Bun: keep using Bun.secrets.
  • Node on macOS: preserve Keychain mutation ownership. When Bun is available, Node delegates read/write/delete operations through that owning Bun runtime; writes opt into Bun's headless allowUnrestrictedAccess mode. When Bun is absent, Node uses the built-in /usr/bin/security boundary directly. This avoids GUI permission prompts while keeping a standalone Node backend for new installations.
  • Node on Windows: call Credential Manager through PowerShell stdin using Bun's exact generic-credential representation: target ${service}/${name}, username name, raw UTF-8 bytes, and enterprise persistence.
  • Node on Linux: call secret-tool through stdin using Bun's exact Secret Service attributes: service, account, and xdg:schema=com.oven-sh.bun.Secret.

Secret values are never placed in process arguments. Command output is bounded and subprocesses time out after ten seconds. Missing OS services still use the existing settings fallback.

Credential reads are also bounded: backend availability checks are structural and never inspect a credential, API and refresh tokens hydrate through one single-flight cache per process, and LETTA_API_KEY skips secure storage entirely. A failed hydration gets one later retry for transient recovery, then stops until reload or restart.

The earlier Node-to-Bun launcher bootstrap has been removed. The published entrypoint remains an ordinary Node shebang, and the final implementation adds no native npm dependency.

Compatibility proof

Tests write, read, and delete the same randomly named OS entries in both directions between Bun.secrets and the explicit Node backend. Platform CI requires this interoperability rather than silently skipping it.

The macOS regression test additionally builds the backend for Node, launches it against a legacy restricted Bun entry, then launches a fresh second Node process to read and delete both legacy and newly written entries. That covers the real Keychain application-ownership and process-restart boundaries; the earlier in-process test alone could not.

Validation on this branch:

  • bun run check — 12/12 checks pass.
  • Auth cache and transient recovery — 9 pass, 0 fail.
  • Cross-runtime backend interoperability — 17 pass, 0 fail.
  • Secret namespace safety — 14 pass, 0 fail.
  • Isolated settings lifecycle — 70 pass, 0 fail.
  • Built Node bundle with Keychain auth — exactly two non-mutating reads (get,get) for API and refresh tokens, zero stderr.
  • Built Node bundle with LETTA_API_KEY — zero Keychain/helper operations, zero stderr.
  • Linux x64/arm64, macOS arm64, Windows x64, Node 18, package-install, lint, and integration CI all pass on the current head.

Risk and limits

The in-process cache intentionally does not poll for credential changes made by another process; Letta Code writes update it directly, and reload or restart rehydrates from secure storage.

The main risk is matching Bun's undocumented platform storage identity exactly. Tests pin target/schema, persistence, encoding, and bidirectional behavior to catch drift.

On macOS, cross-runtime headless access deliberately uses a per-user rather than per-executable Keychain ACL. Credentials remain encrypted by Keychain, but other processes running as the same user can invoke the same credential APIs. This is the same tradeoff exposed by Bun's allowUnrestrictedAccess option and is required for Node and Bun listeners to share credentials without GUI prompts.

The macOS security prompt interface cannot safely accept multiline values through stdin. On systems without Bun, new multiline writes fail rather than exposing a secret in argv. Existing multiline Bun entries, and new multiline writes when Bun is available, remain intact. If the Bun executable that owns a legacy entry is removed, replaced, or missing from PATH, Node can still read unrestricted entries but mutation of an older restricted item may require reauthentication.

Manual validation

  • macOS: the Node-built CLI listed agents twice from existing Keychain credentials with LETTA_API_KEY and LETTA_BASE_URL unset; no Keychain or sign-in prompt appeared.

just-cameron and others added 4 commits July 20, 2026 13:57
Letta Code (agent-c2adbf5c-8419-4211-8cd8-3740db164974)

Keep the published bin entry Windows-shim-safe while replacing Node with Bun before CLI initialization, so scriptless global installs retain secure keychain access. Fixes LET-9257.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>
Letta Code (agent-c2adbf5c-8419-4211-8cd8-3740db164974)

Probe the discovered Bun executable before Node 22 replaces itself, preserving the Node fallback for corrupt or incompatible runtimes and covering both recursion guards.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>
Letta Code (agent-c2adbf5c-8419-4211-8cd8-3740db164974)

Replace launcher runtime switching with platform-native secure-storage backends that preserve existing Bun Keychain, Credential Manager, and Secret Service entries under Node. Isolate credential tests from live user entries and prove the shared storage identity in both directions.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>
Letta Code (agent-c2adbf5c-8419-4211-8cd8-3740db164974)

Integrate the latest channel access-control and schedule-runner changes without rewriting the existing PR branch, so the runtime-independent credential fix is reviewed against current main.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>
@just-cameron just-cameron changed the title fix(cli): prefer Bun without install scripts fix(auth): share OS credentials across runtimes Jul 21, 2026
just-cameron and others added 3 commits July 21, 2026 10:14
Letta Code (agent-c2adbf5c-8419-4211-8cd8-3740db164974)

Install secret-tool on Linux CI runners after unlocking GNOME Keyring so the required Bun-to-Node interoperability test reaches the same command boundary used in production.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>
Letta Code (agent-c2adbf5c-8419-4211-8cd8-3740db164974)

Replace the native Keychain wrapper with the error-preserving macOS security boundary, and migrate restricted legacy Bun entries through their owning Bun runtime before Node reads them. Keep new macOS writes headless-compatible without putting secret values in process arguments, prove the actual Node process boundary, and normalize empty-value deletion across runtimes.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>
Letta Code (agent-c2adbf5c-8419-4211-8cd8-3740db164974)

Keep read, write, and delete operations on the owning Bun boundary whenever Bun is available, rather than assuming unrestricted read access transfers mutation ownership to Node. Exercise the lifecycle across a fresh Node process and construct the fake Darwin backend explicitly on Linux CI.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>
Comment thread src/utils/secret-backends.ts Outdated
const bunPath = getBunExecutablePath(runtime);
if (!bunPath) return null;

const result = await runSecretCommand(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper runs bun -e from the current project directory with the full inherited env plus the keychain locator and, for set, the secret payload on stdin. Bun honors project-local config/preloads for bun -e, so simply running Letta Code inside a repo with a bunfig.toml preload can execute repo-controlled code inside the credential bridge process. That is broader than the mod threat model: users expect installed mods to run code, but not arbitrary cwd Bun config during auth/keychain migration. Please run this bridge from a trusted neutral cwd and disable/sanitize Bun project config/preloads and Bun-affecting env before spawning.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in ba6474b8. The Bun Keychain bridge now runs from a freshly created empty temporary directory and strips Bun project/config/preload environment variables before spawning. The regression test verifies that repo-local bunfig.toml configuration is not consulted and the bridge does not inherit the caller’s working directory.

— Overlord (agent-c2adbf5c-8419-4211-8cd8-3740db164974)

Comment thread src/utils/secret-backends.ts
@cpacker

cpacker commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Fixed both review findings in ba6474b8:

  • Runs the macOS Bun keychain bridge from a freshly-created empty temp cwd and strips Bun project/config preload env before spawning, so repo-local bunfig.toml/preload config is not consulted during credential migration.
  • Makes channel secret redaction atomic with successful keyring writes: upsertChannelAccountWithSecrets writes secrets first, propagates failures, and only then persists refs/redacts plaintext. Sync saves in keyring mode keep plaintext until an async path has actually persisted secrets, avoiding dangling refs.

Validation:

  • bun test src/utils/secret-backends.test.ts src/utils/secrets.test.ts src/test-utils/test-process-env.test.ts src/channels/credential-store.test.ts — 41 pass
  • bun run check — 12/12 checks pass

@just-cameron
just-cameron marked this pull request as draft July 21, 2026 22:14
@just-cameron

Copy link
Copy Markdown
Contributor Author

Converted to a draft after getting too many signin prompts, though this may have been fixed by ba6474b.

just-cameron and others added 3 commits July 21, 2026 16:46
Letta Code (agent-c2adbf5c-8419-4211-8cd8-3740db164974)

Hydrate API and refresh tokens once per process, make availability checks structural, and bypass secure storage entirely when LETTA_API_KEY is authoritative. Bound transient read recovery and isolate credential tests so normal startup no longer amplifies Keychain access or risks live credentials.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>
Letta Code (agent-c2adbf5c-8419-4211-8cd8-3740db164974)

Route LCD account mutations through the awaited credential-storage path so failures cannot redact or orphan credentials before a successful keyring operation. Keep ordinary updates and deletes non-hydrating while preserving rollback access when route ownership changes.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>
Letta Code (agent-c2adbf5c-8419-4211-8cd8-3740db164974)

Keep hydrated keyring values redacted during synchronous metadata saves and delay route cleanup until credential deletion succeeds. This prevents plaintext credential re-persistence and preserves related account state across keyring failures.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>
@just-cameron
just-cameron marked this pull request as ready for review July 22, 2026 23:12
lettamate and others added 5 commits July 30, 2026 11:19
Resolve the conflict on the source-size baseline while preserving awaited credential writes, keychain redaction, and listener channel secret paths.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>
Letta Code (agent-c2adbf5c-8419-4211-8cd8-3740db164974)

Preserve main deletion order by removing local account state before surfacing keyring cleanup failures. The regression now verifies account, route, target, pairing, and multi-secret cleanup behavior.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>
Letta Code (agent-c2adbf5c-8419-4211-8cd8-3740db164974)

Carry secure channel account writes through the new process-boundary protocol while retaining the current gateway and listener refactor.
Letta Code (agent-c2adbf5c-8419-4211-8cd8-3740db164974)

Preserve secure-reference metadata across in-process account mutations and roll back multi-field keyring writes when any credential update fails.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>
Letta Code (agent-c2adbf5c-8419-4211-8cd8-3740db164974)

Serialize each channel credential transaction through its shared account file and roll back only completed keyring writes so failed updates cannot clobber concurrent successes.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>
@cursor

cursor Bot commented Aug 3, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@cpacker cpacker left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

The underlying fix here is correct and clearly needed, and the platform work is careful. My concerns are about packaging and rollout sequencing, not correctness — specifically that this bundles five independent concerns and performs an irreversible credential-format transition in a single deploy.

Recommending we restructure this into a stack before landing any part of it. Details below.


What's good

  • The compatibility-proof approach is the right one. Bidirectional read/write tests between Bun.secrets and each Node backend, pinning target format, schema, persistence, and encoding, is exactly how you defend against undocumented-format drift. The macOS test that builds the bundle, runs it against a legacy restricted entry, then launches a second fresh process correctly targets the ACL-and-process-restart boundary an in-process test can't reach.
  • Secrets never touch argv. Values go over stdin everywhere; the macOS set deliberately keeps -w last and refuses multiline rather than falling back to argv. Output is byte-bounded and subprocesses time out.
  • bun -e runs in a fresh mkdtemp cwd with BUN_CONFIG/BUN_OPTIONS stripped — prevents a project bunfig.toml or env preload from injecting code into the credential helper. Easy to miss.
  • Making isKeychainAvailable() structural is correct. Probing by reading a live credential was causing the very Keychain access it was trying to detect.
  • The self-disclosed risk section is unusually honest, including the allowUnrestrictedAccess tradeoff (any process running as the same user can now read the API key without a prompt, where previously it was ACL-bound to bun). That tradeoff is real and unavoidable for headless cross-runtime sharing, and it's correctly surfaced rather than buried.
  • Layer placement, @/ imports, kebab-case, named export function, size-baseline ratchet, and isolated-test registration all conform to repo conventions.

Main concern: the Bun→Node flip can log out existing macOS users

The dangerous interaction is removing the polyglot shebang combined with macOS Keychain ACLs.

Bun.secrets.set() defaults to an ACL restricted to the writing executable. Every currently-authenticated macOS user who installed with Bun present has a Keychain item owned by the bun binary. After this PR they run under Node.

Scenario Outcome
macOS, was on Bun, bun on this process's PATH ✅ Works — Node spawns bun -e <helper>, Bun is the ACL owner
macOS, was on Bun, bun NOT on this process's PATH ⚠️ Breaks — falls to security find-generic-password against a restricted item → GUI auth prompt, or exit 51 headless → read fails → treated as unauthenticated
Already on Node (any OS) ✅ Net improvement — keychain now works where it didn't
Linux without libsecret-tools isAvailable() correctly false; settings-file fallback holds

The reason I'd call row 2 a real risk rather than an edge case: findExecutableOnPath (src/utils/secret-backends.ts) consults only env.PATH. Bun installs to ~/.bun/bin, which is added by shell profiles — and listeners started from launchd, systemd, a GUI-launched process, or cron get a minimal PATH that does not include it. So the trigger isn't "the user uninstalled Bun," it's "this particular process doesn't have Bun on its PATH" — which is exactly the listener case this PR is trying to fix.

The failure is also quiet and slow. Each failed read waits on the 10s SECRET_COMMAND_TIMEOUT_MS, and SecureTokenCache.hydrateOnce gives up permanently after the second failure:

if (loaded.complete || this.failedHydrations > 0) {
  this.hydrated = true;   // stops retrying for the process lifetime
} else {
  this.failedHydrations += 1;
}

Two 10s hangs, then silently unauthenticated until restart.

There is no proactive ACL migration. allowUnrestrictedAccess: true is only applied on writes. Legacy restricted items get rewritten as unrestricted only when a token write happens to occur — which needs Bun on PATH at that moment. A user who doesn't re-auth is never migrated.

What I'd want before this ships

  1. Ship the ACL migration under Bun first. Release the backends with the shebang flip reverted. Under Bun, proactively rewrite existing items with allowUnrestrictedAccess: true. Let that reach adoption, then flip to Node in a later release. This turns the risky scenario into a no-op because items are already unrestricted.
  2. Widen Bun discovery beyond PATH — check ~/.bun/bin/bun and $BUN_INSTALL/bin/bun explicitly.
  3. Never let a Keychain read block on a GUI prompt. A modal Keychain dialog appearing twice on CLI startup is its own incident. Drop the timeout well below 10s for the security fallback.
  4. Confirm the recovery path. LETTA_SKIP_KEYCHAIN_CHECK=1 forces the settings-file fallback and is a usable kill switch — but only for users who have a settings-file token. Anyone on the Keychain has to re-run letta login. Support should know that's the answer.

Structural: five concerns in one PR

# Concern Files ~Lines
1 Node OS-credential backends secret-backends.ts + tests, secrets.ts ~1,850
2 Remove polyglot shebang / build patch postinstall-patches.js, build.js ~40
3 Single-flight token cache + LETTA_API_KEY short-circuits secure-token-cache.ts, settings-manager.ts ~200
4 Channel secret write serialization + rollback accounts.ts, service-accounts.ts, protocol-* ~780
5 Test service-name namespacing secrets.ts, isolated-unit-tests.json ~100

(4) doesn't belong here. Per-channel write locking and multi-field credential rollback fix a concurrent-writer clobbering bug, not a runtime-availability bug. It's ~780 lines of transactional logic reviewable entirely on its own, and bundling it means a revert of the auth change also reverts that fix. Same for (3) — a legitimate perf improvement, but it changes credential precedence (below) and deserves its own review.

Suggested sequence: (1)+(5) → (3) → (4), with (2) last and separately, since (2) is the only piece that changes behavior for already-working users.


Specific findings

1. Credential precedence silently changed. getSettingsWithSecureTokens:

// before: keychain wins; process.env not consulted here
!secureTokens.apiKey && baseSettings.env?.LETTA_API_KEY ? ... : secureTokens.apiKey
// after: process.env wins over keychain
process.env.LETTA_API_KEY ?? secureTokens.apiKey ?? baseSettings.env?.LETTA_API_KEY

This aligns with src/index.ts:860, which already preferred process.env, so it's a consistency fix rather than a bug. But a user with a stale or differently-scoped LETTA_API_KEY exported in their shell and an active Cloud login will silently switch identity. Worth a changelog line at minimum.

2. getSecretBackend() returning non-null doesn't mean usable. On Linux it always returns a linux-secret-service backend even with no secret-tool and no D-Bus; only isAvailable() knows. All current callers gate on isKeychainAvailable() first so this is correct today, but getBackendOrThrow() reads as a usability guarantee it doesn't provide. Consider returning null when the backend can't function.

3. Windows: C# is recompiled on every credential operation. Add-Type -TypeDefinition invokes the C# compiler per PowerShell spawn — realistically 1–3s per get/set/delete. Two reads at startup means a multi-second delay before Windows users are authenticated. The single-flight cache limits it to once per process, but worth measuring; batching both token reads into one request would halve it.

4. The Bun helper's restore path drops the unrestricted ACL. In BUN_MACOS_KEYCHAIN_HELPER_SCRIPT, the rollback is Bun.secrets.set({ ...locator, value: previousValue }) with no allowUnrestrictedAccess: true — so a failed replace silently converts an unrestricted item back to restricted, re-creating the exact condition this PR exists to avoid.

5. add-generic-password -U -A may not reset an existing item's ACL. -U updates in place; whether -A is reapplied to an existing ACL isn't guaranteed. If it isn't, the Node-without-Bun path can never self-heal a legacy item. Worth an explicit test: create a restricted item, update it via the security path, verify a different binary can then read it.

6. Non-reentrant lock with no guard. runSerializedChannelSecretOperation isn't reentrant. Today no locked body calls back into hydrateChannelAccountSecrets / getChannelAccountWithSecrets, so it's correct — but a future caller adding one self-deadlocks forever with no timeout and no error. A dev-mode "already holding lock for this channel" assertion would make that a loud failure instead of a hang.

7. SecureTokenCache.merge never removes. if (tokens.apiKey) this.tokens.apiKey = ... means a token deleted by another process is never dropped from the cache. Documented as intentional and clear() covers the in-process delete path — noting as a known sharp edge, not a defect.

8. No minimum Bun version check. allowUnrestrictedAccess is passed unconditionally. On an older Bun that ignores the option, items stay restricted and the user silently lands in the broken scenario. A version gate, or a post-write read-back verification, would catch this.

9. flushPendingChannelSecretWrites is now an empty no-op still called at three sites. Fine as a compatibility shim, but if it's only for tests, prefer deleting it and updating the tests in the same change rather than leaving a misleading name.

@cpacker

cpacker commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Following up on my review — I've split this into a reviewable stack, and I want to correct the framing of my earlier comment.

On the diagnosis: it's right, and more definitively than this PR argues. I traced the mechanism. migrateTokensToSecrets deletes the settings-file copy once tokens move to the Keychain (settings-manager.ts:524), so after a user has run under Bun even once, a Node-run process has no token source at all — Keychain unreachable and the file fallback already erased. That's a hard logout every time, which is exactly the reported symptom. My review implied the diagnosis might be worth re-examining; it isn't.

I also over-weighted the deploy risk. The population my macOS ACL finding would newly break is largely the population already broken today, and the recovery path degrades to the settings-file fallback rather than bricking. It's worth fixing before the runtime flip, but it isn't the incident-level risk I framed it as.

The stack

bun run check passes 12/12 on each; full unit suite is 5,935 pass / 0 fail on the tip.

The useful property: #3655 alone fixes both reported symptoms without flipping any runtime. Node processes get real OS credential storage, and Bun writes opt into allowUnrestrictedAccess, so existing macOS items migrate to a per-user ACL on next write while Bun is still the runtime. That's the missing migration, and it comes for free by leaving the polyglot shebang alone. The shebang removal is intentionally not in the stack — it should land separately, after that migration has reached adoption.

Two things found while splitting

1. This branch is stale against main and will drop #3650's work. accounts.ts diverged — #3650 (WhatsApp messaging controls) added four messagePrefix handling sites that this branch predates. Taking this branch's version of the file wholesale breaks 4 WhatsApp service tests. #3657 applies the changes as a 3-way merge onto current main instead. This PR needs a rebase before it merges anywhere.

2. source-file-size-baseline.json here is also stale — it predates growth in protocol_v2.ts, file-commands.ts, and protocol-inbound.ts, so it fails check:file-size against current main.

One addition that isn't from this PR: #3656 includes a fix for a regression these backends would otherwise introduce on Linux. Migrating into the Secret Service and deleting the file fallback strands every process without DBUS_SESSION_BUS_ADDRESS — ssh, systemd, cron — which is this same bug relocated. It's a separate commit and can be dropped independently.

Suggest converting this to draft and keeping it as the reference implementation until the stack lands, rather than closing it — the multi-platform CI evidence and the format-matching details here are the expensive part to reproduce.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants