diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..89af86b
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,35 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ verify:
+ runs-on: macos-latest
+ timeout-minutes: 15
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ persist-credentials: false
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+ with:
+ bun-version: 1.3.14
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+ - name: Lint
+ run: bun run lint
+ - name: Typecheck
+ run: bun run typecheck
+ - name: Test
+ run: bun test
+ - name: Build
+ run: bun run build
+ - name: Compile server entry point
+ run: bun build src/server.ts --target=bun --outdir /tmp/dondo-build
diff --git a/AGENTS.md b/AGENTS.md
index 56ec8cf..1de2285 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,6 +13,10 @@
- UI CSS: `src/ui/styles.css`.
- Antigravity behavior: `src/antigravity/*`.
- Codex behavior: `src/codex/*`.
+- Cline behavior: `src/cline/*`.
+- Kiro behavior: `src/kiro/*`.
+- MiniMax behavior: `src/minimax/*`.
+- Shared account state: `src/account-state.ts`.
- Vault and encryption: `src/storage/*`.
- Shared types: `src/types.ts`.
- Config and constants: `src/config.ts`.
@@ -44,6 +48,18 @@ Do not reintroduce root launcher shims or barrel `index.ts` files. Import concre
"codex": {
"data": {},
"limits": {}
+ },
+ "cline": {
+ "data": {},
+ "limits": {}
+ },
+ "kiro": {
+ "data": {},
+ "limits": {}
+ },
+ "minimax": {
+ "data": {},
+ "limits": {}
}
}
```
@@ -52,7 +68,14 @@ Do not reintroduce root launcher shims or barrel `index.ts` files. Import concre
- `antigravity.limits` contains cached limit data.
- `codex.data` contains encrypted `~/.codex/auth.json` snapshots.
- `codex.limits` contains cached Codex ChatGPT usage data.
-- Do not add flat-vault migrations unless explicitly requested.
+- `cline.data` contains encrypted `~/.cline/data/settings/providers.json` snapshots; Cline has no limit cache.
+- `kiro.data` contains encrypted Kiro auth, profile, and client-registration snapshots.
+- `kiro.limits` contains cached Kiro usage data.
+- `minimax.data` contains encrypted MiniMax Agent config snapshots.
+- `minimax.limits` contains cached MiniMax quota and credit data.
+- The nested encrypted format is a hard cut. Do not add flat-vault, plaintext, or runtime compatibility migrations.
+- Preserve isolated corrupt entries across unrelated writes. They must remain deletable but not loadable, syncable,
+ refreshable, or exportable.
- Default app data path logic lives in `src/config.ts`.
## Verification
@@ -60,10 +83,10 @@ Do not reintroduce root launcher shims or barrel `index.ts` files. Import concre
Before finishing code changes, run:
```sh
-bun run typecheck
bun run lint
+bun run typecheck
bun test
bun build src/server.ts --target=bun --outdir /tmp/dondo-build
```
-All three must pass without TypeScript errors, Biome errors, or Biome warnings.
+All four must pass. `bun run lint` is the full Biome formatting, lint, and assist gate, with warnings treated as errors.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..b9de77c
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,54 @@
+# Contributing
+
+Dondo is a macOS-only Bun and TypeScript project. Install Bun 1.3.14 or newer and ensure the macOS `security` command
+is available.
+
+## Setup and development
+
+```sh
+bun install --frozen-lockfile
+bun run dev
+```
+
+The development watcher restarts the server for runtime TypeScript, TSX, CSS, package metadata, and icon changes. Test
+file edits do not restart it.
+
+The package entry point and HTTP server are in `src/server.ts`. Platform behavior lives in `src/antigravity/`,
+`src/codex/`, `src/cline/`, `src/kiro/`, and `src/minimax/`. Shared vault/encryption code is in `src/storage/`, shared
+types are in `src/types.ts`, configuration is in `src/config.ts`, and the Preact UI is in `src/ui/`.
+
+Do not add launcher shims, barrel exports, runtime compatibility layers, or dependencies without a concrete reduction in
+complexity. Import concrete files directly and use arrow functions.
+
+## Verification
+
+Run every gate before opening a pull request:
+
+```sh
+bun run lint
+bun run typecheck
+bun test
+bun run build
+bun build src/server.ts --target=bun --outdir /tmp/dondo-build
+```
+
+Use `bun run format` for formatting-only writes or `bun run fix` for Biome's safe formatter, lint, and assist fixes.
+`bun run lint` is the full read-only gate, including formatting, lint rules, assists, and warning rejection.
+`bun run build` produces a self-contained runnable `dist/` directory and the build smoke test launches that artifact.
+
+## Tests and secrets
+
+- Isolate filesystem tests with temporary `DONDO_VAULT`, auth, config, and data paths. Never target a real application
+ profile or vault.
+- Keychain tests should inject the command runner. If a test must touch macOS Keychain, use a dedicated service/account,
+ avoid parallel mutation, and clean it up.
+- The packaged UI smoke test starts a real local server, so keep its environment and data directory isolated.
+- Never commit, log, snapshot, or render credentials, Keychain payloads, auth/config contents, or decrypted exports.
+- Only `POST /api/{platform}/export` may return credential payloads. Keep it local-only, confirmed, non-cacheable, and
+ all-or-nothing.
+
+## Pull requests
+
+Keep changes narrow, add behavior-focused tests, preserve unrelated worktree changes, and document user-visible or
+breaking behavior. Include the commands you ran and their results. Changes to storage, export, switching, or token
+handling should explain their failure behavior and demonstrate that ordinary API responses remain redacted.
diff --git a/README.md b/README.md
index 0cff291..42d7121 100644
--- a/README.md
+++ b/README.md
@@ -8,227 +8,218 @@
[](https://bun.sh)
[](https://www.typescriptlang.org)
[](https://preactjs.com)
-[](https://biomejs.dev)
+[](https://biomejs.dev)
[](https://www.apple.com/macos)
[](https://antigravity.google)
[](https://openai.com/codex)
-[](https://kiro.dev)
[](https://cline.bot)
+[](https://kiro.dev)
+[](https://www.minimax.io)
[](./LICENSE)
-[](https://github.com/ragaeeb/dondo/issues)
-[](https://wakatime.com/badge/user/a0b906ce-b8e7-4463-8bce-383238df6d4b/project/1c226a67-6f05-42d3-a8c3-591ef0fa09fd)
-Dondo is a small local Bun app for saving and switching local AI tool accounts. It starts a local web UI, stores saved accounts in an encrypted local vault, and currently supports Antigravity, Codex, Cline, Kiro, and MiniMax.
+Dondo is a small local Bun app for saving and switching Antigravity, Codex, Cline, Kiro, and MiniMax accounts. It
+runs a Preact UI on loopback, keeps saved credentials in an encrypted local vault, and fetches supported usage limits
+server-side without returning token payloads from ordinary state APIs.
-Current platform support is macOS. Dondo uses the macOS `security` CLI for the local vault key, and Antigravity account switching uses macOS Keychain entries.
+**Dondo supports macOS only.** It uses the macOS `security` CLI and the login user's default Keychain for its vault
+key and Antigravity credentials. Custom Keychain files are not supported.
## Install
-Install Bun 1.3 or newer, then run:
+Install Bun 1.3.14 or newer, then run:
```sh
bunx dondo-donuts
```
-Then open the URL printed by the server. By default Dondo starts at:
-
-```text
-http://127.0.0.1:3000
-```
-
-If that port is busy, Dondo uses the next available port. The server binds to `127.0.0.1` only.
-
-## Development
+Open the URL printed by the server. Dondo starts at `http://127.0.0.1:3000` by default and tries the next available
+port when that port is occupied. It never binds to a non-loopback interface.
-```sh
-bun install
-bun run start
-```
-
-Useful checks:
-
-```sh
-bun run typecheck
-bun run lint
-bun test
-bun build src/server.ts --target=bun --outdir /tmp/dondo-build
-```
-
-## Storage
-
-Dondo stores its vault at the platform data directory:
-
-- macOS: `~/Library/Application Support/Dondo/vault.json`
-- Windows: `%LOCALAPPDATA%/Dondo/Data/vault.json`
-- Linux: `$XDG_DATA_HOME/dondo/vault.json` or `~/.local/share/dondo/vault.json`
-
-Set `DONDO_DATA_DIR` to override the directory, or `ANTIGRAVITY_VAULT` to override the full vault path.
-`DONDO_VAULT` also overrides the full vault path and takes precedence over the historical `ANTIGRAVITY_VAULT` name.
-When upgrading, rename `ANTIGRAVITY_VAULT` to `DONDO_VAULT` for clarity; the historical name remains supported.
-
-Vault shape:
-
-```json
-{
- "antigravity": {
- "data": {},
- "limits": {}
- },
- "codex": {
- "data": {},
- "limits": {}
- },
- "cline": {
- "data": {},
- "limits": {}
- },
- "kiro": {
- "data": {},
- "limits": {}
- },
- "minimax": {
- "data": {},
- "limits": {}
- }
-}
-```
+## Using Dondo
-`antigravity.data` stores saved account snapshots. The token-bearing `password` field is encrypted with AES-256-GCM before writing to disk. Non-secret metadata such as labels, service names, and timestamps remains readable in the vault so the UI can list accounts. The encryption key is a random local secret stored in macOS Keychain as `dondo / vault-key`.
+Open a platform tab and use `Save current` while that application's desired account is live. A saved account can then
+be loaded, deleted, and, where supported, refreshed or synchronized with the current account. Export downloads every
+saved account for one platform as an unencrypted JSON attachment after an explicit warning.
-`antigravity.limits` stores cached rate-limit data. Dondo fetches missing limits on first load and refreshes cached limits only when the UI `Refresh limits` button is used.
+### Antigravity switching
-`codex.data` stores encrypted snapshots of `~/.codex/auth.json`. Loading a saved Codex account writes that snapshot back to `~/.codex/auth.json` with `0600` permissions.
+Loading Antigravity replaces its live Keychain credential and removes these local state paths before restoration:
-`codex.limits` stores cached Codex ChatGPT usage data. Dondo fetches missing limits on first load and refreshes cached limits only when the UI `Refresh limits` button is used.
+- `~/.antigravity-agent/cloud_accounts.db`
+- `~/.gemini/antigravity`
+- `~/.gemini/antigravity-ide`
+- `~/.gemini/antigravity-backup`
+- `~/Library/Application Support/Antigravity`
-`cline.data` stores encrypted snapshots of `~/.cline/data/secrets.json`. Loading a saved Cline account writes the complete
-secrets file back to the same path with `0600` permissions. Cline account rows use the stable account identity from the
-file only to show which saved account is active; token values are never returned by the state API or rendered in the UI.
+`Clear live` deletes the live Keychain item and the same local state. Antigravity must be fully quit before loading or
+clearing; Dondo rejects either operation while its process is running so it cannot restore stale state. Reopen it after
+the operation. These actions change local login state; they do not remotely revoke the account.
-`kiro.data` stores encrypted snapshots of `~/.aws/sso/cache/kiro-auth-token.json`. Loading a saved Kiro account
-writes a freshly validated snapshot back to the same path with `0600` permissions. Kiro watches this file and picks
-up account changes while the IDE is running. If Kiro has remotely revoked a saved session, Dondo rejects the load
-without replacing the current live credentials.
+### Kiro switching
-To add multiple accounts, use `Save current` while signed in, fully quit Kiro, and use `Clear live`. Reopen Kiro,
-sign into the next account, and save it under another label. To switch accounts later, fully quit Kiro, load the
-saved account in Dondo, and reopen Kiro. Dondo snapshots the auth token, cached profile, and the client-registration
-credential used by Builder ID or enterprise sessions. `Clear live` removes those account-specific local artifacts
-without calling Kiro's remote logout endpoint.
-The Kiro account rows intentionally do not offer `Sync current`, because that action cannot verify that the live
-account matches the row label.
+Kiro must be fully quit before `Load` or `Clear live`; Dondo rejects either operation while Kiro is running. Save the
+current account first, quit Kiro, clear its live files, reopen it, sign into the next account, and save that account under
+a new label. To switch later, quit Kiro, load the saved account in Dondo, and reopen Kiro.
-`kiro.limits` stores cached Kiro usage data. Dondo fetches missing limits when the Kiro tab opens and refreshes every
-saved account only when `Refresh limits` is selected.
+Dondo snapshots Kiro's auth token, optional profile, and matching client registration. Loading validates or refreshes
+the saved session, stages the replacement files, and then commits them with `0600` permissions. Clearing removes those
+local account files without calling Kiro's remote logout endpoint. Kiro intentionally has no `Sync current` row action.
-`minimax.data` stores encrypted snapshots of `~/Library/Application Support/MiniMax Agent/minimax-agent-config.json`. Loading a saved MiniMax account writes that snapshot back to the same path with `0600` permissions.
+## Vault and recovery
-`minimax.limits` caches the 5-hour and weekly MiniMax Code quotas fetched from its current coding-plan endpoint. Dondo fetches missing limits when the MiniMax tab opens and refreshes every saved account only when `Refresh limits` is selected.
+The default vault is `~/Library/Application Support/Dondo/vault.json`. Set `DONDO_DATA_DIR` to replace its containing
+directory or `DONDO_VAULT` to replace the complete vault path.
-Each limit cache entry has this shape:
+The current vault contract is a hard cut to nested, encrypted platform sections:
```json
{
- "fetchedAt": "2026-06-02T00:00:00.000Z",
- "quota": {
- "ok": true,
- "tier": "plus",
- "expires": "",
- "models": {}
- }
+ "antigravity": { "data": {}, "limits": {} },
+ "cline": { "data": {}, "limits": {} },
+ "codex": { "data": {}, "limits": {} },
+ "kiro": { "data": {}, "limits": {} },
+ "minimax": { "data": {}, "limits": {} }
}
```
-Encrypted strings use the `enc:v1:` envelope: AES-256-GCM with a 12-byte IV, 16-byte auth tag, then ciphertext, base64 encoded.
+Dondo does not perform runtime migrations from historical flat vaults, plaintext secret fields, or `enc:v1`
+ciphertexts. Back up an old vault before upgrading and re-save accounts from their live applications into the current
+format. Existing `enc:v1` rows appear as `Corrupted`; delete and re-save them before exporting. The historical
+`ANTIGRAVITY_VAULT` environment name is no longer supported; use `DONDO_VAULT`. The historical
+`ANTIGRAVITY_KEYCHAIN` override is also unsupported; Dondo always uses the default Keychain.
+
+Secret-bearing fields are encrypted independently with AES-256-GCM in an `enc:v2:` envelope. Each ciphertext is bound
+to its platform, account label, secret field, and non-secret snapshot metadata, so copying it to another row or changing
+its metadata fails authentication. The random vault secret is stored in the default macOS Keychain as
+`dondo / vault-key`; only non-secret labels, timestamps, and cached limit metadata remain readable in the vault. Codex,
+Cline, Kiro, and MiniMax live files restored by Dondo are written with `0600` permissions.
+
+The vault file is not a self-contained credential backup. If the `dondo / vault-key` Keychain item is lost, Dondo
+refuses to generate a replacement while `enc:v2` data exists because doing so would make the loss permanent and
+silent. Restore that Keychain item from backup, or remove the unreadable vault and save every account again from its
+live application.
+
+If one saved credential cannot be decrypted or has an invalid stored shape, Dondo isolates and preserves its raw vault
+entry. The UI shows a redacted `Corrupted` row: it cannot be loaded, synchronized, or refreshed, but it can be deleted.
+Delete that row and use `Save current` to create it again. Other healthy accounts remain usable.
+
+An export is all-or-nothing for the selected platform. If any saved account for that platform is damaged, Dondo refuses
+the entire export instead of silently producing an incomplete wallet. In browsers that support the File System Access
+API, Dondo asks for the destination before requesting the wallet and streams the response directly to that file; a
+failed stream is aborted so a partial file is not committed. Other browsers use a temporary in-memory Blob download.
+
+### Platform data
+
+| Platform | Live source restored by Dondo | Cached usage |
+| --- | --- | --- |
+| Antigravity | Default macOS Keychain service/account configured below | Model quotas |
+| Codex | `~/.codex/auth.json` | ChatGPT usage windows |
+| Cline | `~/.cline/data/settings/providers.json` | None |
+| Kiro | `~/.aws/sso/cache/kiro-auth-token.json` plus related profile/registration | Agentic-request usage |
+| MiniMax | `~/Library/Application Support/MiniMax Agent/minimax-agent-config.json` | 5-hour, weekly, and credit balance |
+
+MiniMax also provides `Daily Check-In`. A successful claim invalidates the affected cached limits so the credit balance
+can be refreshed.
+
+Codex snapshots follow the current Codex CLI auth contract: API-key accounts use `auth_mode: "apikey"` and ChatGPT
+accounts use `auth_mode: "chatgpt"`. The historical `auth_mode: "api_key"` spelling is rejected; sign in again with a
+current Codex CLI and re-save that account.
+
+Cline snapshots contain the current `providers.json` contract only; historical settings or secret-file layouts are not
+read or migrated. MiniMax snapshots require a non-empty JWT access token with the current account identity claim;
+legacy identity heuristics are not used. Kiro snapshots require a refresh token and valid JSON objects for any saved
+profile or client registration. A saved label with any semantically invalid configuration is shown as `Corrupted` and
+must be deleted before that label can be saved again.
## Environment
-```sh
-DONDO_PORT=3000
-PORT=3000
-DONDO_DATA_DIR=/custom/data/dir
-DONDO_VAULT=/custom/vault.json
-ANTIGRAVITY_VAULT=/custom/vault.json
-ANTIGRAVITY_SERVICE=gemini
-ANTIGRAVITY_ACCOUNT=antigravity
-ANTIGRAVITY_KEYCHAIN=login.keychain-db
-ANTIGRAVITY_VERSION=2.0.3
-ANTIGRAVITY_LANGUAGE_SERVER_PATH=/Applications/Antigravity.app/Contents/Resources/bin/language_server
-CODEX_AUTH_PATH=~/.codex/auth.json
-CLINE_SECRETS_PATH=~/.cline/data/secrets.json
-KIRO_AUTH_PATH=~/.aws/sso/cache/kiro-auth-token.json
-KIRO_PROFILE_PATH="~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent/profile.json"
-KIRO_AUTH_REFRESH_URL=https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken
-MINIMAX_CONFIG_PATH=~/Library/Application Support/MiniMax Agent/minimax-agent-config.json
-MINIMAX_PLATFORM_URL=https://platform.minimax.io
-```
-
-`DONDO_PORT` takes precedence over `PORT`; both set the preferred starting port, and Dondo uses the next available port if that port is busy. `ANTIGRAVITY_KEYCHAIN` is passed as the keychain argument to macOS `security` commands, for example `login.keychain-db` or an absolute keychain path.
-
-Antigravity limit refreshes can use the saved Google refresh token to rotate an expired saved access token, then write the refreshed token blob back to the encrypted vault entry. Dondo discovers Antigravity's Google OAuth client from the local Antigravity language server binary. Codex limit refreshes only call the usage endpoint with the saved access token.
+All overrides are optional.
+
+| Variable | Default or behavior |
+| --- | --- |
+| `DONDO_PORT` | Preferred port, default `3000`; takes precedence over `PORT` |
+| `PORT` | Fallback preferred port |
+| `DONDO_DATA_DIR` | `~/Library/Application Support/Dondo` |
+| `DONDO_VAULT` | `vault.json` under `DONDO_DATA_DIR` |
+| `ANTIGRAVITY_SERVICE` | `gemini` |
+| `ANTIGRAVITY_ACCOUNT` | `antigravity` |
+| `ANTIGRAVITY_VERSION` | `2.0.3` request version |
+| `ANTIGRAVITY_PROCESS_NAME` | `Antigravity` |
+| `ANTIGRAVITY_LANGUAGE_SERVER_PATH` | Optional language-server binary used for local OAuth client discovery |
+| `CODEX_AUTH_PATH` | `~/.codex/auth.json` |
+| `CLINE_PROVIDERS_PATH` | `~/.cline/data/settings/providers.json` |
+| `KIRO_AUTH_PATH` | `~/.aws/sso/cache/kiro-auth-token.json` |
+| `KIRO_PROFILE_PATH` | Kiro's macOS global-storage `profile.json` |
+| `KIRO_PROCESS_NAME` | `Kiro` |
+| `KIRO_AUTH_REFRESH_URL` | Kiro desktop session refresh endpoint |
+| `KIRO_USAGE_URL` | Optional usage endpoint override; normally derived from the profile region |
+| `KIRO_USER_AGENT` | `KiroIDE-0.0.0-dondo` |
+| `MINIMAX_CONFIG_PATH` | MiniMax Agent's macOS `minimax-agent-config.json` |
+| `MINIMAX_AGENT_URL` | `https://agent.minimax.io` |
+| `MINIMAX_PLATFORM_URL` | `https://platform.minimax.io` |
+| `MINIMAX_UUID` | Optional MiniMax device UUID override |
+| `MINIMAX_LOCAL_STORAGE_PATH` | `Local Storage/leveldb` beside `MINIMAX_CONFIG_PATH` |
+
+For local development, use repository-relative override paths such as `DONDO_DATA_DIR=./local-data` rather than a real
+application vault.
## Local API
-All API requests must be sent to localhost. Mutating routes require `POST` with a JSON object body. Export routes
-require `POST` and the `X-Dondo-Export: 1` header.
-
-- `GET /api/antigravity/state`
-- `POST /api/antigravity/export`
-- `POST /api/antigravity/limits/refresh` with optional `{ "key": "label" }`
-- `POST /api/antigravity/save` with `{ "key": "label" }`
-- `POST /api/antigravity/load` with `{ "key": "label" }`
-- `POST /api/antigravity/delete` with `{ "key": "label" }`
-- `POST /api/antigravity/clear`
-- `GET /api/codex/state`
-- `POST /api/codex/export`
-- `POST /api/codex/limits/refresh` with optional `{ "key": "label" }`
-- `POST /api/codex/save` with `{ "key": "label" }`
-- `POST /api/codex/load` with `{ "key": "label" }`
-- `POST /api/codex/delete` with `{ "key": "label" }`
-- `GET /api/cline/state`
-- `POST /api/cline/export`
-- `POST /api/cline/save` with `{ "key": "label" }`
-- `POST /api/cline/load` with `{ "key": "label" }`
-- `POST /api/cline/delete` with `{ "key": "label" }`
-- `GET /api/kiro/state`
-- `POST /api/kiro/limits/refresh` with optional `{ "key": "label" }`
-- `POST /api/kiro/export`
-- `POST /api/kiro/save` with `{ "key": "label" }`
-- `POST /api/kiro/load` with `{ "key": "label" }`
-- `POST /api/kiro/delete` with `{ "key": "label" }`
-- `POST /api/kiro/clear`
-- `GET /api/minimax/state`
-- `POST /api/minimax/export`
-- `POST /api/minimax/limits/refresh` with optional `{ "key": "label" }`
-- `POST /api/minimax/save` with `{ "key": "label" }`
-- `POST /api/minimax/load` with `{ "key": "label" }`
-- `POST /api/minimax/delete` with `{ "key": "label" }`
-
-`Clear live` deletes the live Antigravity Keychain item plus these local Antigravity state paths:
+Every API request must target `localhost` or `127.0.0.1`, and the request URL and `Host` header must agree. When an
+`Origin` header is present, it must exactly equal the local server origin, including host and port. JSON body routes
+require `Content-Type: application/json` and a top-level object.
+
+The complete route surface is:
+
+- `GET /api/{platform}/state` for `antigravity`, `codex`, `cline`, `kiro`, and `minimax`.
+- `POST /api/{platform}/export` for all five platforms. It requires `X-Dondo-Export: 1`.
+- `POST /api/{platform}/save`, `/load`, and `/delete` for all five platforms with `{ "key": "label" }`.
+- `POST /api/{platform}/limits/refresh` for Antigravity, Codex, Kiro, and MiniMax with optional
+ `{ "key": "label" }`.
+- `POST /api/antigravity/clear` and `POST /api/kiro/clear` with an empty JSON object.
+- `POST /api/minimax/check-in` with optional `{ "key": "label" }`.
+
+The server accepts at most **16 KiB** per JSON request body, serializes at most **8 MiB** per export attachment, and
+reads at most **16 MiB** from the vault file. Local API traffic is rate limited to 120 requests per 10 seconds.
+Live credential files, captured system-command output, and each upstream HTTP response are limited to **1 MiB**.
+OAuth discovery scans at most **192 MiB** from a language-server binary; MiniMax identity discovery examines the newest
+64 LevelDB/log files, reads at most **16 MiB** from each, and stops after **64 MiB** in total.
+The vault accepts at most 4,096 accounts per platform, 256 cached models per account, and 4 KiB per non-secret metadata
+field.
+
+State, limit, mutation, and error responses are non-cacheable and never contain credential payloads. Export is the only
+token-bearing API response. It is a local-only, non-cacheable attachment and should be protected like the original auth
+files.
+
+When Dondo writes a vault secret or Antigravity credential through the macOS `security` CLI, it sends the secret over
+the child process's standard input instead of placing it in the process argument list. Command failures redact both
+private input and recognizable token fields. macOS may still show a Keychain access prompt, and another process running
+as the logged-in user remains within the local trust boundary.
-- `~/.antigravity-agent/cloud_accounts.db`
-- `~/.gemini/antigravity`
-- `~/.gemini/antigravity-ide`
-- `~/.gemini/antigravity-backup`
-- `~/Library/Application Support/Antigravity`
+## Development
-## Security Model
+```sh
+bun install --frozen-lockfile
+bun run dev
+```
-Dondo is designed to be easy to inspect:
+`bun run dev` restarts the local server when runtime TypeScript, TSX, CSS, package metadata, or icons change. Test-only
+edits do not restart it. Run the full gates before submitting a change:
-- The server listens on `127.0.0.1`.
-- State and limit APIs do not return token payloads.
-- Saved token payloads are encrypted at rest.
-- Rate-limit API calls happen server-side.
-- Token payloads and Codex `auth.json` contents are never rendered in the UI.
+```sh
+bun run lint
+bun run typecheck
+bun test
+bun run build
+bun build src/server.ts --target=bun --outdir /tmp/dondo-build
+```
-The export routes are the sole API exception: they return every saved account for the selected platform as an
-unencrypted JSON attachment. The UI asks for explicit confirmation before calling them. Export requires a local
-`POST` request with a dedicated confirmation header, is rate limited with the rest of the local API, and uses
-`Cache-Control: no-store`. The downloaded file contains live credentials and must be stored and shared as carefully
-as the original auth files.
+`bun run lint` checks formatting, lint rules, and import/key-order assists with warnings treated as failures.
+`bun run format` writes formatting only; `bun run fix` applies Biome's safe formatter, lint, and assist fixes. See
+[CONTRIBUTING.md](./CONTRIBUTING.md) for test isolation and pull request expectations.
-This protects against casual plaintext scraping of the vault file. A process running as the same logged-in user may still be able to access local Keychain items depending on operating-system policy. Antigravity restore currently passes the token blob to the macOS `security` CLI as an argument, which can be visible briefly to same-user process listings.
+`bun run build` writes a runnable `dist/server.js` plus its browser bundle, stylesheet, and icons. Launch that artifact
+with `bun dist/server.js`; it does not depend on the source tree at runtime.
## License
diff --git a/biome.json b/biome.json
index e40139e..6ae959a 100644
--- a/biome.json
+++ b/biome.json
@@ -1,5 +1,5 @@
{
- "$schema": "https://biomejs.dev/schemas/latest/schema.json",
+ "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json",
"assist": {
"actions": {
"source": {
@@ -17,11 +17,8 @@
"ignoreUnknown": true,
"includes": [
"src/**",
- "test/**",
- "bin/**",
- "*.ts",
- "*.mjs",
- "*.js",
+ "scripts/**",
+ ".github/**",
"*.json",
"*.md",
"!!**/node_modules",
@@ -54,7 +51,7 @@
"enabled": true,
"rules": {
"complexity": {
- "noExcessiveCognitiveComplexity": "off",
+ "noExcessiveCognitiveComplexity": "error",
"useArrowFunction": "error"
},
"correctness": {
@@ -69,7 +66,7 @@
},
"style": {
"noInferrableTypes": "error",
- "noNonNullAssertion": "off",
+ "noNonNullAssertion": "error",
"noRestrictedImports": {
"level": "error",
"options": {
@@ -104,7 +101,7 @@
"noAssignInExpressions": "off",
"noConsole": "off",
"noControlCharactersInRegex": "off",
- "noExplicitAny": "off",
+ "noExplicitAny": "error",
"noTemplateCurlyInString": "off"
}
}
diff --git a/bun.lock b/bun.lock
index f6c2190..cdd48bc 100644
--- a/bun.lock
+++ b/bun.lock
@@ -3,35 +3,35 @@
"configVersion": 1,
"workspaces": {
"": {
- "name": "dondo",
+ "name": "dondo-donuts",
"dependencies": {
- "preact": "latest",
+ "preact": "10.29.8",
},
"devDependencies": {
- "@biomejs/biome": "latest",
- "@types/bun": "latest",
- "typescript": "latest",
+ "@biomejs/biome": "2.5.8",
+ "@types/bun": "1.3.14",
+ "typescript": "7.0.2",
},
},
},
"packages": {
- "@biomejs/biome": ["@biomejs/biome@2.5.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.6", "@biomejs/cli-darwin-x64": "2.5.6", "@biomejs/cli-linux-arm64": "2.5.6", "@biomejs/cli-linux-arm64-musl": "2.5.6", "@biomejs/cli-linux-x64": "2.5.6", "@biomejs/cli-linux-x64-musl": "2.5.6", "@biomejs/cli-win32-arm64": "2.5.6", "@biomejs/cli-win32-x64": "2.5.6" }, "bin": { "biome": "bin/biome" } }, "sha512-lxVNjv7UF6KfhMJfL9gaUHbWdJdHbsAj6OSmwSYNdhRuG67NxNQ4Xdvh3TUxsSK9sBzJBQhEJj3AopmmNJ5pSA=="],
+ "@biomejs/biome": ["@biomejs/biome@2.5.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.8", "@biomejs/cli-darwin-x64": "2.5.8", "@biomejs/cli-linux-arm64": "2.5.8", "@biomejs/cli-linux-arm64-musl": "2.5.8", "@biomejs/cli-linux-x64": "2.5.8", "@biomejs/cli-linux-x64-musl": "2.5.8", "@biomejs/cli-win32-arm64": "2.5.8", "@biomejs/cli-win32-x64": "2.5.8" }, "bin": { "biome": "bin/biome" } }, "sha512-aeAeeJB9fSDc7Gq+2GqpQxA0qBj6gj1k2R6L1cYqGePKP/baIq1WX8y6B+D+nRsO5ViQL22K/8IwbqERW0q1nw=="],
- "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zMOLZP4oMrjh6m1zcSj1ud2awUPgTuMVbmQhYYWL7J8HwCnbHHBvTm7VBTRuY7epT5bez76IpKYQ11ZAqHFlnw=="],
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mk1QON9PHllvvLN5gU3f4rMxeh4syK5p9OvKyWH6/W8ueh04uaC8TUXXByhGufWf/y5mQc03ZLM45zU+cmqMjA=="],
- "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-JAC1VqzvO7Th5ZplU0G2uGfkZbxEe9uDDektPAhF0JLusoz1w+T4okp2bkykI0bbaO2vslKiRfj4gU43JaGreA=="],
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-bsGwFMBNyHPyiLSsQcZJxdoRrg1V4JL+d7wEsvUBczlP9U9lwM+7mzQHxI4o1mhBsTmdOBbAb6fHU3Z3snN45w=="],
- "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-6XsYwCFkp5sMxl85ffhgeGpGgs6A7dRYFnkceZ7WVxvycuTnGdD5xa534Z3xfrBQ0JCMK/mujT6ZNPJoghedwg=="],
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-XmFiA0WPYFC+uiUDC8WRFzAIH9bo7vwQLav38Uoq4ETC+T/+uBi0TsYGJECkugY3r8USl3jc+Ae2/irAF6F2lQ=="],
- "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-eUa3jeeYvfMt19LBeh6E5PUZpxnTC4JqNWo+EDjTtQjAr2xLGnWaxACtVU1DQqmHYbvThlJzLX+ZsYgrqh2qVw=="],
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-VcJNbstduTHx83NGAdhp78/JOcP45BZHXL7yNsfI1uGzdUgegAz2s+mSoT7wK6PBNzLoqG0zDOXaz/RQYVtSiw=="],
- "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Pop9VXCFUhFTMfFefZ39S+u2rOPyNp5iHlxbZRwXGACHLy2r0jjiRgJHmaEKJzL3SyxlVeGShXhvvElvWowonA=="],
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-S5wcm9OBDvLHodD4PUaN488hCpco9QD/9ZxuYJiw4euWtr/oQvLR72z2ixItH8Wd5BCm6FZaeb+YNvOoM1xHtQ=="],
- "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2Vp13QdKysH3HIWLaYLhUUwbK+jbZonJD1K+Lr0d0RO4wH7mkYd43vJixEDm8cUWrowoRz4UUHF1nm9Ae7ym8A=="],
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-kKmiyokeISRGq2FLwvr+TzsgBusfxaZ0FZNLcOYOpCK/78tRrEjeEBLvq3xLZMpqbANgJdRPI7vZX8ZL37u9/w=="],
- "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-tDGshcm6BdkZOCGnTDX0Y8/U4IfBSlnUU7T56nNDuPEfed+aHg+u8G36NB43fJVl0Os6+QURXIE1yuD7AaEofA=="],
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-nILH0mzm3Hi3iEdd7o7GpB8kBR/mSQwfQG/tyBqyNrY2GFtcgwfV9nV8xLmbtUpMNY/Oi0Ml1XgfR4flOdq+AA=="],
- "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-WN05KwXnTO/2J45RQPvzZMXf7tZUIofHoR35xIPfCo7pQ2RFidxI8sfb5mGsaTxdMmEOzHzOPRCdA5/fCpc7xQ=="],
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.8", "", { "os": "win32", "cpu": "x64" }, "sha512-I2czzXTY61f3nFJxXoMDq80t7MivxDEnCjE+8sDKoFfcKMaoQdkqhIFQ3KyY0XLzeSpUBYeNAXgD+iOV/BU0VA=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
@@ -79,7 +79,7 @@
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
- "preact": ["preact@10.29.7", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q=="],
+ "preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="],
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
diff --git a/icon.png b/icon.png
index 34f6d9c..eab848e 100644
Binary files a/icon.png and b/icon.png differ
diff --git a/package.json b/package.json
index 7ea1893..659a2ec 100644
--- a/package.json
+++ b/package.json
@@ -1,59 +1,73 @@
{
- "author": {
- "name": "Ragaeeb Haq",
- "url": "https://github.com/ragaeeb"
- },
- "bin": {
- "dondo": "src/server.ts",
- "dondo-donuts": "src/server.ts"
- },
- "bugs": {
- "url": "https://github.com/ragaeeb/dondo/issues"
- },
- "dependencies": {
- "preact": "^10.29.7"
- },
- "description": "Minimal local UI for switching Antigravity, Codex, and Cline accounts.",
- "devDependencies": {
- "@biomejs/biome": "^2.5.6",
- "@types/bun": "^1.3.14",
- "typescript": "^7.0.2"
- },
- "engines": {
- "bun": ">=1.3.14"
- },
- "files": [
- "LICENSE",
- "README.md",
- "icon.png",
- "icon.svg",
- "src"
- ],
- "homepage": "https://github.com/ragaeeb/dondo",
- "keywords": [
- "antigravity",
- "cline",
- "codex",
- "account-switcher",
- "bun",
- "keychain"
- ],
- "license": "MIT",
- "name": "dondo-donuts",
- "os": [
- "darwin"
- ],
- "packageManager": "bun@1.3.14",
- "repository": {
- "type": "git",
- "url": "git+https://github.com/ragaeeb/dondo.git"
- },
- "scripts": {
- "format": "biome check . --write && biome lint . --write",
- "lint": "biome lint .",
- "start": "bun src/server.ts",
- "typecheck": "tsc --noEmit"
- },
- "type": "module",
- "version": "0.3.0"
+ "author": {
+ "name": "Ragaeeb Haq",
+ "url": "https://github.com/ragaeeb"
+ },
+ "bin": {
+ "dondo": "src/server.ts",
+ "dondo-donuts": "src/server.ts"
+ },
+ "bugs": {
+ "url": "https://github.com/ragaeeb/dondo/issues"
+ },
+ "dependencies": {
+ "preact": "10.29.8"
+ },
+ "description": "Encrypted local account switcher for Antigravity, Codex, Cline, Kiro, and MiniMax on macOS.",
+ "devDependencies": {
+ "@biomejs/biome": "2.5.8",
+ "@types/bun": "1.3.14",
+ "typescript": "7.0.2"
+ },
+ "engines": {
+ "bun": ">=1.3.14"
+ },
+ "files": [
+ "LICENSE",
+ "README.md",
+ "CONTRIBUTING.md",
+ "icon.png",
+ "icon.svg",
+ "scripts/*.ts",
+ "!scripts/*.test.ts",
+ "src/**/*.css",
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ "!src/**/*.test.ts",
+ "!src/**/*.test.tsx"
+ ],
+ "homepage": "https://github.com/ragaeeb/dondo",
+ "keywords": [
+ "antigravity",
+ "cline",
+ "codex",
+ "kiro",
+ "minimax",
+ "account-switcher",
+ "bun",
+ "encrypted-vault",
+ "keychain",
+ "macos"
+ ],
+ "license": "MIT",
+ "name": "dondo-donuts",
+ "os": [
+ "darwin"
+ ],
+ "packageManager": "bun@1.3.14",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/ragaeeb/dondo.git"
+ },
+ "scripts": {
+ "build": "bun scripts/build.ts",
+ "dev": "bun scripts/dev.ts",
+ "fix": "biome check . --write --enforce-assist=true",
+ "format": "biome format . --write",
+ "lint": "biome check . --enforce-assist=true --error-on-warnings",
+ "start": "bun src/server.ts",
+ "typecheck": "tsc --noEmit"
+ },
+ "type": "module",
+ "version": "0.4.0"
}
diff --git a/scripts/build.test.ts b/scripts/build.test.ts
new file mode 100644
index 0000000..edebdf1
--- /dev/null
+++ b/scripts/build.test.ts
@@ -0,0 +1,68 @@
+import { expect, it } from 'bun:test';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { buildDistribution } from './build.ts';
+
+const startedServerUrl = async (child: Bun.Subprocess<'ignore', 'pipe', 'pipe'>) => {
+ const reader = child.stdout.getReader();
+ const decoder = new TextDecoder('utf-8', { fatal: true });
+ let output = '';
+ const timeout = setTimeout(() => child.kill('SIGKILL'), 5_000);
+ try {
+ for (;;) {
+ const next = await reader.read();
+ if (next.done) {
+ const error = await new Response(child.stderr).text();
+ throw new Error(`Built server exited before startup: ${error}`);
+ }
+ output += decoder.decode(next.value, { stream: true });
+ const url = output.match(/Dondo running at (http:\/\/127\.0\.0\.1:\d+)/u)?.[1];
+ if (url) {
+ return url;
+ }
+ if (Buffer.byteLength(output, 'utf8') > 4 * 1024) {
+ throw new Error('Built server startup output exceeded 4 KiB');
+ }
+ }
+ } finally {
+ clearTimeout(timeout);
+ reader.releaseLock();
+ }
+};
+
+it('builds a runnable distribution with its browser assets', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'dondo-build-smoke-'));
+ const outdir = join(root, 'dist');
+ let child: Bun.Subprocess<'ignore', 'pipe', 'pipe'> | undefined;
+ try {
+ await buildDistribution(outdir);
+ child = Bun.spawn(['bun', join(outdir, 'server.js')], {
+ env: {
+ ...globalThis.process.env,
+ DONDO_DATA_DIR: join(root, 'data'),
+ DONDO_PORT: '32000',
+ },
+ stderr: 'pipe',
+ stdin: 'ignore',
+ stdout: 'pipe',
+ });
+ const serverUrl = await startedServerUrl(child);
+ const response = await fetch(serverUrl);
+ expect(response.status).toBe(200);
+ expect(await response.text()).toContain('
Dondo ');
+ const [appJs, css, icon] = await Promise.all([
+ fetch(`${serverUrl}/assets/app.js`),
+ fetch(`${serverUrl}/assets/styles.css`),
+ fetch(`${serverUrl}/icon.png`),
+ ]);
+ expect([appJs.status, css.status, icon.status]).toEqual([200, 200, 200]);
+ expect((await appJs.text()).length).toBeGreaterThan(1_000);
+ expect((await css.text()).length).toBeGreaterThan(1_000);
+ expect((await icon.bytes()).byteLength).toBeGreaterThan(1_000);
+ } finally {
+ child?.kill('SIGKILL');
+ await child?.exited.catch(() => undefined);
+ await rm(root, { force: true, recursive: true });
+ }
+});
diff --git a/scripts/build.ts b/scripts/build.ts
new file mode 100644
index 0000000..6f0c431
--- /dev/null
+++ b/scripts/build.ts
@@ -0,0 +1,109 @@
+import { randomUUID } from 'node:crypto';
+import { mkdir, rename, rm, stat } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { basename, dirname, join, resolve, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { waitForAll } from '../src/async-queue.ts';
+
+const PROJECT_ROOT = fileURLToPath(new URL('../', import.meta.url));
+const DEFAULT_OUTDIR = join(PROJECT_ROOT, 'dist');
+
+const buildFailure = (label: string, logs: readonly { message: string }[]) => {
+ return new Error(`${label} failed:\n${logs.map((log) => log.message).join('\n') || 'Unknown build error'}`);
+};
+
+const assertBuild = (label: string, result: Awaited>) => {
+ if (!result.success) {
+ throw buildFailure(label, result.logs);
+ }
+ return result;
+};
+
+const prepareOutput = async (outdir: string) => {
+ const resolved = resolve(outdir);
+ const temporaryRoot = resolve(tmpdir());
+ if (
+ basename(resolved) !== 'dist' ||
+ (resolved !== resolve(DEFAULT_OUTDIR) && !resolved.startsWith(`${temporaryRoot}${sep}`))
+ ) {
+ throw new Error('Distribution output must be the project dist directory or a temporary dist directory');
+ }
+ const staged = join(dirname(resolved), `.dist-${process.pid}-${randomUUID()}`);
+ await rm(staged, { force: true, recursive: true });
+ await mkdir(join(staged, 'assets'), { recursive: true });
+ return { resolved, staged };
+};
+
+export const buildDistribution = async (outdir = DEFAULT_OUTDIR) => {
+ const { resolved, staged } = await prepareOutput(outdir);
+ const backup = join(dirname(resolved), `.dist-${process.pid}-${randomUUID()}.backup`);
+ let hasBackup = false;
+ try {
+ const ui = assertBuild(
+ 'Browser build',
+ await Bun.build({
+ entrypoints: [join(PROJECT_ROOT, 'src', 'ui', 'client.tsx')],
+ jsx: { importSource: 'preact', runtime: 'automatic' },
+ target: 'browser',
+ }),
+ );
+ const appJs = ui.outputs.find((output) => output.path.endsWith('.js'));
+ if (!appJs) {
+ throw new Error('Browser build did not produce JavaScript');
+ }
+ await waitForAll([
+ Bun.write(join(staged, 'assets', 'app.js'), appJs),
+ Bun.write(join(staged, 'assets', 'styles.css'), Bun.file(join(PROJECT_ROOT, 'src', 'ui', 'styles.css'))),
+ Bun.write(join(staged, 'icon.png'), Bun.file(join(PROJECT_ROOT, 'icon.png'))),
+ Bun.write(join(staged, 'icon.svg'), Bun.file(join(PROJECT_ROOT, 'icon.svg'))),
+ ]);
+ assertBuild(
+ 'Server build',
+ await Bun.build({
+ define: { DONDO_BUNDLED_ASSETS: 'true' },
+ entrypoints: [join(PROJECT_ROOT, 'src', 'server.ts')],
+ outdir: staged,
+ target: 'bun',
+ }),
+ );
+ await rm(backup, { force: true, recursive: true });
+ try {
+ await stat(resolved);
+ await rename(resolved, backup);
+ hasBackup = true;
+ } catch (error) {
+ if ((error as { code?: unknown }).code !== 'ENOENT') {
+ throw error;
+ }
+ }
+ try {
+ await rename(staged, resolved);
+ } catch (error) {
+ await rm(staged, { force: true, recursive: true });
+ if (hasBackup) {
+ await rm(resolved, { force: true, recursive: true });
+ await rename(backup, resolved);
+ hasBackup = false;
+ }
+ throw error;
+ }
+ if (hasBackup) {
+ await rm(backup, { force: true, recursive: true });
+ hasBackup = false;
+ }
+ } catch (error) {
+ await rm(staged, { force: true, recursive: true });
+ if (hasBackup) {
+ await rm(resolved, { force: true, recursive: true });
+ await rename(backup, resolved);
+ }
+ throw error;
+ } finally {
+ await rm(backup, { force: true, recursive: true }).catch(() => {});
+ }
+};
+
+if (import.meta.main) {
+ await buildDistribution();
+ console.log('Built runnable distribution in dist');
+}
diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts
new file mode 100644
index 0000000..ff97c1e
--- /dev/null
+++ b/scripts/dev.test.ts
@@ -0,0 +1,11 @@
+import { expect, it } from 'bun:test';
+import { isRuntimeSource } from './dev.ts';
+
+it('should restart development only for runtime source changes', () => {
+ for (const path of ['server.ts', 'ui/client.tsx', 'ui/styles.css', 'storage/vault.ts']) {
+ expect(isRuntimeSource(path)).toBe(true);
+ }
+ for (const path of ['server.test.ts', 'ui/client.test.tsx', 'notes.md', 'generated.js']) {
+ expect(isRuntimeSource(path)).toBe(false);
+ }
+});
diff --git a/scripts/dev.ts b/scripts/dev.ts
new file mode 100644
index 0000000..e9aa7f6
--- /dev/null
+++ b/scripts/dev.ts
@@ -0,0 +1,91 @@
+import { type FSWatcher, watch } from 'node:fs';
+
+const RESTART_DELAY_MS = 75;
+const SHUTDOWN_GRACE_MS = 1_000;
+const ROOT_RUNTIME_FILES = new Set(['icon.png', 'icon.svg', 'package.json']);
+
+export const isRuntimeSource = (path: string) => {
+ return !path.endsWith('.test.ts') && !path.endsWith('.test.tsx') && /\.(?:css|ts|tsx)$/u.test(path);
+};
+
+let child: ReturnType | undefined;
+let restartTimer: ReturnType | undefined;
+let restartQueue = Promise.resolve();
+let shuttingDown = false;
+const watchers: FSWatcher[] = [];
+
+const startServer = () => {
+ child = Bun.spawn([process.execPath, 'src/server.ts'], {
+ stderr: 'inherit',
+ stdin: 'inherit',
+ stdout: 'inherit',
+ });
+};
+
+const stopServer = async (server: ReturnType) => {
+ server.kill('SIGTERM');
+ const stopped = await Promise.race([
+ server.exited.then(() => true),
+ Bun.sleep(SHUTDOWN_GRACE_MS).then(() => false),
+ ]);
+ if (!stopped) {
+ server.kill('SIGKILL');
+ await server.exited.catch(() => undefined);
+ }
+};
+
+const restartServer = async () => {
+ const previous = child;
+ child = undefined;
+ if (previous) {
+ await stopServer(previous);
+ }
+ if (!shuttingDown) {
+ startServer();
+ }
+};
+
+const scheduleRestart = () => {
+ clearTimeout(restartTimer);
+ restartTimer = setTimeout(() => {
+ restartQueue = restartQueue.then(restartServer, restartServer);
+ }, RESTART_DELAY_MS);
+};
+
+const shutdown = async (exitCode: number) => {
+ if (shuttingDown) {
+ return;
+ }
+ shuttingDown = true;
+ clearTimeout(restartTimer);
+ for (const watcher of watchers) {
+ watcher.close();
+ }
+ await restartQueue;
+ if (child) {
+ await stopServer(child);
+ }
+ process.exit(exitCode);
+};
+
+export const startDevWatcher = () => {
+ startServer();
+ watchers.push(
+ watch('src', { recursive: true }, (_event, filename) => {
+ if (!filename || isRuntimeSource(String(filename))) {
+ scheduleRestart();
+ }
+ }),
+ watch('.', (_event, filename) => {
+ if (!filename || ROOT_RUNTIME_FILES.has(String(filename))) {
+ scheduleRestart();
+ }
+ }),
+ );
+ process.once('SIGINT', () => void shutdown(130));
+ process.once('SIGTERM', () => void shutdown(143));
+};
+
+if (import.meta.main) {
+ startDevWatcher();
+}
diff --git a/src/account-state.test.ts b/src/account-state.test.ts
new file mode 100644
index 0000000..5e35483
--- /dev/null
+++ b/src/account-state.test.ts
@@ -0,0 +1,105 @@
+import { expect, it } from 'bun:test';
+import { boundedMap, selectRefreshEntries, sortAccountEntries, stateVersion } from './account-state.ts';
+import type { LimitCache } from './types.ts';
+
+it('produces fixed-size deterministic state versions', () => {
+ const first = stateVersion({ auth: 'credential', updatedAt: 'now' });
+
+ expect(first).toHaveLength(64);
+ expect(stateVersion({ auth: 'credential', updatedAt: 'now' })).toBe(first);
+ expect(stateVersion({ auth: 'replacement', updatedAt: 'now' })).not.toBe(first);
+});
+
+it('maps every bounded input in order, including undefined values', async () => {
+ let active = 0;
+ let maximumActive = 0;
+ const result = await boundedMap(
+ [3, undefined, 1, 2],
+ async (value, index) => {
+ active += 1;
+ maximumActive = Math.max(maximumActive, active);
+ await Bun.sleep((4 - index) * 2);
+ active -= 1;
+ return `${index}:${String(value)}`;
+ },
+ 2,
+ );
+
+ expect(result).toEqual(['0:3', '1:undefined', '2:1', '3:2']);
+ expect(maximumActive).toBe(2);
+});
+
+it('normalizes invalid bounded-map concurrency to one worker', async () => {
+ let active = 0;
+ let maximumActive = 0;
+ await boundedMap(
+ [1, 2],
+ async () => {
+ active += 1;
+ maximumActive = Math.max(maximumActive, active);
+ await Bun.sleep(1);
+ active -= 1;
+ },
+ Number.NaN,
+ );
+ expect(maximumActive).toBe(1);
+});
+
+it('waits for every bounded worker to settle before propagating a failure', async () => {
+ let delayedFinished = false;
+ await expect(
+ boundedMap(
+ ['failed', 'delayed'],
+ async (value) => {
+ if (value === 'failed') {
+ throw new Error('mapper failed');
+ }
+ await Bun.sleep(10);
+ delayedFinished = true;
+ },
+ 2,
+ ),
+ ).rejects.toThrow('mapper failed');
+ expect(delayedFinished).toBe(true);
+});
+
+it('selects only stale refresh entries unless a forced target is requested', () => {
+ const data = { cached: { token: 'one' }, fresh: { token: 'two' }, stale: { token: 'three' } };
+ const cachedLimit: LimitCache = {
+ fetchedAt: '2026-01-01T00:00:00.000Z',
+ quota: { error: 'unavailable', ok: false },
+ };
+ const limits = { cached: cachedLimit, fresh: cachedLimit };
+
+ expect(selectRefreshEntries(data, limits, { force: false }).map(([key]) => key)).toEqual(['stale']);
+ expect(selectRefreshEntries(data, limits, { force: true, targetKey: 'fresh' }).map(([key]) => key)).toEqual([
+ 'fresh',
+ ]);
+});
+
+it('sorts active accounts first and depleted accounts last without mutating input', () => {
+ const available = {
+ expires: '',
+ models: { quota: { displayName: 'Quota', percentage: 50, resetTime: '' } },
+ ok: true as const,
+ tier: '',
+ };
+ const depleted = {
+ expires: '',
+ models: { quota: { displayName: 'Quota', percentage: 0, resetTime: '' } },
+ ok: true as const,
+ tier: '',
+ };
+ const entries = [
+ { active: false, key: 'depleted', quota: depleted },
+ { active: false, key: 'available', quota: available },
+ { active: true, key: 'active', quota: depleted },
+ ];
+ const originalOrder = entries.map(({ key }) => key);
+ const sorted = sortAccountEntries(entries, (quota) =>
+ quota?.ok === true ? Object.values(quota.models).every((model) => model.percentage <= 0) : false,
+ );
+
+ expect(sorted.map(({ key }) => key)).toEqual(['active', 'available', 'depleted']);
+ expect(entries.map(({ key }) => key)).toEqual(originalOrder);
+});
diff --git a/src/account-state.ts b/src/account-state.ts
new file mode 100644
index 0000000..7a52111
--- /dev/null
+++ b/src/account-state.ts
@@ -0,0 +1,79 @@
+import { createHash } from 'node:crypto';
+import { waitForAll } from './async-queue.ts';
+import type { LimitCache, LimitResult } from './types.ts';
+
+const DEFAULT_REFRESH_CONCURRENCY = 3;
+
+type AccountEntry = {
+ active: boolean;
+ key: string;
+ quota: LimitResult | null;
+};
+
+type RefreshOptions = {
+ force: boolean;
+ targetKey?: string;
+};
+
+export const CORRUPTED_ACCOUNT_ERROR = 'Saved account data is corrupted';
+
+export const stateVersion = (value: unknown) => {
+ return createHash('sha256')
+ .update(JSON.stringify(value) ?? 'undefined')
+ .digest('hex');
+};
+
+export const boundedMap = async (
+ inputs: readonly Input[],
+ mapper: (input: Input, index: number) => Promise,
+ concurrency = DEFAULT_REFRESH_CONCURRENCY,
+) => {
+ if (inputs.length === 0) {
+ return [];
+ }
+ const normalizedConcurrency = Number.isFinite(concurrency) ? Math.floor(concurrency) : 1;
+ const workerCount = Math.max(1, Math.min(normalizedConcurrency, inputs.length));
+ const results = new Array(inputs.length);
+ let nextIndex = 0;
+ const worker = async () => {
+ while (nextIndex < inputs.length) {
+ const index = nextIndex;
+ nextIndex += 1;
+ results[index] = await mapper(inputs[index] as Input, index);
+ }
+ };
+ await waitForAll(Array.from({ length: workerCount }, worker));
+ return results;
+};
+
+export const selectRefreshEntries = (
+ data: Record,
+ limits: Record,
+ options: RefreshOptions,
+) => {
+ return Object.entries(data).filter(([key]) => {
+ if (options.targetKey && key !== options.targetKey) {
+ return false;
+ }
+ return options.force || !limits[key];
+ });
+};
+
+export const sortAccountEntries = (
+ entries: readonly Entry[],
+ isDepleted?: (quota: LimitResult | null) => boolean,
+) => {
+ return [...entries].sort((a, b) => {
+ if (a.active !== b.active) {
+ return a.active ? -1 : 1;
+ }
+ if (isDepleted) {
+ const aDepleted = isDepleted(a.quota);
+ const bDepleted = isDepleted(b.quota);
+ if (aDepleted !== bDepleted) {
+ return aDepleted ? 1 : -1;
+ }
+ }
+ return a.key.localeCompare(b.key);
+ });
+};
diff --git a/src/antigravity/google.test.ts b/src/antigravity/google.test.ts
index d1ab1a6..1d3f56e 100644
--- a/src/antigravity/google.test.ts
+++ b/src/antigravity/google.test.ts
@@ -2,8 +2,14 @@ import { afterEach, expect, it } from 'bun:test';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
-import { decodeToken, fetchLimits } from './google.ts';
-import { clearGoogleOAuthClientCache, extractGoogleOAuthClient, extractGoogleOAuthClients } from './oauth.ts';
+import { decodeToken, fetchLimits, resolveGoogleIdentity } from './google.ts';
+import {
+ clearGoogleOAuthClientCache,
+ extractGoogleOAuthClient,
+ extractGoogleOAuthClients,
+ googleOAuthClients,
+ scanGoogleOAuthClients,
+} from './oauth.ts';
const originalFetch = globalThis.fetch;
const originalLanguageServerPath = process.env.ANTIGRAVITY_LANGUAGE_SERVER_PATH;
@@ -11,7 +17,11 @@ const testClientSecret = `${'GO'}${'CSPX'}-1234567890123456789012345678`;
afterEach(() => {
globalThis.fetch = originalFetch;
- process.env.ANTIGRAVITY_LANGUAGE_SERVER_PATH = originalLanguageServerPath;
+ if (originalLanguageServerPath === undefined) {
+ delete process.env.ANTIGRAVITY_LANGUAGE_SERVER_PATH;
+ } else {
+ process.env.ANTIGRAVITY_LANGUAGE_SERVER_PATH = originalLanguageServerPath;
+ }
clearGoogleOAuthClientCache();
});
@@ -24,6 +34,66 @@ it('should decode go-keyring base64 token payloads', () => {
it('should return null for invalid token payloads', () => {
expect(decodeToken('not base64 json')).toBeNull();
+ for (const value of [
+ null,
+ false,
+ 1,
+ 'text',
+ [],
+ {},
+ { token: [] },
+ { token: {} },
+ { token: { access_token: 1 } },
+ ]) {
+ expect(decodeToken(Buffer.from(JSON.stringify(value)).toString('base64'))).toBeNull();
+ }
+ const canonical = Buffer.from(JSON.stringify({ token: { access_token: 'access' } })).toString('base64');
+ expect(decodeToken(`${canonical.slice(0, -1)}A`)).toBeNull();
+ expect(decodeToken(Buffer.from([0xc3, 0x28]).toString('base64'))).toBeNull();
+});
+
+it('should resolve a stable Google subject without exposing the access token', async () => {
+ let receivedToken = '';
+ globalThis.fetch = (async (input: string | URL | Request) => {
+ const url = new URL(String(input));
+ receivedToken = url.searchParams.get('access_token') ?? '';
+ return Response.json({ sub: 'stable-google-user' });
+ }) as typeof fetch;
+ const password = Buffer.from(
+ JSON.stringify({ token: { access_token: 'rotated-access', expiry: '2999-01-01T00:00:00.000Z' } }),
+ ).toString('base64');
+
+ const result = await resolveGoogleIdentity({
+ account: 'antigravity',
+ createdAt: '',
+ kind: 'Generic Password',
+ label: 'gemini',
+ password,
+ service: 'gemini',
+ updatedAt: '',
+ });
+
+ expect(result).toEqual({ identity: 'stable-google-user' });
+ expect(receivedToken).toBe('rotated-access');
+});
+
+it('should reject an incomplete Google account identity', async () => {
+ globalThis.fetch = (async () => Response.json({ email: 'account@example.com' })) as unknown as typeof fetch;
+ const password = Buffer.from(
+ JSON.stringify({ token: { access_token: 'access', expiry: '2999-01-01T00:00:00.000Z' } }),
+ ).toString('base64');
+
+ await expect(
+ resolveGoogleIdentity({
+ account: 'antigravity',
+ createdAt: '',
+ kind: 'Generic Password',
+ label: 'gemini',
+ password,
+ service: 'gemini',
+ updatedAt: '',
+ }),
+ ).rejects.toThrow('incomplete account identity');
});
it('should extract Antigravity Google OAuth credentials from binary text', () => {
@@ -63,6 +133,177 @@ it('should return discovered OAuth candidates in retry order', () => {
]);
});
+it('should stream OAuth discovery across binary chunk boundaries', async () => {
+ const clientId = '100000000000-streamed.apps.googleusercontent.com';
+ const content = `prefix ${testClientSecret} middle ${clientId} suffix`;
+ const chunks = [
+ content.slice(0, 13),
+ content.slice(13, 38),
+ content.slice(38, 57),
+ content.slice(57, 79),
+ content.slice(79),
+ ];
+ const stream = new ReadableStream({
+ start: (controller) => {
+ for (const chunk of chunks) {
+ controller.enqueue(Buffer.from(chunk, 'latin1'));
+ }
+ controller.close();
+ },
+ });
+
+ expect(await scanGoogleOAuthClients(stream)).toContainEqual({ clientId, clientSecret: testClientSecret });
+});
+
+it('should cancel OAuth binary discovery at its scan byte ceiling', async () => {
+ let cancelled = false;
+ const clientId = '100000000000-beyond-cap.apps.googleusercontent.com';
+ const stream = new ReadableStream({
+ cancel: () => {
+ cancelled = true;
+ },
+ start: (controller) => {
+ controller.enqueue(Buffer.from('1234'));
+ controller.enqueue(Buffer.from(`${testClientSecret}\0${clientId}`));
+ },
+ });
+
+ expect(await scanGoogleOAuthClients(stream, 5)).toEqual([]);
+ expect(cancelled).toBe(true);
+});
+
+it('should share an in-flight OAuth binary scan across concurrent callers', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-antigravity-oauth-cache-test-'));
+ const path = join(dir, 'language_server');
+ const originalBunFile = Bun.file;
+ let streamCount = 0;
+ try {
+ await Bun.write(path, `${testClientSecret}\0${'100000000000-cache.apps.googleusercontent.com'}`);
+ process.env.ANTIGRAVITY_LANGUAGE_SERVER_PATH = path;
+ Bun.file = ((input: Parameters[0], options?: Parameters[1]) => {
+ const file = options === undefined ? originalBunFile(input) : originalBunFile(input, options);
+ if (String(input) !== path) {
+ return file;
+ }
+ return new Proxy(file, {
+ get: (target, property) => {
+ if (property === 'stream') {
+ return () => {
+ streamCount += 1;
+ return target.stream();
+ };
+ }
+ const value = Reflect.get(target, property, target) as unknown;
+ return typeof value === 'function' ? value.bind(target) : value;
+ },
+ });
+ }) as typeof Bun.file;
+ clearGoogleOAuthClientCache();
+
+ const [first, second, third] = await Promise.all([
+ googleOAuthClients(),
+ googleOAuthClients(),
+ googleOAuthClients(),
+ ]);
+
+ expect(first).toEqual(second);
+ expect(second).toEqual(third);
+ expect(streamCount).toBe(1);
+ } finally {
+ Bun.file = originalBunFile;
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should cache empty OAuth discovery until explicitly cleared', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-antigravity-oauth-empty-cache-test-'));
+ const path = join(dir, 'language_server');
+ const missingPath = join(dir, 'missing');
+ const originalBunFile = Bun.file;
+ let streamCount = 0;
+ try {
+ await Bun.write(path, 'no credentials');
+ process.env.ANTIGRAVITY_LANGUAGE_SERVER_PATH = path;
+ Bun.file = ((input: Parameters[0], options?: Parameters[1]) => {
+ const selected =
+ String(input) === path ? input : (missingPath as unknown as Parameters[0]);
+ const file = options === undefined ? originalBunFile(selected) : originalBunFile(selected, options);
+ if (String(input) !== path) {
+ return file;
+ }
+ return new Proxy(file, {
+ get: (target, property) => {
+ if (property === 'stream') {
+ return () => {
+ streamCount += 1;
+ return target.stream();
+ };
+ }
+ const value = Reflect.get(target, property, target) as unknown;
+ return typeof value === 'function' ? value.bind(target) : value;
+ },
+ });
+ }) as typeof Bun.file;
+ clearGoogleOAuthClientCache();
+ expect(await googleOAuthClients()).toEqual([]);
+
+ await Bun.write(path, `${testClientSecret}\0${'100000000000-retry.apps.googleusercontent.com'}`);
+ expect(await googleOAuthClients()).toEqual([]);
+ expect(streamCount).toBe(1);
+
+ clearGoogleOAuthClientCache();
+ expect(await googleOAuthClients()).toContainEqual({
+ clientId: '100000000000-retry.apps.googleusercontent.com',
+ clientSecret: testClientSecret,
+ });
+ expect(streamCount).toBe(2);
+ } finally {
+ Bun.file = originalBunFile;
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should retry OAuth discovery after a rejected scan', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-antigravity-oauth-rejected-cache-test-'));
+ const path = join(dir, 'language_server');
+ const missingPath = join(dir, 'missing');
+ const originalBunFile = Bun.file;
+ let rejectScan = true;
+ try {
+ await Bun.write(path, `${testClientSecret}\0${'100000000000-retry.apps.googleusercontent.com'}`);
+ process.env.ANTIGRAVITY_LANGUAGE_SERVER_PATH = path;
+ Bun.file = ((input: Parameters[0], options?: Parameters[1]) => {
+ const selected =
+ String(input) === path ? input : (missingPath as unknown as Parameters[0]);
+ const file = options === undefined ? originalBunFile(selected) : originalBunFile(selected, options);
+ if (String(input) !== path || !rejectScan) {
+ return file;
+ }
+ return new Proxy(file, {
+ get: (target, property) => {
+ if (property === 'stream') {
+ return () =>
+ new ReadableStream({
+ pull: () => {
+ throw new Error('scan failed');
+ },
+ });
+ }
+ const value = Reflect.get(target, property, target) as unknown;
+ return typeof value === 'function' ? value.bind(target) : value;
+ },
+ });
+ }) as typeof Bun.file;
+ clearGoogleOAuthClientCache();
+ await expect(googleOAuthClients()).rejects.toThrow('scan failed');
+ rejectScan = false;
+ expect(await googleOAuthClients()).toHaveLength(1);
+ } finally {
+ Bun.file = originalBunFile;
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
it('should fetch Antigravity limits without refreshing usable OAuth tokens', async () => {
const calls: string[] = [];
globalThis.fetch = (async (url: string | URL | Request) => {
@@ -107,6 +348,61 @@ it('should fetch Antigravity limits without refreshing usable OAuth tokens', asy
expect(calls.some((call) => call.includes('/token'))).toBe(false);
});
+it('should clamp finite Antigravity quota fractions and omit non-finite values', async () => {
+ globalThis.fetch = (async (url: string | URL | Request) => {
+ if (String(url).includes('loadCodeAssist')) {
+ return Response.json({ cloudaicompanionProject: 'project' });
+ }
+ return Response.json({
+ models: {
+ above: { displayName: 'Above', quotaInfo: { remainingFraction: 2 } },
+ below: { displayName: 'Below', quotaInfo: { remainingFraction: -1 } },
+ invalid: { displayName: 'Invalid', quotaInfo: { remainingFraction: Number.POSITIVE_INFINITY } },
+ },
+ });
+ }) as typeof fetch;
+ const payload = { token: { access_token: 'access', expiry: '2999-01-01T00:00:00.000Z' } };
+ const result = await fetchLimits({
+ account: 'antigravity',
+ createdAt: '',
+ kind: 'Generic Password',
+ label: 'gemini',
+ password: Buffer.from(JSON.stringify(payload)).toString('base64'),
+ service: 'gemini',
+ updatedAt: '',
+ });
+
+ expect(result.quota.ok).toBe(true);
+ if (result.quota.ok) {
+ expect(result.quota.models.above?.percentage).toBe(100);
+ expect(result.quota.models.below?.percentage).toBe(0);
+ expect(result.quota.models.invalid).toBeUndefined();
+ }
+});
+
+it('should reject Antigravity quota responses without validated models', async () => {
+ globalThis.fetch = (async (url: string | URL | Request) => {
+ return String(url).includes('loadCodeAssist')
+ ? Response.json({ cloudaicompanionProject: 'project' })
+ : Response.json({ models: { hostile: { quotaInfo: { remainingFraction: 'all' } } } });
+ }) as typeof fetch;
+ const password = Buffer.from(
+ JSON.stringify({ token: { access_token: 'access', expiry: '2999-01-01T00:00:00.000Z' } }),
+ ).toString('base64');
+
+ const result = await fetchLimits({
+ account: 'antigravity',
+ createdAt: '',
+ kind: 'Generic Password',
+ label: 'gemini',
+ password,
+ service: 'gemini',
+ updatedAt: '',
+ });
+
+ expect(result.quota).toEqual({ error: 'Antigravity quota returned no quota fields', ok: false });
+});
+
it('should refresh expired Antigravity access tokens and return an updated snapshot password', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dondo-antigravity-oauth-test-'));
process.env.ANTIGRAVITY_LANGUAGE_SERVER_PATH = join(dir, 'language_server');
@@ -167,6 +463,39 @@ it('should refresh expired Antigravity access tokens and return an updated snaps
}
});
+it('should reject malformed Antigravity token refresh fields', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-antigravity-oauth-invalid-response-test-'));
+ process.env.ANTIGRAVITY_LANGUAGE_SERVER_PATH = join(dir, 'language_server');
+ await Bun.write(
+ process.env.ANTIGRAVITY_LANGUAGE_SERVER_PATH,
+ [`secret ${testClientSecret}`, 'client 100000000000-test.apps.googleusercontent.com'].join('\0'),
+ );
+ globalThis.fetch = (async (url: string | URL | Request) => {
+ return String(url).includes('/token')
+ ? Response.json({ access_token: { secret: true }, expires_in: '3600' })
+ : Response.json({});
+ }) as typeof fetch;
+ const payload = {
+ token: { access_token: 'expired', expiry: '2000-01-01T00:00:00.000Z', refresh_token: 'refresh' },
+ };
+
+ try {
+ await expect(
+ fetchLimits({
+ account: 'antigravity',
+ createdAt: '',
+ kind: 'Generic Password',
+ label: 'gemini',
+ password: Buffer.from(JSON.stringify(payload)).toString('base64'),
+ service: 'gemini',
+ updatedAt: '',
+ }),
+ ).rejects.toThrow('response was incomplete');
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
it('should force refresh Antigravity tokens after a 401 project response', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dondo-antigravity-oauth-test-'));
process.env.ANTIGRAVITY_LANGUAGE_SERVER_PATH = join(dir, 'language_server');
@@ -228,3 +557,67 @@ it('should force refresh Antigravity tokens after a 401 project response', async
await rm(dir, { force: true, recursive: true });
}
});
+
+it('should not expose hostile Antigravity upstream error messages', async () => {
+ globalThis.fetch = (async () =>
+ Response.json(
+ { error: { message: 'token=secret-provider-value', status: 'SECRET_PROVIDER_TOKEN_VALUE' } },
+ { status: 500 },
+ )) as unknown as typeof fetch;
+ const password = Buffer.from(
+ JSON.stringify({ token: { access_token: 'access', expiry: '2999-01-01T00:00:00.000Z' } }),
+ ).toString('base64');
+
+ const error = await fetchLimits({
+ account: 'antigravity',
+ createdAt: '',
+ kind: 'Generic Password',
+ label: 'gemini',
+ password,
+ service: 'gemini',
+ updatedAt: '',
+ }).catch((value) => String(value));
+
+ expect(error).toContain('HTTP 500');
+ expect(error).not.toContain('secret-provider-value');
+ expect(error).not.toContain('SECRET_PROVIDER_TOKEN_VALUE');
+});
+
+it('should cap failed Antigravity OAuth refresh attempts and duration', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-antigravity-oauth-budget-test-'));
+ process.env.ANTIGRAVITY_LANGUAGE_SERVER_PATH = join(dir, 'language_server');
+ const values = Array.from({ length: 10 }, (_, index) => [
+ `${'GO'}${'CSPX'}-${String(index).padStart(28, '0')}`,
+ `${100000000000 + index}-client-${index}.apps.googleusercontent.com`,
+ ]).flat();
+ await Bun.write(process.env.ANTIGRAVITY_LANGUAGE_SERVER_PATH, values.join('\0'));
+ let attempts = 0;
+ globalThis.fetch = (async (url: string | URL | Request) => {
+ if (String(url).includes('/token')) {
+ attempts += 1;
+ return new Response('', { status: 400 });
+ }
+ return Response.json({});
+ }) as typeof fetch;
+ const payload = {
+ token: { access_token: 'expired', expiry: '2000-01-01T00:00:00.000Z', refresh_token: 'refresh' },
+ };
+ const startedAt = performance.now();
+ try {
+ await expect(
+ fetchLimits({
+ account: 'antigravity',
+ createdAt: '',
+ kind: 'Generic Password',
+ label: 'gemini',
+ password: Buffer.from(JSON.stringify(payload)).toString('base64'),
+ service: 'gemini',
+ updatedAt: '',
+ }),
+ ).rejects.toThrow('Token refresh failed');
+ expect(attempts).toBe(8);
+ expect(performance.now() - startedAt).toBeLessThan(1_000);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
diff --git a/src/antigravity/google.ts b/src/antigravity/google.ts
index 3a716de..197d503 100644
--- a/src/antigravity/google.ts
+++ b/src/antigravity/google.ts
@@ -1,5 +1,6 @@
import { ANTIGRAVITY_VERSION, GOOGLE_TOKEN_URL, LOAD_PROJECT_URL, QUOTA_URLS } from '../config.ts';
-import type { LimitResult, Snapshot, TokenPayload } from '../types.ts';
+import { discardResponse, readBoundedResponseJson } from '../http.ts';
+import type { AntigravityCredential, LimitResult, TokenPayload } from '../types.ts';
import { googleOAuthClients } from './oauth.ts';
type JsonObject = Record;
@@ -14,55 +15,99 @@ type GoogleRefreshResponse = {
};
const REQUEST_TIMEOUT_MS = 15_000;
+const TOKEN_REQUEST_TIMEOUT_MS = 4_000;
+const TOKEN_REFRESH_DEADLINE_MS = 16_000;
+const MAX_OAUTH_ATTEMPTS = 8;
const EXPIRY_GRACE_MS = 60_000;
const TOKEN_PREFIX = 'go-keyring-base64:';
+const TOKEN_INFO_URL = 'https://oauth2.googleapis.com/tokeninfo';
const asObject = (value: unknown): JsonObject => {
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as JsonObject) : {};
};
const stringValue = (value: unknown) => (typeof value === 'string' ? value : undefined);
-const numberValue = (value: unknown) => (typeof value === 'number' ? value : undefined);
+const finiteNumber = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
const staleTokenQuota = {
error: 'Saved Antigravity credentials are expired or rejected. Use this account in Antigravity, then click Sync current on this saved row.',
ok: false as const,
};
+const hasOnlyStringFields = (record: Record, fields: readonly string[]) => {
+ return fields.every((field) => record[field] === undefined || typeof record[field] === 'string');
+};
+
+const decodeCanonicalBase64Json = (encoded: string) => {
+ if (!encoded || !/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded) || encoded.length % 4 !== 0) {
+ return null;
+ }
+ const decoded = Buffer.from(encoded, 'base64');
+ if (decoded.toString('base64') !== encoded) {
+ return null;
+ }
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(decoded)) as unknown;
+};
+
+const isUsableToken = (value: unknown): value is NonNullable => {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ return false;
+ }
+ const token = value as Record;
+ if (!hasOnlyStringFields(token, ['access_token', 'refresh_token', 'expiry', 'token_type'])) {
+ return false;
+ }
+ return [token.access_token, token.refresh_token].some(
+ (credential) => typeof credential === 'string' && Boolean(credential.trim()),
+ );
+};
+
export const decodeToken = (password: string): TokenPayload | null => {
try {
const encoded = password.startsWith(TOKEN_PREFIX) ? password.slice(TOKEN_PREFIX.length) : password;
- return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'));
+ const value = decodeCanonicalBase64Json(encoded);
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ return null;
+ }
+ const payload = value as Record;
+ if (!hasOnlyStringFields(payload, ['auth_method']) || !isUsableToken(payload.token)) {
+ return null;
+ }
+ return value as TokenPayload;
} catch {
return null;
}
};
+const parseRefreshResponse = (value: Record): GoogleRefreshResponse | null => {
+ if (typeof value.access_token !== 'string' || !value.access_token.trim()) {
+ return null;
+ }
+ if (value.refresh_token !== undefined && (typeof value.refresh_token !== 'string' || !value.refresh_token.trim())) {
+ return null;
+ }
+ if (
+ value.expires_in !== undefined &&
+ (typeof value.expires_in !== 'number' || !Number.isFinite(value.expires_in))
+ ) {
+ return null;
+ }
+ return value as GoogleRefreshResponse;
+};
+
const encodeToken = (password: string, payload: TokenPayload) => {
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64');
return password.startsWith(TOKEN_PREFIX) ? `${TOKEN_PREFIX}${encoded}` : encoded;
};
const headers = (accessToken: string) => {
- const platform = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'windows' : 'linux';
const arch = process.arch === 'x64' ? 'amd64' : process.arch;
return {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
- 'User-Agent': `antigravity/${ANTIGRAVITY_VERSION} ${platform}/${arch}`,
+ 'User-Agent': `antigravity/${ANTIGRAVITY_VERSION} darwin/${arch}`,
};
};
-const errorStatus = async (res: Response) => {
- const text = await res.text();
- try {
- const body = asObject(JSON.parse(text));
- const error = asObject(body.error);
- return stringValue(error.status) ?? stringValue(error.message) ?? res.statusText;
- } catch {
- return res.statusText;
- }
-};
-
const postJson = async (url: string, accessToken: string, body: unknown) => {
const res = await fetch(url, {
body: JSON.stringify(body),
@@ -71,10 +116,10 @@ const postJson = async (url: string, accessToken: string, body: unknown) => {
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) {
- const status = await errorStatus(res);
- throw new Error(`HTTP ${res.status}${status ? `: ${status}` : ''}`);
+ await discardResponse(res);
+ throw new Error(`HTTP ${res.status}`);
}
- return res.json();
+ return readBoundedResponseJson(res, 'Antigravity quota');
};
const refreshAccessToken = async (refreshToken: string) => {
@@ -84,22 +129,39 @@ const refreshAccessToken = async (refreshToken: string) => {
}
let lastStatus = '';
- for (const client of clients) {
- const res = await fetch(GOOGLE_TOKEN_URL, {
- body: new URLSearchParams({
- client_id: client.clientId,
- client_secret: client.clientSecret,
- grant_type: 'refresh_token',
- refresh_token: refreshToken,
- }),
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
- method: 'POST',
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- });
+ const deadline = Date.now() + TOKEN_REFRESH_DEADLINE_MS;
+ for (const client of clients.slice(0, MAX_OAUTH_ATTEMPTS)) {
+ const remainingMs = deadline - Date.now();
+ if (remainingMs <= 0) {
+ break;
+ }
+ let res: Response;
+ try {
+ res = await fetch(GOOGLE_TOKEN_URL, {
+ body: new URLSearchParams({
+ client_id: client.clientId,
+ client_secret: client.clientSecret,
+ grant_type: 'refresh_token',
+ refresh_token: refreshToken,
+ }),
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ method: 'POST',
+ signal: AbortSignal.timeout(Math.max(1, Math.min(TOKEN_REQUEST_TIMEOUT_MS, remainingMs))),
+ });
+ } catch {
+ lastStatus = 'request failed';
+ continue;
+ }
if (res.ok) {
- return (await res.json()) as GoogleRefreshResponse;
+ const value = await readBoundedResponseJson>(res, 'Antigravity token refresh');
+ const refreshed = parseRefreshResponse(value);
+ if (!refreshed) {
+ throw new Error('Antigravity token refresh response was incomplete');
+ }
+ return refreshed;
}
lastStatus = `HTTP ${res.status}`;
+ await discardResponse(res);
}
throw new Error(`Token refresh failed${lastStatus ? `: ${lastStatus}` : ''}`);
@@ -123,10 +185,15 @@ const validAccessToken = async (token: NonNullable, force
return { accessToken: undefined };
}
+ const expiresIn = finiteNumber(refreshed.expires_in);
+ const nextExpiry =
+ expiresIn !== undefined && expiresIn > 0 && expiresIn <= 31_536_000
+ ? new Date(Date.now() + expiresIn * 1_000).toISOString()
+ : token.expiry;
const nextToken = {
...token,
access_token: refreshed.access_token,
- expiry: refreshed.expires_in ? new Date(Date.now() + refreshed.expires_in * 1000).toISOString() : token.expiry,
+ ...(nextExpiry ? { expiry: nextExpiry } : {}),
refresh_token: refreshed.refresh_token ?? token.refresh_token,
};
return { accessToken: refreshed.access_token, token: nextToken };
@@ -156,23 +223,28 @@ const quotaWithAccessToken = async (accessToken: string, expires: string): Promi
const data = asObject(await postJson(url, accessToken, project ? { project } : {}));
const responseModels = asObject(data.models);
const models = Object.fromEntries(
- Object.entries(responseModels)
- .filter(([, info]) => {
- return Boolean(asObject(info).quotaInfo);
- })
- .map(([name, info]) => {
- const model = asObject(info);
- const quotaInfo = asObject(model.quotaInfo);
- return [
+ Object.entries(responseModels).flatMap(([name, info]) => {
+ const model = asObject(info);
+ const quotaInfo = asObject(model.quotaInfo);
+ const remainingFraction = finiteNumber(quotaInfo.remainingFraction);
+ if (remainingFraction === undefined) {
+ return [];
+ }
+ return [
+ [
name,
{
displayName: stringValue(model.displayName) ?? name,
- percentage: Math.round((numberValue(quotaInfo.remainingFraction) ?? 0) * 100),
+ percentage: Math.round(Math.max(0, Math.min(1, remainingFraction)) * 100),
resetTime: stringValue(quotaInfo.resetTime) ?? '',
},
- ];
- }),
+ ] as const,
+ ];
+ }),
);
+ if (Object.keys(models).length === 0) {
+ return { error: 'Antigravity quota returned no quota fields', ok: false };
+ }
return {
expires,
models,
@@ -187,14 +259,11 @@ const quotaWithAccessToken = async (accessToken: string, expires: string): Promi
}
}
- return {
- error: `Quota API is rate-limited or unavailable${lastError ? ` (${lastError.replace(/^Error:\s*/, '')})` : ''}`,
- ok: false,
- };
+ return { error: 'Quota API is rate-limited or unavailable', ok: false };
};
const resultWithAccessToken = async (
- snap: Snapshot,
+ snap: AntigravityCredential,
payload: TokenPayload,
token: NonNullable,
forceRefresh = false,
@@ -205,12 +274,12 @@ const resultWithAccessToken = async (
}
return {
- password: refreshedToken ? encodeToken(snap.password, { ...payload, token: refreshedToken }) : undefined,
+ ...(refreshedToken ? { password: encodeToken(snap.password, { ...payload, token: refreshedToken }) } : {}),
quota: await quotaWithAccessToken(accessToken, refreshedToken?.expiry ?? token.expiry ?? ''),
};
};
-export const fetchLimits = async (snap: Snapshot): Promise => {
+export const fetchLimits = async (snap: AntigravityCredential): Promise => {
const payload = decodeToken(snap.password);
const token = payload?.token;
if (!payload || !token || (!token.access_token && !token.refresh_token)) {
@@ -234,3 +303,30 @@ export const fetchLimits = async (snap: Snapshot): Promise =>
throw error;
}
};
+
+export const resolveGoogleIdentity = async (snap: AntigravityCredential) => {
+ const payload = decodeToken(snap.password);
+ const token = payload?.token;
+ if (!payload || !token) {
+ throw new Error('Antigravity credential payload is invalid');
+ }
+ const { accessToken, token: refreshedToken } = await validAccessToken(token);
+ if (!accessToken) {
+ throw new Error('Antigravity credential has no usable access token');
+ }
+ const url = new URL(TOKEN_INFO_URL);
+ url.searchParams.set('access_token', accessToken);
+ const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
+ if (!response.ok) {
+ await discardResponse(response);
+ throw new Error(`Google identity lookup failed with HTTP ${response.status}`);
+ }
+ const value = await readBoundedResponseJson>(response, 'Google identity lookup');
+ if (typeof value.sub !== 'string' || !value.sub.trim() || Buffer.byteLength(value.sub, 'utf8') > 256) {
+ throw new Error('Google identity lookup returned an incomplete account identity');
+ }
+ return {
+ identity: value.sub,
+ ...(refreshedToken ? { password: encodeToken(snap.password, { ...payload, token: refreshedToken }) } : {}),
+ };
+};
diff --git a/src/antigravity/keychain.test.ts b/src/antigravity/keychain.test.ts
index 94976ce..fe1cf29 100644
--- a/src/antigravity/keychain.test.ts
+++ b/src/antigravity/keychain.test.ts
@@ -1,8 +1,218 @@
import { expect, it } from 'bun:test';
-import { parsePassword } from './keychain.ts';
+import type { run } from '../shell.ts';
+import type { AntigravityCredential } from '../types.ts';
+import {
+ clearLiveAuth,
+ deleteLivePassword,
+ parsePassword,
+ readCurrentSnapshot,
+ replaceLiveSnapshot,
+} from './keychain.ts';
+
+const snapshot = (password: string): AntigravityCredential => ({
+ account: 'antigravity',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ kind: 'Generic Password',
+ label: 'gemini',
+ password,
+ service: 'gemini',
+ updatedAt: '2026-01-01T00:00:00.000Z',
+});
+
+const runError = (code: number, message = `security failed ${code}`) => {
+ return Object.assign(new Error(message), { code, stderr: '', stdout: '' });
+};
it('should parse escaped keychain password output without greedy capture', () => {
const stderr = '"labl"="gemini"\npassword: "go-keyring-base64:abc\\"def"\n"extra"="ignored"';
expect(parsePassword(stderr)).toBe('go-keyring-base64:abc"def');
});
+
+it('should read the current snapshot through the injected command runner', async () => {
+ let command = '';
+ const runCommand = (async (cmd) => {
+ command = cmd;
+ return {
+ stderr: '"labl"="gemini"\npassword: "go-keyring-base64:test"',
+ stdout: '',
+ };
+ }) as typeof run;
+
+ const current = await readCurrentSnapshot(runCommand);
+
+ expect(current).toMatchObject({
+ account: 'antigravity',
+ label: 'gemini',
+ password: 'go-keyring-base64:test',
+ service: 'gemini',
+ });
+ expect(command).toBe('/usr/bin/security');
+});
+
+it('should wrap Keychain read failures in fixed public copy', async () => {
+ const denied = (async () => {
+ throw runError(55, 'security says password: "private"');
+ }) as typeof run;
+
+ const error = await readCurrentSnapshot(denied).catch((value: unknown) => value);
+ expect((error as { status?: number }).status).toBe(500);
+ expect(String(error)).toContain('Dondo could not access the current Antigravity credential in macOS Keychain');
+ expect(String(error)).not.toContain('private');
+});
+
+it('should replace an absent credential through repeated prompted stdin without a secret argv', async () => {
+ const next = snapshot('go-keyring-base64:private-snapshot');
+ const invocations: Array<{ args: string[]; stdin: string | undefined }> = [];
+ const runCommand = (async (_cmd, args, options) => {
+ invocations.push({ args, stdin: options?.stdin });
+ if (args.at(-1) === '-g') {
+ throw runError(44);
+ }
+ if (args[0] === 'find-generic-password') {
+ return { stderr: '', stdout: `${next.password}\n` };
+ }
+ return { stderr: '', stdout: '' };
+ }) as typeof run;
+
+ await replaceLiveSnapshot(next, runCommand);
+
+ const add = invocations.find(({ args }) => args[0] === 'add-generic-password');
+ expect(add?.args.at(-1)).toBe('-w');
+ expect(add?.args).not.toContain(next.password);
+ expect(add?.args).not.toContain('login.keychain-db');
+ expect(add?.stdin).toBe(`${next.password}\n${next.password}\n`);
+});
+
+it('should restore the previous credential after a failed replacement', async () => {
+ const previous = snapshot('go-keyring-base64:previous');
+ const next = snapshot('go-keyring-base64:next');
+ const additions: Array<{ args: string[]; stdin: string | undefined }> = [];
+ let addCalls = 0;
+ const runCommand = (async (_cmd, args, options) => {
+ if (args.at(-1) === '-g') {
+ return {
+ stderr: `"labl"="gemini"\npassword: "${previous.password}"`,
+ stdout: '',
+ };
+ }
+ if (args[0] === 'add-generic-password') {
+ additions.push({ args, stdin: options?.stdin });
+ addCalls += 1;
+ if (addCalls === 1) {
+ throw runError(55);
+ }
+ return { stderr: '', stdout: '' };
+ }
+ return { stderr: '', stdout: `${previous.password}\n` };
+ }) as typeof run;
+
+ await expect(replaceLiveSnapshot(next, runCommand)).rejects.toThrow(
+ 'Dondo could not replace the Antigravity credential in macOS Keychain',
+ );
+ expect(additions.map(({ stdin }) => stdin)).toEqual([
+ `${next.password}\n${next.password}\n`,
+ `${previous.password}\n${previous.password}\n`,
+ ]);
+ for (const addition of additions) {
+ expect(addition.args).not.toContain(next.password);
+ expect(addition.args).not.toContain(previous.password);
+ }
+});
+
+it('should delete a newly created credential when exact readback fails', async () => {
+ const next = snapshot('go-keyring-base64:private-snapshot');
+ const commands: string[] = [];
+ const runCommand = (async (_cmd, args) => {
+ commands.push(args[0] ?? '');
+ if (args.at(-1) === '-g') {
+ throw runError(44);
+ }
+ return { stderr: '', stdout: '' };
+ }) as typeof run;
+
+ await expect(replaceLiveSnapshot(next, runCommand)).rejects.toThrow(
+ 'Dondo could not replace the Antigravity credential in macOS Keychain',
+ );
+ expect(commands).toEqual([
+ 'find-generic-password',
+ 'add-generic-password',
+ 'find-generic-password',
+ 'delete-generic-password',
+ ]);
+});
+
+it('should return a fixed error when replacement rollback also fails', async () => {
+ const previous = snapshot('go-keyring-base64:previous');
+ const next = snapshot('go-keyring-base64:next');
+ const runCommand = (async (_cmd, args) => {
+ if (args.at(-1) === '-g') {
+ return {
+ stderr: `"labl"="gemini"\npassword: "${previous.password}"`,
+ stdout: '',
+ };
+ }
+ throw runError(55, `failed with ${next.password} and ${previous.password}`);
+ }) as typeof run;
+
+ const error = await replaceLiveSnapshot(next, runCommand).catch((value: unknown) => value);
+ expect(String(error)).toContain('could not restore the previous Antigravity credential');
+ expect(String(error)).not.toContain(next.password);
+ expect(String(error)).not.toContain(previous.password);
+});
+
+it('should abort before replacement when the prior credential cannot be read', async () => {
+ const commands: string[] = [];
+ const runCommand = (async (_cmd, args) => {
+ commands.push(args[0] ?? '');
+ throw runError(55);
+ }) as typeof run;
+
+ await expect(replaceLiveSnapshot(snapshot('private'), runCommand)).rejects.toThrow(
+ 'Dondo could not access the current Antigravity credential in macOS Keychain',
+ );
+ expect(commands).toEqual(['find-generic-password']);
+});
+
+it('should ignore only Keychain not-found failures when deleting a live credential', async () => {
+ const missing = (async () => {
+ throw runError(44);
+ }) as typeof run;
+ const denied = (async () => {
+ throw runError(55);
+ }) as typeof run;
+
+ await expect(deleteLivePassword(missing)).resolves.toBeUndefined();
+ const error = await deleteLivePassword(denied).catch((value: unknown) => value);
+ expect((error as { status?: number }).status).toBe(500);
+ expect(String(error)).toContain('Dondo could not delete the Antigravity credential from macOS Keychain');
+ expect(String(error)).not.toContain('security failed 55');
+});
+
+it('should preserve the live credential when local-state cleanup fails', async () => {
+ let deleted = false;
+ const runCommand = (async () => {
+ deleted = true;
+ return { stderr: '', stdout: '' };
+ }) as typeof run;
+ const clearState = async () => {
+ expect(deleted).toBe(false);
+ throw new Error('cleanup failed');
+ };
+
+ await expect(clearLiveAuth(runCommand, clearState)).rejects.toThrow('cleanup failed');
+ expect(deleted).toBe(false);
+});
+
+it('should delete the live credential only after local-state cleanup succeeds', async () => {
+ const events: string[] = [];
+ const runCommand = (async () => {
+ events.push('delete');
+ return { stderr: '', stdout: '' };
+ }) as typeof run;
+
+ await clearLiveAuth(runCommand, async () => {
+ events.push('clear-state');
+ });
+ expect(events).toEqual(['clear-state', 'delete']);
+});
diff --git a/src/antigravity/keychain.ts b/src/antigravity/keychain.ts
index 8a585dc..61fbe7e 100644
--- a/src/antigravity/keychain.ts
+++ b/src/antigravity/keychain.ts
@@ -1,9 +1,13 @@
import { rm } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
-import { ANTIGRAVITY_ACCOUNT, ANTIGRAVITY_KEYCHAIN, ANTIGRAVITY_SERVICE } from '../config.ts';
-import { run } from '../shell.ts';
-import type { Snapshot } from '../types.ts';
+import { waitForAll } from '../async-queue.ts';
+import { ANTIGRAVITY_ACCOUNT, ANTIGRAVITY_SERVICE } from '../config.ts';
+import { publicError } from '../errors.ts';
+import { isRunError, run } from '../shell.ts';
+import type { AntigravityCredential } from '../types.ts';
+
+const SECURITY_PATH = '/usr/bin/security';
export const parsePassword = (stderr: string) => {
const match = stderr.match(/password: "((?:\\"|[^"])*)"/);
@@ -13,30 +17,28 @@ export const parsePassword = (stderr: string) => {
return match[1]?.replace(/\\"/g, '"') ?? '';
};
-const keychainArgs = () => {
- return ANTIGRAVITY_KEYCHAIN ? [ANTIGRAVITY_KEYCHAIN] : [];
-};
-
-const deleteLivePassword = async () => {
- await run('security', [
+export const deleteLivePassword = async (runCommand: typeof run = run) => {
+ await runCommand(SECURITY_PATH, [
'delete-generic-password',
'-s',
ANTIGRAVITY_SERVICE,
'-a',
ANTIGRAVITY_ACCOUNT,
- ...keychainArgs(),
- ]).catch(() => {});
+ ]).catch((error) => {
+ if (!isRunError(error) || error.code !== 44) {
+ throw publicError(500, 'Dondo could not delete the Antigravity credential from macOS Keychain');
+ }
+ });
};
-export const readCurrentSnapshot = async (): Promise => {
- const { stderr } = await run('security', [
+const readSnapshot = async (runCommand: typeof run): Promise => {
+ const { stderr } = await runCommand(SECURITY_PATH, [
'find-generic-password',
'-s',
ANTIGRAVITY_SERVICE,
'-a',
ANTIGRAVITY_ACCOUNT,
'-g',
- ...keychainArgs(),
]);
const now = new Date().toISOString();
return {
@@ -50,29 +52,64 @@ export const readCurrentSnapshot = async (): Promise => {
};
};
-export const restoreSnapshot = async (snap: Snapshot) => {
- await deleteLivePassword();
- await run('security', [
- 'add-generic-password',
+const readOptionalSnapshot = async (runCommand: typeof run) => {
+ return readSnapshot(runCommand).catch((error) => {
+ if (isRunError(error) && error.code === 44) {
+ return null;
+ }
+ throw publicError(500, 'Dondo could not access the current Antigravity credential in macOS Keychain');
+ });
+};
+
+export const readCurrentSnapshot = async (runCommand: typeof run = run): Promise => {
+ return readSnapshot(runCommand).catch(() => {
+ throw publicError(500, 'Dondo could not access the current Antigravity credential in macOS Keychain');
+ });
+};
+
+const writeAndVerifySnapshot = async (snap: AntigravityCredential, runCommand: typeof run) => {
+ await runCommand(
+ SECURITY_PATH,
+ ['add-generic-password', '-s', snap.service, '-a', snap.account, '-l', snap.label, '-D', snap.kind, '-U', '-w'],
+ { stdin: `${snap.password}\n${snap.password}\n` },
+ );
+ const restored = await runCommand(SECURITY_PATH, [
+ 'find-generic-password',
'-s',
snap.service,
'-a',
snap.account,
- '-l',
- snap.label,
- '-D',
- snap.kind,
'-w',
- snap.password,
- '-U',
- ...keychainArgs(),
]);
+ if (restored.stdout.replace(/\r?\n$/, '') !== snap.password) {
+ throw new Error('macOS Keychain did not persist the restored credential');
+ }
};
-export const clearLiveAuth = async () => {
- await deleteLivePassword();
+export const replaceLiveSnapshot = async (snap: AntigravityCredential, runCommand: typeof run = run) => {
+ const previous = await readOptionalSnapshot(runCommand);
+ try {
+ await writeAndVerifySnapshot(snap, runCommand);
+ } catch {
+ try {
+ if (previous) {
+ await writeAndVerifySnapshot(previous, runCommand);
+ } else {
+ await deleteLivePassword(runCommand);
+ }
+ } catch {
+ throw publicError(
+ 500,
+ 'Dondo could not restore the previous Antigravity credential after a failed replacement',
+ );
+ }
+ throw publicError(500, 'Dondo could not replace the Antigravity credential in macOS Keychain');
+ }
+};
+
+export const clearLocalState = async () => {
const home = homedir();
- await Promise.all(
+ await waitForAll(
[
join(home, '.antigravity-agent', 'cloud_accounts.db'),
join(home, '.gemini', 'antigravity'),
@@ -82,3 +119,11 @@ export const clearLiveAuth = async () => {
].map((path) => rm(path, { force: true, recursive: true })),
);
};
+
+export const clearLiveAuth = async (
+ runCommand: typeof run = run,
+ clearState: typeof clearLocalState = clearLocalState,
+) => {
+ await clearState();
+ await deleteLivePassword(runCommand);
+};
diff --git a/src/antigravity/oauth.ts b/src/antigravity/oauth.ts
index d316723..5fb3e4e 100644
--- a/src/antigravity/oauth.ts
+++ b/src/antigravity/oauth.ts
@@ -1,6 +1,6 @@
import { homedir } from 'node:os';
-import { join } from 'node:path';
-import { ANTIGRAVITY_LANGUAGE_SERVER_PATH } from '../config.ts';
+import { join, resolve } from 'node:path';
+import { antigravityLanguageServerPath } from '../config.ts';
type GoogleOAuthClient = {
clientId: string;
@@ -8,58 +8,132 @@ type GoogleOAuthClient = {
};
const googleSecretPrefix = 'GO' + 'CSPX-';
-const clientIdPattern = /\d{10,}-[A-Za-z0-9_-]+\.apps\.googleusercontent\.com/g;
+const clientIdPattern = /\d{10,}-[A-Za-z0-9_-]{1,128}\.apps\.googleusercontent\.com/g;
const clientSecretPattern = new RegExp(`${googleSecretPrefix}[A-Za-z0-9_-]{28}`, 'g');
const isNonEmptyString = (value: string | undefined): value is string => Boolean(value);
+const SCAN_TAIL_BYTES = 512;
+const MAX_LANGUAGE_SERVER_SCAN_BYTES = 192 * 1024 * 1024;
+const MAX_DISCOVERED_VALUES = 64;
+const MAX_CLIENT_CANDIDATES = 8;
-const defaultLanguageServerPaths = () =>
- [
- process.env.ANTIGRAVITY_LANGUAGE_SERVER_PATH?.trim(),
- ANTIGRAVITY_LANGUAGE_SERVER_PATH,
- '/Applications/Antigravity.app/Contents/Resources/bin/language_server',
- join(homedir(), 'Applications', 'Antigravity.app', 'Contents', 'Resources', 'bin', 'language_server'),
- ].filter(isNonEmptyString);
+type PositionedValue = {
+ index: number;
+ value: string;
+};
+
+const defaultLanguageServerPaths = () => [
+ ...new Set(
+ [
+ antigravityLanguageServerPath(),
+ '/Applications/Antigravity.app/Contents/Resources/bin/language_server',
+ join(homedir(), 'Applications', 'Antigravity.app', 'Contents', 'Resources', 'bin', 'language_server'),
+ ]
+ .filter(isNonEmptyString)
+ .map((path) => resolve(path)),
+ ),
+];
-let cachedClients: GoogleOAuthClient[] | undefined;
+let cachedClients: Promise | undefined;
export const clearGoogleOAuthClientCache = () => {
cachedClients = undefined;
};
-export const extractGoogleOAuthClients = (content: string): GoogleOAuthClient[] => {
- const clientIds = [...content.matchAll(clientIdPattern)].map((match) => ({
- index: match.index ?? -1,
- value: match[0],
- }));
- const clientSecrets = [...content.matchAll(clientSecretPattern)].map((match) => ({
- index: match.index ?? -1,
- value: match[0],
- }));
+const clientCandidates = (clientIds: PositionedValue[], clientSecrets: PositionedValue[]): GoogleOAuthClient[] => {
if (clientIds.length === 0 || clientSecrets.length === 0) {
return [];
}
- return [...clientIds].reverse().flatMap((clientId) => {
- const priorSecrets = clientSecrets.filter((secret) => secret.index <= clientId.index).reverse();
- const laterSecrets = clientSecrets.filter((secret) => secret.index > clientId.index);
- return [...priorSecrets, ...laterSecrets].map((secret) => ({
- clientId: clientId.value,
- clientSecret: secret.value,
- }));
- });
+ return [...clientIds]
+ .reverse()
+ .flatMap((clientId) => {
+ const priorSecrets = clientSecrets.filter((secret) => secret.index <= clientId.index).reverse();
+ const laterSecrets = clientSecrets.filter((secret) => secret.index > clientId.index);
+ return [...priorSecrets, ...laterSecrets].map((secret) => ({
+ clientId: clientId.value,
+ clientSecret: secret.value,
+ }));
+ })
+ .slice(0, MAX_CLIENT_CANDIDATES);
+};
+
+const matches = (content: string, pattern: RegExp, offset = 0): PositionedValue[] => {
+ return [...content.matchAll(pattern)].map((match) => ({
+ index: offset + (match.index ?? 0),
+ value: match[0],
+ }));
+};
+
+export const extractGoogleOAuthClients = (content: string): GoogleOAuthClient[] => {
+ return clientCandidates(matches(content, clientIdPattern), matches(content, clientSecretPattern));
};
export const extractGoogleOAuthClient = (content: string) => {
return extractGoogleOAuthClients(content)[0] ?? null;
};
+const appendUniqueMatches = (values: PositionedValue[], additions: PositionedValue[]) => {
+ for (const addition of additions) {
+ if (!values.some((value) => value.index === addition.index && value.value === addition.value)) {
+ values.push(addition);
+ if (values.length > MAX_DISCOVERED_VALUES) {
+ values.shift();
+ }
+ }
+ }
+};
+
+export const scanGoogleOAuthClients = async (
+ stream: ReadableStream,
+ maxBytes = MAX_LANGUAGE_SERVER_SCAN_BYTES,
+) => {
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
+ throw new TypeError('OAuth binary scan byte limit must be a non-negative safe integer');
+ }
+ const clientIds: PositionedValue[] = [];
+ const clientSecrets: PositionedValue[] = [];
+ const reader = stream.getReader();
+ let tail = '';
+ let bytesRead = 0;
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ break;
+ }
+ const remaining = maxBytes - bytesRead;
+ if (remaining <= 0) {
+ await reader.cancel();
+ break;
+ }
+ const boundedValue = value.subarray(0, remaining);
+ const chunk = Buffer.from(boundedValue).toString('latin1');
+ const content = `${tail}${chunk}`;
+ const offset = bytesRead - tail.length;
+ appendUniqueMatches(clientIds, matches(content, clientIdPattern, offset));
+ appendUniqueMatches(clientSecrets, matches(content, clientSecretPattern, offset));
+ bytesRead += boundedValue.byteLength;
+ tail = content.slice(-SCAN_TAIL_BYTES);
+ if (boundedValue.byteLength < value.byteLength || bytesRead >= maxBytes) {
+ await reader.cancel();
+ break;
+ }
+ }
+ } finally {
+ reader.releaseLock();
+ }
+
+ return clientCandidates(clientIds, clientSecrets);
+};
+
const readDiscoveredClients = async () => {
for (const path of defaultLanguageServerPaths()) {
const file = Bun.file(path);
if (!(await file.exists())) {
continue;
}
- const clients = extractGoogleOAuthClients(await file.text());
+ const clients = await scanGoogleOAuthClients(file.stream());
if (clients.length > 0) {
return clients;
}
@@ -68,6 +142,14 @@ const readDiscoveredClients = async () => {
};
export const googleOAuthClients = async () => {
- cachedClients ??= await readDiscoveredClients();
- return cachedClients;
+ const discovery = cachedClients ?? readDiscoveredClients();
+ cachedClients = discovery;
+ try {
+ return await discovery;
+ } catch (error) {
+ if (cachedClients === discovery) {
+ cachedClients = undefined;
+ }
+ throw error;
+ }
};
diff --git a/src/antigravity/service.test.ts b/src/antigravity/service.test.ts
new file mode 100644
index 0000000..d6d4401
--- /dev/null
+++ b/src/antigravity/service.test.ts
@@ -0,0 +1,308 @@
+import { expect, it } from 'bun:test';
+
+const runAntigravityScript = async (script: string, env: Record = {}) => {
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: { ...process.env, ANTIGRAVITY_PROCESS_NAME: 'dondo-antigravity-test-not-running', ...env },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ return JSON.parse(stdout) as unknown;
+};
+
+it('should preserve Antigravity account creation time when replacing a healthy row', async () => {
+ const script = `
+ const { mock } = await import('bun:test');
+ const password = Buffer.from(JSON.stringify({ token: { access_token: 'access' } })).toString('base64');
+ const snapshot = {
+ account: 'antigravity', createdAt: 'new-created', kind: 'Generic Password', label: 'gemini',
+ identity: 'google-user', password, service: 'gemini', updatedAt: 'new-updated',
+ };
+ const section = {
+ data: { saved: { ...snapshot, createdAt: 'original-created', updatedAt: 'old-updated' } },
+ limits: {},
+ };
+ mock.module('./src/storage/vault.ts', () => ({
+ readVaultSection: async () => section,
+ updateVaultSection: async (_platform, operation) => operation(section).result,
+ }));
+ mock.module('./src/antigravity/google.ts', () => ({
+ decodeToken: () => ({ token: { access_token: 'access' } }),
+ fetchLimits: async () => ({ quota: { error: 'not refreshed', ok: false } }),
+ resolveGoogleIdentity: async () => ({ identity: 'google-user' }),
+ }));
+ mock.module('./src/antigravity/keychain.ts', () => ({
+ clearLiveAuth: async () => {},
+ clearLocalState: async () => {},
+ readCurrentSnapshot: async () => snapshot,
+ replaceLiveSnapshot: async () => {},
+ }));
+ const { saveAntigravity } = await import('./src/antigravity/service.ts');
+ await saveAntigravity('saved');
+ console.log(JSON.stringify({
+ createdAt: section.data.saved.createdAt,
+ updatedAt: section.data.saved.updatedAt,
+ }));
+ `;
+ expect(await runAntigravityScript(script)).toEqual({ createdAt: 'original-created', updatedAt: 'new-updated' });
+});
+
+it('should reject an inert live Antigravity credential before writing the vault', async () => {
+ const script = `
+ const { mock } = await import('bun:test');
+ let wrote = false;
+ const snapshot = {
+ account: 'antigravity', createdAt: '', kind: 'Generic Password', label: 'gemini',
+ password: Buffer.from(JSON.stringify({ token: {} })).toString('base64'),
+ service: 'gemini', updatedAt: '',
+ };
+ mock.module('./src/storage/vault.ts', () => ({
+ readVaultSection: async () => ({ data: {}, limits: {} }),
+ updateVaultSection: async () => { wrote = true; },
+ }));
+ mock.module('./src/antigravity/keychain.ts', () => ({
+ clearLiveAuth: async () => {},
+ clearLocalState: async () => {},
+ readCurrentSnapshot: async () => snapshot,
+ replaceLiveSnapshot: async () => {},
+ }));
+ const { saveAntigravity } = await import('./src/antigravity/service.ts');
+ const error = await saveAntigravity('saved').catch((value) => String(value));
+ console.log(JSON.stringify({ error, wrote }));
+ `;
+ expect(await runAntigravityScript(script)).toEqual({
+ error: 'Error: Current Antigravity credential payload is invalid',
+ wrote: false,
+ });
+});
+
+it('should clear stale Antigravity state before restoring the replacement credential', async () => {
+ const script = `
+ const { mock } = await import('bun:test');
+ const calls = [];
+ const snapshot = {
+ account: 'antigravity',
+ createdAt: '',
+ identity: 'google-user',
+ kind: 'Generic Password',
+ label: 'gemini',
+ password: Buffer.from(JSON.stringify({ token: { access_token: 'access' } })).toString('base64'),
+ service: 'gemini',
+ updatedAt: '',
+ };
+ mock.module('./src/storage/vault.ts', () => ({
+ readVaultSection: async () => ({ data: { saved: snapshot }, limits: {} }),
+ updateVaultSection: async () => undefined,
+ }));
+ mock.module('./src/antigravity/keychain.ts', () => ({
+ clearLiveAuth: async () => {},
+ clearLocalState: async () => { calls.push('clear'); },
+ readCurrentSnapshot: async () => snapshot,
+ replaceLiveSnapshot: async () => { calls.push('replace'); },
+ }));
+ const { loadAntigravity } = await import('./src/antigravity/service.ts');
+ await loadAntigravity('saved');
+ console.log(JSON.stringify(calls));
+ `;
+ expect(await runAntigravityScript(script)).toEqual(['clear', 'replace']);
+});
+
+it('should leave the Antigravity credential untouched when stale-state cleanup fails', async () => {
+ const script = `
+ const { mock } = await import('bun:test');
+ let restored = false;
+ const snapshot = {
+ account: 'antigravity', createdAt: '', kind: 'Generic Password', label: 'gemini',
+ identity: 'google-user', password: Buffer.from(JSON.stringify({ token: { access_token: 'access' } })).toString('base64'),
+ service: 'gemini', updatedAt: '',
+ };
+ mock.module('./src/storage/vault.ts', () => ({
+ readVaultSection: async () => ({ data: { saved: snapshot }, limits: {} }),
+ updateVaultSection: async () => undefined,
+ }));
+ mock.module('./src/antigravity/keychain.ts', () => ({
+ clearLiveAuth: async () => {},
+ clearLocalState: async () => { throw new Error('cleanup failed'); },
+ readCurrentSnapshot: async () => snapshot,
+ replaceLiveSnapshot: async () => { restored = true; },
+ }));
+ const { loadAntigravity } = await import('./src/antigravity/service.ts');
+ const error = await loadAntigravity('saved').catch((value) => String(value));
+ console.log(JSON.stringify({ error, restored }));
+ `;
+ expect(await runAntigravityScript(script)).toEqual({ error: 'Error: cleanup failed', restored: false });
+});
+
+it('should reject tampered Antigravity keychain metadata without clearing or restoring', async () => {
+ const script = `
+ const { mock } = await import('bun:test');
+ const calls = [];
+ const snapshot = {
+ account: 'unrelated-account', createdAt: '', kind: 'Generic Password', label: 'gemini',
+ identity: 'google-user', password: Buffer.from(JSON.stringify({ token: { access_token: 'access' } })).toString('base64'),
+ service: 'unrelated-service', updatedAt: '',
+ };
+ mock.module('./src/storage/vault.ts', () => ({
+ readVaultSection: async () => ({ data: { saved: snapshot }, limits: {} }),
+ updateVaultSection: async () => undefined,
+ }));
+ mock.module('./src/antigravity/keychain.ts', () => ({
+ clearLiveAuth: async () => {},
+ clearLocalState: async () => { calls.push('clear'); },
+ readCurrentSnapshot: async () => snapshot,
+ replaceLiveSnapshot: async () => { calls.push('replace'); },
+ }));
+ const { loadAntigravity } = await import('./src/antigravity/service.ts');
+ const error = await loadAntigravity('saved').catch((value) => String(value));
+ console.log(JSON.stringify({ calls, error }));
+ `;
+ expect(await runAntigravityScript(script)).toEqual({ calls: [], error: 'Error: Saved account data is corrupted' });
+});
+
+it('should reject replacing a semantic-invalid saved Antigravity account', async () => {
+ const script = `
+ const { mock } = await import('bun:test');
+ const valid = {
+ account: 'antigravity', createdAt: 'live-created', kind: 'Generic Password', label: 'gemini',
+ identity: 'google-user', password: Buffer.from(JSON.stringify({ token: { access_token: 'access' } })).toString('base64'),
+ service: 'gemini', updatedAt: 'live-updated',
+ };
+ const section = {
+ data: { saved: { ...valid, password: Buffer.from(JSON.stringify({ token: {} })).toString('base64') } },
+ limits: {},
+ };
+ mock.module('./src/storage/vault.ts', () => ({
+ readVaultSection: async () => section,
+ updateVaultSection: async (_platform, operation) => operation(section).result,
+ }));
+ mock.module('./src/antigravity/google.ts', () => ({
+ decodeToken: (password) => password === valid.password ? { token: { access_token: 'access' } } : null,
+ fetchLimits: async () => ({ quota: { error: 'not refreshed', ok: false } }),
+ resolveGoogleIdentity: async () => ({ identity: 'google-user' }),
+ }));
+ mock.module('./src/antigravity/keychain.ts', () => ({
+ clearLiveAuth: async () => {},
+ clearLocalState: async () => {},
+ readCurrentSnapshot: async () => valid,
+ replaceLiveSnapshot: async () => {},
+ }));
+ const { saveAntigravity } = await import('./src/antigravity/service.ts');
+ const error = await saveAntigravity('saved').catch((value) => String(value));
+ console.log(JSON.stringify({ error, unchanged: section.data.saved.password !== valid.password }));
+ `;
+ expect(await runAntigravityScript(script)).toEqual({
+ error: 'Error: Saved account data is corrupted',
+ unchanged: true,
+ });
+});
+
+it('should reject Antigravity load and clear while the app is running', async () => {
+ const script = `
+ const { mock } = await import('bun:test');
+ const calls = [];
+ mock.module('./src/process.ts', () => ({ isProcessRunning: async () => true }));
+ mock.module('./src/storage/vault.ts', () => ({
+ readVaultSection: async () => { calls.push('read-vault'); return { data: {}, limits: {} }; },
+ updateVaultSection: async () => undefined,
+ }));
+ mock.module('./src/antigravity/keychain.ts', () => ({
+ clearLiveAuth: async () => { calls.push('clear-live'); },
+ clearLocalState: async () => { calls.push('clear-state'); },
+ readCurrentSnapshot: async () => { throw new Error('not called'); },
+ replaceLiveSnapshot: async () => { calls.push('replace'); },
+ }));
+ const { clearAntigravity, loadAntigravity } = await import('./src/antigravity/service.ts');
+ const loadError = await loadAntigravity('saved').catch((value) => String(value));
+ const clearError = await clearAntigravity().catch((value) => String(value));
+ console.log(JSON.stringify({ calls, clearError, loadError }));
+ `;
+ expect(await runAntigravityScript(script, { ANTIGRAVITY_PROCESS_NAME: '-hostile-name' })).toEqual({
+ calls: [],
+ clearError:
+ 'Error: Quit Antigravity completely before clearing or loading an account. Antigravity must be closed while Dondo replaces its local login state.',
+ loadError:
+ 'Error: Quit Antigravity completely before clearing or loading an account. Antigravity must be closed while Dondo replaces its local login state.',
+ });
+});
+
+it('should serialize overlapping Antigravity live-state operations', async () => {
+ const script = `
+ const { mock } = await import('bun:test');
+ const password = Buffer.from(JSON.stringify({ token: { access_token: 'access' } })).toString('base64');
+ const snapshot = {
+ account: 'antigravity', createdAt: '', kind: 'Generic Password', label: 'gemini',
+ identity: 'google-user', password, service: 'gemini', updatedAt: '',
+ };
+ let active = 0;
+ let maximumActive = 0;
+ const calls = [];
+ mock.module('./src/storage/vault.ts', () => ({
+ readVaultSection: async () => ({ data: { first: snapshot, second: snapshot }, limits: {} }),
+ updateVaultSection: async () => undefined,
+ }));
+ mock.module('./src/antigravity/keychain.ts', () => ({
+ clearLiveAuth: async () => {},
+ clearLocalState: async () => {
+ active += 1;
+ maximumActive = Math.max(maximumActive, active);
+ calls.push('clear-start');
+ await Bun.sleep(25);
+ calls.push('clear-end');
+ active -= 1;
+ },
+ readCurrentSnapshot: async () => snapshot,
+ replaceLiveSnapshot: async () => { calls.push('replace'); },
+ }));
+ const { loadAntigravity } = await import('./src/antigravity/service.ts');
+ await Promise.all([loadAntigravity('first'), loadAntigravity('second')]);
+ console.log(JSON.stringify({ calls, maximumActive }));
+ `;
+ expect(await runAntigravityScript(script)).toEqual({
+ calls: ['clear-start', 'clear-end', 'replace', 'clear-start', 'clear-end', 'replace'],
+ maximumActive: 1,
+ });
+});
+
+it('should keep the active Antigravity account stable across token rotation', async () => {
+ const script = `
+ const { mock } = await import('bun:test');
+ const credential = (password) => ({
+ account: 'antigravity', createdAt: '', kind: 'Generic Password', label: 'gemini',
+ password, service: 'gemini', updatedAt: '',
+ });
+ const section = {
+ data: {
+ first: { ...credential('saved-first-token'), identity: 'google-user-one' },
+ second: { ...credential('saved-second-token'), identity: 'google-user-two' },
+ },
+ limits: {},
+ };
+ mock.module('./src/storage/vault.ts', () => ({
+ readVaultSection: async () => section,
+ updateVaultSection: async (_platform, operation) => operation(section).result,
+ }));
+ mock.module('./src/antigravity/google.ts', () => ({
+ decodeToken: () => ({ token: { access_token: 'valid' } }),
+ fetchLimits: async () => ({ quota: { error: 'not refreshed', ok: false } }),
+ resolveGoogleIdentity: async () => ({ identity: 'google-user-two' }),
+ }));
+ mock.module('./src/antigravity/keychain.ts', () => ({
+ clearLiveAuth: async () => {},
+ clearLocalState: async () => {},
+ readCurrentSnapshot: async () => credential('rotated-live-token'),
+ replaceLiveSnapshot: async () => {},
+ }));
+ const { antigravityState } = await import('./src/antigravity/service.ts');
+ const state = await antigravityState();
+ console.log(JSON.stringify(state.entries.filter((entry) => entry.active).map((entry) => entry.key)));
+ `;
+ expect(await runAntigravityScript(script)).toEqual(['second']);
+});
diff --git a/src/antigravity/service.ts b/src/antigravity/service.ts
index e00647f..7991cf3 100644
--- a/src/antigravity/service.ts
+++ b/src/antigravity/service.ts
@@ -1,11 +1,31 @@
-import { ANTIGRAVITY_ACCOUNT, ANTIGRAVITY_SERVICE, VAULT_PATH } from '../config.ts';
+import {
+ boundedMap,
+ CORRUPTED_ACCOUNT_ERROR,
+ selectRefreshEntries,
+ sortAccountEntries,
+ stateVersion,
+} from '../account-state.ts';
+import { createAsyncQueue } from '../async-queue.ts';
+import { ANTIGRAVITY_ACCOUNT, ANTIGRAVITY_PROCESS_NAME, ANTIGRAVITY_SERVICE, VAULT_PATH } from '../config.ts';
import { assertAccountKey, cleanLimitError, publicError } from '../errors.ts';
-import { readVault, updateVault } from '../storage/vault.ts';
-import type { AppVault, LimitResult, Snapshot } from '../types.ts';
-import { decodeToken, fetchLimits } from './google.ts';
-import { clearLiveAuth, readCurrentSnapshot, restoreSnapshot } from './keychain.ts';
+import { isProcessRunning } from '../process.ts';
+import { readVaultSection, updateVaultSection } from '../storage/vault.ts';
+import type { AntigravityCredential, LimitResult, PlatformVault, Snapshot } from '../types.ts';
+import { decodeToken, fetchLimits, resolveGoogleIdentity } from './google.ts';
+import { clearLiveAuth, clearLocalState, readCurrentSnapshot, replaceLiveSnapshot } from './keychain.ts';
-const isSameSnapshot = (a: Snapshot | null, b: Snapshot) => {
+type AntigravityLimitUpdate = {
+ key: string;
+ password?: string;
+ quota: LimitResult;
+ sourceLimitVersion: string;
+ sourceSnapshotVersion: string;
+};
+
+const queueAntigravityOperation = createAsyncQueue();
+let liveIdentityCache: { identity: string; passwordVersion: string } | undefined;
+
+const hasSameToken = (a: AntigravityCredential | null, b: Snapshot) => {
if (a?.service !== b.service || a.account !== b.account) {
return false;
}
@@ -22,101 +42,200 @@ const hasNoUsageLeft = (quota: LimitResult | null) => {
return limits.length > 0 && limits.every((model) => model.percentage <= 0);
};
-const sortEntries = (entries: T[]) => {
- return [...entries].sort((a, b) => {
- if (a.active !== b.active) {
- return a.active ? -1 : 1;
- }
- if (hasNoUsageLeft(a.quota) !== hasNoUsageLeft(b.quota)) {
- return hasNoUsageLeft(a.quota) ? 1 : -1;
- }
- return a.key.localeCompare(b.key);
- });
+const isReadableCredential = (snapshot: AntigravityCredential) => {
+ return (
+ snapshot.account === ANTIGRAVITY_ACCOUNT &&
+ snapshot.service === ANTIGRAVITY_SERVICE &&
+ decodeToken(snapshot.password) !== null
+ );
+};
+
+const isReadableSnapshot = (snapshot: Snapshot) => {
+ return isReadableCredential(snapshot) && Boolean(snapshot.identity.trim());
};
-const updateMissingOrStaleLimits = async (vault: AppVault, force: boolean, targetKey?: string) => {
- let changed = false;
- if (targetKey && !vault.antigravity.data[targetKey]) {
- throw publicError(404, `No snapshot named ${targetKey}`);
+const liveIdentity = async (credential: AntigravityCredential | null) => {
+ if (!credential || !isReadableCredential(credential)) {
+ return null;
+ }
+ const passwordVersion = stateVersion(credential.password);
+ if (liveIdentityCache?.passwordVersion === passwordVersion) {
+ return liveIdentityCache.identity;
}
+ const resolved = await resolveGoogleIdentity(credential);
+ liveIdentityCache = { identity: resolved.identity, passwordVersion };
+ return resolved.identity;
+};
- for (const [key, snap] of Object.entries(vault.antigravity.data)) {
- if ((targetKey && key !== targetKey) || (!force && vault.antigravity.limits[key])) {
- continue;
- }
- const result = await fetchLimits(snap).catch((error) => ({
+const assertReadableAccount = (section: PlatformVault, key: string) => {
+ if (section.corruptions?.[key]) {
+ throw publicError(409, CORRUPTED_ACCOUNT_ERROR);
+ }
+ const snapshot = section.data[key];
+ if (!snapshot) {
+ throw publicError(404, `No snapshot named ${key}`);
+ }
+ if (!isReadableSnapshot(snapshot)) {
+ throw publicError(409, CORRUPTED_ACCOUNT_ERROR);
+ }
+ return snapshot;
+};
+
+const assertAntigravityClosed = async () => {
+ if (await isProcessRunning(ANTIGRAVITY_PROCESS_NAME)) {
+ throw publicError(
+ 409,
+ 'Quit Antigravity completely before clearing or loading an account. Antigravity must be closed while Dondo replaces its local login state.',
+ );
+ }
+};
+
+const fetchAntigravityLimitUpdates = async (section: PlatformVault, force: boolean, targetKey?: string) => {
+ if (targetKey) {
+ assertReadableAccount(section, targetKey);
+ }
+ const readableData = Object.fromEntries(
+ Object.entries(section.data).filter(([, snapshot]) => isReadableSnapshot(snapshot)),
+ );
+ const selected = selectRefreshEntries(readableData, section.limits, targetKey ? { force, targetKey } : { force });
+ return boundedMap(selected, async ([key, snapshot]): Promise => {
+ const result = await fetchLimits(snapshot).catch((error) => ({
password: undefined,
quota: cleanLimitError(error),
}));
- if (result.password) {
- vault.antigravity.data[key] = { ...snap, password: result.password, updatedAt: new Date().toISOString() };
- }
- vault.antigravity.limits[key] = { fetchedAt: new Date().toISOString(), quota: result.quota };
- changed = true;
- }
-
- return changed;
+ return {
+ key,
+ ...(result.password ? { password: result.password } : {}),
+ quota: result.quota,
+ sourceLimitVersion: stateVersion(section.limits[key] ?? null),
+ sourceSnapshotVersion: stateVersion(snapshot),
+ };
+ });
};
-export const saveAntigravity = async (key: string) => {
+const saveAntigravityOperation = async (key: string) => {
const safeKey = assertAccountKey(key);
- const snapshot = await readCurrentSnapshot();
- await updateVault(async (vault) => {
- vault.antigravity.data[safeKey] = snapshot;
- delete vault.antigravity.limits[safeKey];
+ const credential = await readCurrentSnapshot();
+ if (!isReadableCredential(credential)) {
+ throw publicError(400, 'Current Antigravity credential payload is invalid');
+ }
+ const resolved = await resolveGoogleIdentity(credential).catch(() => {
+ throw publicError(502, 'Could not verify the current Antigravity account identity');
+ });
+ const snapshot: Snapshot = {
+ ...credential,
+ identity: resolved.identity,
+ password: resolved.password ?? credential.password,
+ };
+ await updateVaultSection('antigravity', (section) => {
+ const existing = section.data[safeKey];
+ if (section.corruptions?.[safeKey] || (existing && !isReadableSnapshot(existing))) {
+ throw publicError(409, CORRUPTED_ACCOUNT_ERROR);
+ }
+ section.data[safeKey] = {
+ ...snapshot,
+ createdAt: existing?.createdAt ?? snapshot.createdAt,
+ };
+ delete section.limits[safeKey];
return { result: undefined };
});
+ liveIdentityCache = { identity: resolved.identity, passwordVersion: stateVersion(credential.password) };
};
-export const loadAntigravity = async (key: string) => {
+export const saveAntigravity = (key: string) => queueAntigravityOperation(() => saveAntigravityOperation(key));
+
+const loadAntigravityOperation = async (key: string) => {
const safeKey = assertAccountKey(key);
- const snap = (await readVault()).antigravity.data[safeKey];
- if (!snap) {
- throw publicError(404, `No snapshot named ${safeKey}`);
- }
- await restoreSnapshot(snap);
+ await assertAntigravityClosed();
+ const snapshot = assertReadableAccount(await readVaultSection('antigravity'), safeKey);
+ await clearLocalState();
+ await replaceLiveSnapshot(snapshot);
+ liveIdentityCache = { identity: snapshot.identity, passwordVersion: stateVersion(snapshot.password) };
};
+export const loadAntigravity = (key: string) => queueAntigravityOperation(() => loadAntigravityOperation(key));
+
export const deleteAntigravity = async (key: string) => {
const safeKey = assertAccountKey(key);
- await updateVault(async (vault) => {
- if (!vault.antigravity.data[safeKey]) {
+ await updateVaultSection('antigravity', (section) => {
+ if (!section.data[safeKey] && !section.corruptions?.[safeKey]) {
throw publicError(404, `No snapshot named ${safeKey}`);
}
- delete vault.antigravity.data[safeKey];
- delete vault.antigravity.limits[safeKey];
+ delete section.data[safeKey];
+ delete section.limits[safeKey];
+ if (section.corruptions) {
+ delete section.corruptions[safeKey];
+ }
return { result: undefined };
});
};
-export const clearAntigravity = async () => {
+const clearAntigravityOperation = async () => {
+ await assertAntigravityClosed();
await clearLiveAuth();
+ liveIdentityCache = undefined;
};
+export const clearAntigravity = () => queueAntigravityOperation(clearAntigravityOperation);
+
export const antigravityState = async (options: { refreshLimitKey?: string; refreshLimits?: boolean } = {}) => {
const refreshLimitKey = options.refreshLimitKey ? assertAccountKey(options.refreshLimitKey) : undefined;
- const vault = await updateVault(async (current) => {
- const changed = await updateMissingOrStaleLimits(current, options.refreshLimits === true, refreshLimitKey);
- return { result: current, write: changed };
+ const snapshot = await readVaultSection('antigravity');
+ const updates = await fetchAntigravityLimitUpdates(snapshot, options.refreshLimits === true, refreshLimitKey);
+ const section =
+ updates.length === 0
+ ? snapshot
+ : await updateVaultSection('antigravity', (current) => {
+ let changed = false;
+ for (const update of updates) {
+ const saved = current.data[update.key];
+ if (
+ !saved ||
+ stateVersion(saved) !== update.sourceSnapshotVersion ||
+ stateVersion(current.limits[update.key] ?? null) !== update.sourceLimitVersion
+ ) {
+ continue;
+ }
+ if (update.password) {
+ saved.password = update.password;
+ saved.updatedAt = new Date().toISOString();
+ }
+ current.limits[update.key] = { fetchedAt: new Date().toISOString(), quota: update.quota };
+ changed = true;
+ }
+ return { result: current, write: changed };
+ });
+ const live = await queueAntigravityOperation(() => readCurrentSnapshot().catch(() => null));
+ const activeIdentity = await liveIdentity(live).catch(() => null);
+ const healthyEntries = Object.entries(section.data).map(([key, saved]: [string, Snapshot]) => {
+ const snapshotValid = isReadableSnapshot(saved);
+ const cached = section.limits[key];
+ return {
+ account: saved.account,
+ active: snapshotValid && (activeIdentity ? saved.identity === activeIdentity : hasSameToken(live, saved)),
+ ...(!snapshotValid ? { corrupted: true as const, error: CORRUPTED_ACCOUNT_ERROR } : {}),
+ key,
+ limitUpdatedAt: cached?.fetchedAt ?? '',
+ quota: cached?.quota ?? null,
+ service: saved.service,
+ updatedAt: saved.updatedAt,
+ };
});
- const live = await readCurrentSnapshot().catch(() => null);
+ const corruptedEntries = Object.keys(section.corruptions ?? {}).map((key) => ({
+ account: '',
+ active: false,
+ corrupted: true as const,
+ error: CORRUPTED_ACCOUNT_ERROR,
+ key,
+ limitUpdatedAt: '',
+ quota: null,
+ service: '',
+ updatedAt: '',
+ }));
return {
account: ANTIGRAVITY_ACCOUNT,
- entries: sortEntries(
- Object.entries(vault.antigravity.data).map(([key, snap]: [string, Snapshot]) => {
- const cached = vault.antigravity.limits[key];
- return {
- account: snap.account,
- active: isSameSnapshot(live, snap),
- key,
- limitUpdatedAt: cached?.fetchedAt ?? '',
- quota: cached?.quota ?? null,
- service: snap.service,
- updatedAt: snap.updatedAt,
- };
- }),
- ),
+ entries: sortAccountEntries([...healthyEntries, ...corruptedEntries], hasNoUsageLeft),
service: ANTIGRAVITY_SERVICE,
vaultPath: VAULT_PATH,
};
diff --git a/src/async-queue.test.ts b/src/async-queue.test.ts
new file mode 100644
index 0000000..141df14
--- /dev/null
+++ b/src/async-queue.test.ts
@@ -0,0 +1,40 @@
+import { expect, it } from 'bun:test';
+import { createAsyncQueue, waitForAll } from './async-queue.ts';
+
+it('serializes operations and continues after a rejected operation', async () => {
+ const queue = createAsyncQueue();
+ const events: string[] = [];
+ let active = 0;
+ let maximumActive = 0;
+ const operation = (name: string, reject = false) =>
+ queue(async () => {
+ active += 1;
+ maximumActive = Math.max(maximumActive, active);
+ events.push(`${name}-start`);
+ await Bun.sleep(2);
+ events.push(`${name}-end`);
+ active -= 1;
+ if (reject) {
+ throw new Error(name);
+ }
+ return name;
+ });
+
+ const first = operation('first');
+ const failed = operation('failed', true).catch((error: unknown) => String(error));
+ const last = operation('last');
+
+ expect(await Promise.all([first, failed, last])).toEqual(['first', 'Error: failed', 'last']);
+ expect(maximumActive).toBe(1);
+ expect(events).toEqual(['first-start', 'first-end', 'failed-start', 'failed-end', 'last-start', 'last-end']);
+});
+
+it('waits for every operation to settle before propagating a failure', async () => {
+ let delayedFinished = false;
+ const delayed = Bun.sleep(10).then(() => {
+ delayedFinished = true;
+ });
+
+ await expect(waitForAll([Promise.reject(new Error('failed')), delayed])).rejects.toThrow('failed');
+ expect(delayedFinished).toBe(true);
+});
diff --git a/src/async-queue.ts b/src/async-queue.ts
new file mode 100644
index 0000000..8433125
--- /dev/null
+++ b/src/async-queue.ts
@@ -0,0 +1,21 @@
+export type AsyncQueue = (operation: () => Promise) => Promise;
+
+export const createAsyncQueue = (): AsyncQueue => {
+ let tail: Promise = Promise.resolve();
+ return (operation: () => Promise) => {
+ const queued = tail.then(operation);
+ tail = queued.then(
+ () => undefined,
+ () => undefined,
+ );
+ return queued;
+ };
+};
+
+export const waitForAll = async (operations: readonly Promise[]) => {
+ const results = await Promise.allSettled(operations);
+ const failure = results.find((result) => result.status === 'rejected');
+ if (failure?.status === 'rejected') {
+ throw failure.reason;
+ }
+};
diff --git a/src/cline/providers.ts b/src/cline/providers.ts
new file mode 100644
index 0000000..5519c0e
--- /dev/null
+++ b/src/cline/providers.ts
@@ -0,0 +1,91 @@
+export type ClineAccount = {
+ accessToken?: string;
+ accountId?: string;
+ email?: string;
+ id?: string;
+ refreshToken?: string;
+};
+
+type ClineProviderFile = Record;
+
+const isRecord = (value: unknown): value is Record => {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+};
+
+const parseJsonRecord = (text: string) => {
+ try {
+ const value = JSON.parse(text) as unknown;
+ return isRecord(value) ? value : null;
+ } catch {
+ return null;
+ }
+};
+
+const stringValue = (value: unknown) => (typeof value === 'string' && value.trim() ? value : undefined);
+
+const hasValidOptionalStrings = (record: Record, keys: readonly string[]) => {
+ return keys.every((key) => record[key] === undefined || typeof record[key] === 'string');
+};
+
+const providerSettings = (provider: unknown) => {
+ if (!isRecord(provider) || !isRecord(provider.settings) || !isRecord(provider.settings.auth)) {
+ return null;
+ }
+ return hasValidOptionalStrings(provider.settings, ['provider']) ? provider.settings : null;
+};
+
+const providerMetadata = (auth: Record) => {
+ if (auth.metadata !== undefined && !isRecord(auth.metadata)) {
+ return null;
+ }
+ const metadata = isRecord(auth.metadata) ? auth.metadata : {};
+ if (!hasValidOptionalStrings(metadata, ['accountId', 'email', 'userId'])) {
+ return null;
+ }
+ if (metadata.userInfo !== undefined && !isRecord(metadata.userInfo)) {
+ return null;
+ }
+ const userInfo = isRecord(metadata.userInfo) ? metadata.userInfo : {};
+ return hasValidOptionalStrings(userInfo, ['email', 'id']) ? { metadata, userInfo } : null;
+};
+
+const parseProviderAccount = (provider: unknown): ClineAccount | null => {
+ const settings = providerSettings(provider);
+ if (!settings) {
+ return null;
+ }
+ const providerName = stringValue(settings.provider);
+ if (providerName && providerName !== 'cline') {
+ return null;
+ }
+ const auth = settings.auth as Record;
+ if (!hasValidOptionalStrings(auth, ['accessToken', 'accountId', 'refreshToken'])) {
+ return null;
+ }
+ const accessToken = stringValue(auth.accessToken);
+ const context = providerMetadata(auth);
+ if (!accessToken || !context) {
+ return null;
+ }
+ const { metadata, userInfo } = context;
+ const accountId = stringValue(auth.accountId) ?? stringValue(metadata.accountId);
+ const email = stringValue(metadata.email) ?? stringValue(userInfo.email);
+ const id = stringValue(metadata.userId) ?? stringValue(userInfo.id);
+ const refreshToken = stringValue(auth.refreshToken);
+ return {
+ accessToken,
+ ...(accountId ? { accountId } : {}),
+ ...(email ? { email } : {}),
+ ...(id ? { id } : {}),
+ ...(refreshToken ? { refreshToken } : {}),
+ };
+};
+
+export const parseClineProviders = (text: string) => {
+ const providersFile = parseJsonRecord(text);
+ if (!providersFile || !isRecord(providersFile.providers)) {
+ return null;
+ }
+ const account = parseProviderAccount(providersFile.providers.cline);
+ return account ? { account, providers: providersFile as ClineProviderFile } : null;
+};
diff --git a/src/cline/service.test.ts b/src/cline/service.test.ts
index fb1048b..b7166cf 100644
--- a/src/cline/service.test.ts
+++ b/src/cline/service.test.ts
@@ -3,37 +3,35 @@ import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
-const runClineScript = async (env: Record) => {
+const runClineProvidersScript = async (env: Record) => {
const script = `
const { clineState, deleteCline, loadCline, saveCline } = await import('./src/cline/service.ts');
- const secretsPath = process.env.CLINE_SECRETS_PATH;
+ const providersPath = process.env.CLINE_PROVIDERS_PATH;
const vaultPath = process.env.DONDO_VAULT;
await saveCline('saved');
- await Bun.write(secretsPath, JSON.stringify({
- 'cline:clineAccountId': JSON.stringify({
- idToken: 'other-id-token',
- refreshToken: 'other-refresh-token',
- userInfo: { email: 'other@example.com', id: 'other-user' },
- }),
- unrelated: 'preserved',
+ await Bun.write(providersPath, JSON.stringify({
+ version: 1,
+ lastUsedProvider: 'cline',
+ providers: {
+ cline: { settings: { provider: 'cline', auth: { accessToken: 'workos:other-access', refreshToken: 'other-refresh', accountId: 'other-user' } } },
+ sapaicore: { settings: { provider: 'sapaicore', auth: { accessToken: 'other-provider-access' } } },
+ },
}));
const before = await clineState();
await loadCline('saved');
const after = await clineState();
- const loadedSecrets = JSON.parse(await Bun.file(secretsPath).text());
- const loadedAccount = JSON.parse(loadedSecrets['cline:clineAccountId']);
+ const loadedProviders = JSON.parse(await Bun.file(providersPath).text());
const vaultText = await Bun.file(vaultPath).text();
await deleteCline('saved');
const afterDelete = await clineState();
console.log(JSON.stringify({
activeBeforeLoad: before.entries[0]?.active ?? null,
activeAfterLoad: after.entries[0]?.active ?? null,
+ accountId: loadedProviders.providers.cline.settings.auth.accountId,
deleted: afterDelete.entries.length === 0,
- loadedEmail: loadedAccount.userInfo.email,
- loadedRefreshToken: loadedAccount.refreshToken,
- preservedUnrelated: loadedSecrets.unrelated,
- secretsPath: after.secretsPath,
- vaultHasPlainToken: vaultText.includes('saved-id-token') || vaultText.includes('saved-refresh-token'),
+ providerKeys: Object.keys(loadedProviders.providers).sort(),
+ providersPath: after.providersPath,
+ vaultHasPlainToken: vaultText.includes('saved-access') || vaultText.includes('saved-refresh'),
}));
`;
const proc = Bun.spawn([process.execPath, '--eval', script], {
@@ -48,56 +46,62 @@ const runClineScript = async (env: Record) => {
throw new Error(stderr);
}
return JSON.parse(stdout) as {
+ accountId: string;
activeAfterLoad: boolean;
activeBeforeLoad: boolean;
deleted: boolean;
- loadedEmail: string;
- loadedRefreshToken: string;
- preservedUnrelated: string;
- secretsPath: string;
+ providerKeys: string[];
+ providersPath: string;
vaultHasPlainToken: boolean;
};
};
-it('should save and load Cline secrets with encrypted vault storage', async () => {
- const dir = await mkdtemp(join(tmpdir(), 'dondo-cline-test-'));
- const secretsPath = join(dir, 'secrets.json');
+it('should save and load the current Cline providers file with encrypted vault storage', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-cline-providers-test-'));
+ const providersPath = join(dir, 'providers.json');
const vaultPath = join(dir, 'vault.json');
try {
await Bun.write(
- secretsPath,
+ providersPath,
JSON.stringify({
- 'cline:clineAccountId': JSON.stringify({
- expiresAt: 1_900_000_000_000,
- idToken: 'saved-id-token',
- refreshToken: 'saved-refresh-token',
- userInfo: { email: 'saved@example.com', id: 'saved-user' },
- }),
- unrelated: 'preserved',
+ lastUsedProvider: 'cline',
+ providers: {
+ cline: {
+ settings: {
+ auth: {
+ accessToken: 'workos:saved-access',
+ accountId: 'saved-user',
+ refreshToken: 'saved-refresh',
+ },
+ provider: 'cline',
+ },
+ },
+ sapaicore: { settings: { provider: 'sapaicore' } },
+ },
+ version: 1,
}),
);
- const result = await runClineScript({
- CLINE_SECRETS_PATH: secretsPath,
+ const result = await runClineProvidersScript({
+ CLINE_PROVIDERS_PATH: providersPath,
DONDO_VAULT: vaultPath,
});
expect(result.activeBeforeLoad).toBe(false);
expect(result.activeAfterLoad).toBe(true);
+ expect(result.accountId).toBe('saved-user');
expect(result.deleted).toBe(true);
- expect(result.loadedEmail).toBe('saved@example.com');
- expect(result.loadedRefreshToken).toBe('saved-refresh-token');
- expect(result.preservedUnrelated).toBe('preserved');
- expect(result.secretsPath).toBe(secretsPath);
+ expect(result.providerKeys).toEqual(['cline', 'sapaicore']);
+ expect(result.providersPath).toBe(providersPath);
expect(result.vaultHasPlainToken).toBe(false);
} finally {
await rm(dir, { force: true, recursive: true });
}
});
-it('should reject a Cline secrets file without an account token', async () => {
+it('should reject a Cline providers file without an account token', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dondo-cline-invalid-test-'));
- const secretsPath = join(dir, 'secrets.json');
+ const providersPath = join(dir, 'providers.json');
const vaultPath = join(dir, 'vault.json');
const script = `
const { saveCline } = await import('./src/cline/service.ts');
@@ -105,10 +109,10 @@ it('should reject a Cline secrets file without an account token', async () => {
console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
`;
try {
- await Bun.write(secretsPath, JSON.stringify({ unrelated: 'only' }));
+ await Bun.write(providersPath, JSON.stringify({ providers: { cline: { settings: { auth: {} } } } }));
const proc = Bun.spawn([process.execPath, '--eval', script], {
cwd: process.cwd(),
- env: { ...process.env, CLINE_SECRETS_PATH: secretsPath, DONDO_VAULT: vaultPath },
+ env: { ...process.env, CLINE_PROVIDERS_PATH: providersPath, DONDO_VAULT: vaultPath },
stderr: 'pipe',
stdout: 'pipe',
});
@@ -125,3 +129,208 @@ it('should reject a Cline secrets file without an account token', async () => {
await rm(dir, { force: true, recursive: true });
}
});
+
+it('should reject malformed optional Cline auth fields', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-cline-malformed-auth-test-'));
+ const providersPath = join(dir, 'providers.json');
+ const vaultPath = join(dir, 'vault.json');
+ const script = `
+ const { saveCline } = await import('./src/cline/service.ts');
+ const error = await saveCline('broken').catch((value) => String(value));
+ console.log(JSON.stringify({ error }));
+ `;
+ try {
+ await Bun.write(
+ providersPath,
+ JSON.stringify({
+ providers: {
+ cline: { settings: { auth: { accessToken: 'access', refreshToken: 123 }, provider: 'cline' } },
+ },
+ }),
+ );
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: { ...process.env, CLINE_PROVIDERS_PATH: providersPath, DONDO_VAULT: vaultPath },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ expect(JSON.parse(stdout).error).toContain('Cline account token');
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should expose semantic-invalid saved Cline providers as deletable corruption', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-cline-semantic-corruption-test-'));
+ const providersPath = join(dir, 'providers.json');
+ const vaultPath = join(dir, 'vault.json');
+ const valid = JSON.stringify({
+ providers: {
+ cline: { settings: { auth: { accessToken: 'access' }, provider: 'cline' } },
+ },
+ });
+ const script = `
+ const { clineState, deleteCline, loadCline, saveCline } = await import('./src/cline/service.ts');
+ const { updateVaultSection } = await import('./src/storage/vault.ts');
+ await saveCline('saved');
+ await updateVaultSection('cline', (section) => {
+ section.data.saved.secrets = '{}';
+ return { result: undefined };
+ });
+ const saveError = await saveCline('saved').catch((error) => String(error));
+ const state = await clineState();
+ const loadError = await loadCline('saved').catch((error) => String(error));
+ await deleteCline('saved');
+ console.log(JSON.stringify({
+ corrupted: state.entries[0]?.corrupted ?? false,
+ deleted: (await clineState()).entries.length === 0,
+ loadError,
+ saveError,
+ }));
+ `;
+ try {
+ await Bun.write(providersPath, valid);
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: { ...process.env, CLINE_PROVIDERS_PATH: providersPath, DONDO_VAULT: vaultPath },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ expect(JSON.parse(stdout)).toEqual({
+ corrupted: true,
+ deleted: true,
+ loadError: 'Error: Saved account data is corrupted',
+ saveError: 'Error: Saved account data is corrupted',
+ });
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should keep saved Cline accounts usable when the live providers file is unreadable', async () => {
+ const script = `
+ const { mock } = await import('bun:test');
+ const valid = JSON.stringify({
+ providers: { cline: { settings: { auth: { accessToken: 'saved-access' }, provider: 'cline' } } },
+ });
+ mock.module('./src/storage/file.ts', () => ({
+ readBoundedLocalText: async () => { throw new Error('live providers unreadable'); },
+ writePrivateFile: async () => {},
+ }));
+ mock.module('./src/storage/vault.ts', () => ({
+ readVaultSection: async () => ({
+ data: { saved: { createdAt: '', secrets: valid, updatedAt: 'saved-at' } },
+ limits: {},
+ }),
+ updateVaultSection: async () => undefined,
+ }));
+ const { clineState } = await import('./src/cline/service.ts');
+ const state = await clineState();
+ console.log(JSON.stringify(state.entries));
+ `;
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ expect(JSON.parse(stdout)).toEqual([
+ { active: false, key: 'saved', limitUpdatedAt: '', quota: null, updatedAt: 'saved-at' },
+ ]);
+});
+
+it('should mark a metadata-free opaque Cline token active after saving', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-cline-opaque-test-'));
+ const providersPath = join(dir, 'providers.json');
+ const vaultPath = join(dir, 'vault.json');
+ const script = `
+ const { clineState, saveCline } = await import('./src/cline/service.ts');
+ await saveCline('opaque');
+ const state = await clineState();
+ console.log(JSON.stringify({ active: state.entries[0]?.active ?? false }));
+ `;
+ try {
+ await Bun.write(
+ providersPath,
+ JSON.stringify({ providers: { cline: { settings: { auth: { accessToken: 'opaque-access' } } } } }),
+ );
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: { ...process.env, CLINE_PROVIDERS_PATH: providersPath, DONDO_VAULT: vaultPath },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ expect(JSON.parse(stdout)).toEqual({ active: true });
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should treat a hostile non-UTF-8 Cline JWT as an opaque identity', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-cline-hostile-jwt-test-'));
+ const providersPath = join(dir, 'providers.json');
+ const token = `header.${Buffer.from([0xc3, 0x28]).toString('base64url')}.signature`;
+ const script = `
+ const { clineState, saveCline } = await import('./src/cline/service.ts');
+ await saveCline('opaque');
+ console.log(JSON.stringify({ active: (await clineState()).entries[0]?.active ?? false }));
+ `;
+ try {
+ await Bun.write(
+ providersPath,
+ JSON.stringify({ providers: { cline: { settings: { auth: { accessToken: token } } } } }),
+ );
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: {
+ ...process.env,
+ CLINE_PROVIDERS_PATH: providersPath,
+ DONDO_VAULT: join(dir, 'vault.json'),
+ },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ expect(JSON.parse(stdout)).toEqual({ active: true });
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
diff --git a/src/cline/service.ts b/src/cline/service.ts
index d7962e1..7c09d44 100644
--- a/src/cline/service.ts
+++ b/src/cline/service.ts
@@ -1,89 +1,48 @@
-import { CLINE_SECRETS_PATH, VAULT_PATH } from '../config.ts';
+import { CORRUPTED_ACCOUNT_ERROR, sortAccountEntries } from '../account-state.ts';
+import { CLINE_PROVIDERS_PATH, VAULT_PATH } from '../config.ts';
import { assertAccountKey, publicError } from '../errors.ts';
-import { writePrivateFile } from '../storage/file.ts';
-import { readVault, updateVault } from '../storage/vault.ts';
-import type { ClineSnapshot } from '../types.ts';
-
-const CLINE_ACCOUNT_KEY = 'cline:clineAccountId';
-
-type ClineAccount = {
- idToken?: string;
- refreshToken?: string;
- userInfo?: {
- email?: string;
- id?: string;
- };
-};
-
-type ClineSecrets = Record;
-
-const isRecord = (value: unknown): value is Record => {
- return typeof value === 'object' && value !== null && !Array.isArray(value);
+import { decodeJwtPayload } from '../jwt.ts';
+import { readBoundedLocalText, writePrivateFile } from '../storage/file.ts';
+import { readVaultSection, updateVaultSection } from '../storage/vault.ts';
+import type { ClineSnapshot, ClineVault } from '../types.ts';
+import { type ClineAccount, parseClineProviders } from './providers.ts';
+
+const liveFile = async () => {
+ const text = await readBoundedLocalText(CLINE_PROVIDERS_PATH);
+ return text === null ? null : { path: CLINE_PROVIDERS_PATH, text };
};
-const parseJsonRecord = (text: string) => {
- try {
- const value = JSON.parse(text) as unknown;
- return isRecord(value) ? value : null;
- } catch {
- return null;
+const readValidLiveFile = async () => {
+ const current = await liveFile();
+ if (!current) {
+ throw publicError(
+ 404,
+ `No live Cline session found at ${CLINE_PROVIDERS_PATH}. Sign into Cline, then use Save current.`,
+ );
}
-};
-
-const parseAccount = (secrets: ClineSecrets): ClineAccount | null => {
- const raw = secrets[CLINE_ACCOUNT_KEY];
- if (typeof raw !== 'string' || !raw.trim()) {
- return null;
+ if (!current.text.trim()) {
+ throw publicError(400, `${CLINE_PROVIDERS_PATH} is empty`);
}
- const account = parseJsonRecord(raw);
- return typeof account?.idToken === 'string' && account.idToken ? (account as ClineAccount) : null;
-};
-
-const parseSecrets = (text: string) => {
- const secrets = parseJsonRecord(text);
- if (!secrets) {
- return null;
+ if (!parseClineProviders(current.text)) {
+ throw publicError(400, `${CLINE_PROVIDERS_PATH} does not contain a valid Cline account token`);
}
- const account = parseAccount(secrets);
- return account ? { account, secrets } : null;
-};
-
-const liveSecrets = async () => {
- const file = Bun.file(CLINE_SECRETS_PATH);
- return (await file.exists()) ? await file.text() : '';
-};
-
-const readValidLiveSecrets = async () => {
- const file = Bun.file(CLINE_SECRETS_PATH);
- if (!(await file.exists())) {
- throw publicError(404, `No live Cline session found. Sign into Cline, then use Save current.`);
- }
- const text = await file.text();
- if (!text.trim()) {
- throw publicError(400, `${CLINE_SECRETS_PATH} is empty`);
- }
- const parsed = parseSecrets(text);
- if (!parsed) {
- throw publicError(400, `${CLINE_SECRETS_PATH} does not contain a valid Cline account token`);
- }
- return text;
+ return current;
};
const jwtSubject = (token: string) => {
- const part = token.split('.')[1];
- if (!part) {
- return '';
- }
- try {
- const payload = JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as { sub?: unknown };
- return typeof payload.sub === 'string' ? payload.sub : '';
- } catch {
- return '';
- }
+ const subject = decodeJwtPayload(token)?.sub;
+ return typeof subject === 'string' ? subject : '';
};
const identity = (account: ClineAccount) => {
- return account.userInfo?.id || account.userInfo?.email || (account.idToken ? jwtSubject(account.idToken) : '') || '';
+ return (
+ account.accountId ||
+ account.id ||
+ account.email ||
+ (account.accessToken ? jwtSubject(account.accessToken) : '') ||
+ account.accessToken ||
+ ''
+ );
};
const isSameAccount = (a: ClineAccount | null, b: ClineAccount | null) => {
@@ -100,59 +59,85 @@ const entry = (key: string, snap: ClineSnapshot, active: boolean) => ({
updatedAt: snap.updatedAt,
});
+const isReadableSnapshot = (snapshot: ClineSnapshot) => parseClineProviders(snapshot.secrets) !== null;
+
+const assertReadableAccount = (section: ClineVault, key: string) => {
+ if (section.corruptions?.[key]) {
+ throw publicError(409, CORRUPTED_ACCOUNT_ERROR);
+ }
+ const snapshot = section.data[key];
+ if (!snapshot) {
+ throw publicError(404, `No Cline auth named ${key}`);
+ }
+ if (!isReadableSnapshot(snapshot)) {
+ throw publicError(409, CORRUPTED_ACCOUNT_ERROR);
+ }
+ return snapshot;
+};
+
export const saveCline = async (key: string) => {
const safeKey = assertAccountKey(key);
- const secrets = await readValidLiveSecrets();
- await updateVault(async (vault) => {
- const existing = vault.cline.data[safeKey];
+ const current = await readValidLiveFile();
+ await updateVaultSection('cline', (section) => {
+ const existing = section.data[safeKey];
+ if (section.corruptions?.[safeKey] || (existing && !isReadableSnapshot(existing))) {
+ throw publicError(409, CORRUPTED_ACCOUNT_ERROR);
+ }
const now = new Date().toISOString();
- vault.cline.data[safeKey] = {
+ section.data[safeKey] = {
createdAt: existing?.createdAt ?? now,
- secrets,
+ secrets: current.text,
updatedAt: now,
};
- delete vault.cline.limits[safeKey];
+ delete section.limits[safeKey];
return { result: undefined };
});
};
export const loadCline = async (key: string) => {
const safeKey = assertAccountKey(key);
- const snap = (await readVault()).cline.data[safeKey];
- if (!snap) {
- throw publicError(404, `No Cline auth named ${safeKey}`);
- }
- if (!parseSecrets(snap.secrets)) {
- throw publicError(500, `Saved Cline auth named ${safeKey} does not contain a valid account token`);
- }
- await writePrivateFile(CLINE_SECRETS_PATH, snap.secrets);
+ const snap = assertReadableAccount(await readVaultSection('cline'), safeKey);
+ await writePrivateFile(CLINE_PROVIDERS_PATH, snap.secrets);
};
export const deleteCline = async (key: string) => {
const safeKey = assertAccountKey(key);
- await updateVault(async (vault) => {
- if (!vault.cline.data[safeKey]) {
+ await updateVaultSection('cline', (section) => {
+ if (!section.data[safeKey] && !section.corruptions?.[safeKey]) {
throw publicError(404, `No Cline auth named ${safeKey}`);
}
- delete vault.cline.data[safeKey];
- delete vault.cline.limits[safeKey];
+ delete section.data[safeKey];
+ delete section.limits[safeKey];
+ if (section.corruptions) {
+ delete section.corruptions[safeKey];
+ }
return { result: undefined };
});
};
export const clineState = async () => {
- const vault = await readVault();
- const activeAccount = parseSecrets(await liveSecrets().catch(() => ''))?.account ?? null;
+ const section = await readVaultSection('cline');
+ const current = await liveFile().catch(() => null);
+ const activeAccount = current ? (parseClineProviders(current.text)?.account ?? null) : null;
+ const parsedEntries = Object.entries(section.data).map(
+ ([key, snap]) => [key, snap, parseClineProviders(snap.secrets)] as const,
+ );
+ const healthyEntries = parsedEntries
+ .filter(([, , account]) => account !== null)
+ .map(([key, snap, account]) => entry(key, snap, isSameAccount(activeAccount, account?.account ?? null)));
+ const semanticCorruptions = parsedEntries.filter(([, , account]) => account === null).map(([key]) => key);
+ const corruptedEntries = [...Object.keys(section.corruptions ?? {}), ...semanticCorruptions].map((key) => ({
+ active: false,
+ corrupted: true as const,
+ error: CORRUPTED_ACCOUNT_ERROR,
+ key,
+ limitUpdatedAt: '',
+ quota: null,
+ updatedAt: '',
+ }));
return {
- entries: Object.entries(vault.cline.data)
- .map(([key, snap]: [string, ClineSnapshot]) => entry(key, snap, isSameAccount(activeAccount, parseSecrets(snap.secrets)?.account ?? null)))
- .sort((a, b) => {
- if (a.active !== b.active) {
- return a.active ? -1 : 1;
- }
- return a.key.localeCompare(b.key);
- }),
- secretsPath: CLINE_SECRETS_PATH,
+ entries: sortAccountEntries([...healthyEntries, ...corruptedEntries]),
+ providersPath: CLINE_PROVIDERS_PATH,
vaultPath: VAULT_PATH,
};
};
diff --git a/src/codex/auth.ts b/src/codex/auth.ts
new file mode 100644
index 0000000..ea82eff
--- /dev/null
+++ b/src/codex/auth.ts
@@ -0,0 +1,99 @@
+import { decodeJwtPayload } from '../jwt.ts';
+
+export type CodexAuth = {
+ OPENAI_API_KEY?: string | null;
+ auth_mode: 'apikey' | 'chatgpt';
+ last_refresh?: string | null;
+ tokens?: {
+ access_token: string;
+ account_id?: string | null;
+ id_token: string;
+ refresh_token: string;
+ } | null;
+};
+
+const isRecord = (value: unknown): value is Record => {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+};
+
+const nonEmptyString = (value: unknown): value is string => typeof value === 'string' && Boolean(value.trim());
+
+const idTokenClaimIdentity = (token: string) => {
+ const payload = decodeJwtPayload(token);
+ if (!payload) {
+ return '';
+ }
+ const auth = isRecord(payload['https://api.openai.com/auth']) ? payload['https://api.openai.com/auth'] : {};
+ for (const value of [auth.chatgpt_account_id, auth.chatgpt_user_id, auth.user_id, payload.sub]) {
+ if (nonEmptyString(value)) {
+ return value;
+ }
+ }
+ return '';
+};
+
+const parsedRecord = (text: string) => {
+ try {
+ const value = JSON.parse(text) as unknown;
+ return isRecord(value) ? value : null;
+ } catch {
+ return null;
+ }
+};
+
+const isOptionalString = (value: unknown) => value === undefined || value === null || typeof value === 'string';
+
+const optionalTokensValid = (tokens: unknown) => {
+ if (tokens === undefined || tokens === null) {
+ return true;
+ }
+ if (!isRecord(tokens)) {
+ return false;
+ }
+ return ['access_token', 'account_id', 'id_token', 'refresh_token'].every((field) =>
+ isOptionalString(tokens[field]),
+ );
+};
+
+const optionalFieldsValid = (value: Record) => {
+ return (
+ isOptionalString(value.OPENAI_API_KEY) &&
+ isOptionalString(value.last_refresh) &&
+ optionalTokensValid(value.tokens)
+ );
+};
+
+const chatGptTokensValid = (tokens: Record) => {
+ return (
+ nonEmptyString(tokens.access_token) &&
+ nonEmptyString(tokens.id_token) &&
+ nonEmptyString(tokens.refresh_token) &&
+ (tokens.account_id === undefined || tokens.account_id === null || typeof tokens.account_id === 'string')
+ );
+};
+
+export const parseCodexAuth = (text: string): CodexAuth | null => {
+ const value = parsedRecord(text);
+ if (!value || !nonEmptyString(value.auth_mode) || !optionalFieldsValid(value)) {
+ return null;
+ }
+ if (value.auth_mode === 'apikey') {
+ return nonEmptyString(value.OPENAI_API_KEY) ? (value as CodexAuth) : null;
+ }
+ if (value.auth_mode !== 'chatgpt' || !isRecord(value.tokens)) {
+ return null;
+ }
+ const tokens = value.tokens;
+ if (!chatGptTokensValid(tokens)) {
+ return null;
+ }
+ return value as CodexAuth;
+};
+
+export const codexAuthIdentity = (auth: CodexAuth | null) => {
+ if (auth?.auth_mode !== 'chatgpt') {
+ return auth?.OPENAI_API_KEY ?? '';
+ }
+ const tokens = auth.tokens;
+ return tokens?.account_id || (tokens?.id_token ? idTokenClaimIdentity(tokens.id_token) || tokens.id_token : '');
+};
diff --git a/src/codex/service.test.ts b/src/codex/service.test.ts
new file mode 100644
index 0000000..8523bef
--- /dev/null
+++ b/src/codex/service.test.ts
@@ -0,0 +1,198 @@
+import { expect, it } from 'bun:test';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+const runScript = async (script: string, env: Record) => {
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: { ...process.env, ...env },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ return JSON.parse(stdout) as unknown;
+};
+
+it('should not open a vault write transaction for cached Codex state', async () => {
+ const script = `
+ const { mock } = await import('bun:test');
+ let writes = 0;
+ mock.module('./src/storage/vault.ts', () => ({
+ readVaultSection: async () => ({
+ data: {
+ saved: {
+ auth: JSON.stringify({
+ auth_mode: 'chatgpt',
+ tokens: { access_token: 'access', id_token: 'id', refresh_token: 'refresh' },
+ }),
+ createdAt: '',
+ updatedAt: '',
+ },
+ },
+ limits: {
+ saved: { fetchedAt: 'cached', quota: { expires: '', models: {}, ok: true, tier: 'plus' } },
+ },
+ }),
+ updateVaultSection: async () => { writes += 1; },
+ }));
+ const { codexState } = await import('./src/codex/service.ts');
+ const state = await codexState();
+ console.log(JSON.stringify({ cached: state.entries[0]?.limitUpdatedAt, writes }));
+ `;
+
+ expect(await runScript(script, {})).toEqual({ cached: 'cached', writes: 0 });
+});
+
+it('should reject invalid Codex auth on save and again before load', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-codex-validation-test-'));
+ const authPath = join(dir, 'auth.json');
+ const vaultPath = join(dir, 'vault.json');
+ const script = `
+ const { codexState, deleteCodex, loadCodex, saveCodex } = await import('./src/codex/service.ts');
+ const { updateVaultSection } = await import('./src/storage/vault.ts');
+ const authPath = process.env.CODEX_AUTH_PATH;
+ const valid = JSON.stringify({
+ auth_mode: 'chatgpt',
+ tokens: {
+ access_token: 'access',
+ account_id: 'account',
+ id_token: 'id',
+ refresh_token: 'refresh',
+ },
+ });
+ await Bun.write(authPath, '{');
+ const saveError = await saveCodex('invalid').catch((error) => String(error));
+ await Bun.write(authPath, valid);
+ await saveCodex('saved');
+ await updateVaultSection('codex', (section) => {
+ section.data.saved.auth = '{}';
+ return { result: undefined };
+ });
+ const replacementSaveError = await saveCodex('saved').catch((error) => String(error));
+ const state = await codexState({ refreshLimits: true });
+ const refreshError = await codexState({ refreshLimitKey: 'saved' }).catch((error) => String(error));
+ const loadError = await loadCodex('saved').catch((error) => String(error));
+ await deleteCodex('saved');
+ console.log(JSON.stringify({
+ corrupted: state.entries[0]?.corrupted ?? false,
+ deleted: (await codexState()).entries.length === 0,
+ liveUnchanged: await Bun.file(authPath).text() === valid,
+ loadError,
+ refreshError,
+ replacementSaveError,
+ saveError,
+ }));
+ `;
+ try {
+ const result = (await runScript(script, { CODEX_AUTH_PATH: authPath, DONDO_VAULT: vaultPath })) as {
+ corrupted: boolean;
+ deleted: boolean;
+ liveUnchanged: boolean;
+ loadError: string;
+ refreshError: string;
+ replacementSaveError: string;
+ saveError: string;
+ };
+ expect(result.saveError).toContain('not valid Codex auth JSON');
+ expect(result.loadError).toContain('Saved account data is corrupted');
+ expect(result.refreshError).toContain('Saved account data is corrupted');
+ expect(result.replacementSaveError).toContain('Saved account data is corrupted');
+ expect(result.corrupted).toBe(true);
+ expect(result.deleted).toBe(true);
+ expect(result.liveUnchanged).toBe(true);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should expose, block, and intentionally delete a corrupt Codex account', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-codex-corruption-test-'));
+ const authPath = join(dir, 'auth.json');
+ const vaultPath = join(dir, 'vault.json');
+ const script = `
+ const { codexState, deleteCodex, loadCodex } = await import('./src/codex/service.ts');
+ const before = await codexState();
+ const loadError = await loadCodex('damaged').catch((error) => String(error));
+ await deleteCodex('damaged');
+ const after = await codexState();
+ console.log(JSON.stringify({ before: before.entries, deleted: after.entries.length === 0, loadError }));
+ `;
+ try {
+ await Bun.write(
+ vaultPath,
+ JSON.stringify({
+ codex: {
+ data: { damaged: { auth: 'enc:v1:AAAA', createdAt: '', updatedAt: '' } },
+ limits: {},
+ },
+ }),
+ );
+ const result = (await runScript(script, { CODEX_AUTH_PATH: authPath, DONDO_VAULT: vaultPath })) as {
+ before: Array>;
+ deleted: boolean;
+ loadError: string;
+ };
+ expect(result.before).toEqual([
+ {
+ active: false,
+ corrupted: true,
+ error: 'Saved account data is corrupted',
+ key: 'damaged',
+ limitUpdatedAt: '',
+ quota: null,
+ updatedAt: '',
+ },
+ ]);
+ expect(result.loadError).toContain('Saved account data is corrupted');
+ expect(result.deleted).toBe(true);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should not attach a stale Codex refresh to a replacement account', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-codex-stale-refresh-test-'));
+ const authPath = join(dir, 'auth.json');
+ const vaultPath = join(dir, 'vault.json');
+ const script = `
+ let markStarted;
+ let release;
+ const started = new Promise((resolve) => { markStarted = resolve; });
+ const gate = new Promise((resolve) => { release = resolve; });
+ globalThis.fetch = async () => {
+ markStarted();
+ await gate;
+ return Response.json({ rate_limit: { primary_window: { used_percent: 10 } } });
+ };
+ const { codexState, saveCodex } = await import('./src/codex/service.ts');
+ const jwt = (id) => 'header.' + Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600, sub: id })).toString('base64url') + '.signature';
+ const auth = (id) => JSON.stringify({
+ auth_mode: 'chatgpt',
+ tokens: { access_token: jwt(id), account_id: id, id_token: 'id-' + id, refresh_token: 'refresh-' + id },
+ });
+ await Bun.write(process.env.CODEX_AUTH_PATH, auth('old'));
+ await saveCodex('saved');
+ const refresh = codexState({ refreshLimits: true });
+ await started;
+ await Bun.write(process.env.CODEX_AUTH_PATH, auth('new'));
+ await saveCodex('saved');
+ release();
+ const state = await refresh;
+ const saved = state.entries.find((entry) => entry.key === 'saved');
+ console.log(JSON.stringify({ limitUpdatedAt: saved?.limitUpdatedAt ?? '', quota: saved?.quota ?? null }));
+ `;
+ try {
+ const result = await runScript(script, { CODEX_AUTH_PATH: authPath, DONDO_VAULT: vaultPath });
+ expect(result).toEqual({ limitUpdatedAt: '', quota: null });
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
diff --git a/src/codex/service.ts b/src/codex/service.ts
index 3bd6d8e..8fac9dd 100644
--- a/src/codex/service.ts
+++ b/src/codex/service.ts
@@ -1,31 +1,33 @@
+import {
+ boundedMap,
+ CORRUPTED_ACCOUNT_ERROR,
+ selectRefreshEntries,
+ sortAccountEntries,
+ stateVersion,
+} from '../account-state.ts';
import { CODEX_AUTH_PATH, VAULT_PATH } from '../config.ts';
import { assertAccountKey, cleanLimitError, publicError } from '../errors.ts';
-import { writePrivateFile } from '../storage/file.ts';
-import { readVault, updateVault } from '../storage/vault.ts';
-import type { AppVault, CodexSnapshot, LimitResult } from '../types.ts';
+import { readBoundedLocalText, writePrivateFile } from '../storage/file.ts';
+import { readVaultSection, updateVaultSection } from '../storage/vault.ts';
+import type { CodexSnapshot, CodexVault, LimitResult } from '../types.ts';
+import { codexAuthIdentity, parseCodexAuth } from './auth.ts';
import { fetchCodexLimits } from './usage.ts';
-const liveAuth = async () => {
- const file = Bun.file(CODEX_AUTH_PATH);
- return (await file.exists()) ? await file.text() : '';
+type CodexLimitUpdate = {
+ key: string;
+ quota: LimitResult;
+ sourceLimitVersion: string;
+ sourceSnapshotVersion: string;
};
-const parseAuth = (auth: string) => {
- try {
- return JSON.parse(auth) as {
- OPENAI_API_KEY?: string | null;
- tokens?: { account_id?: string; refresh_token?: string };
- };
- } catch {
- return {};
- }
+const liveAuth = async () => {
+ return (await readBoundedLocalText(CODEX_AUTH_PATH)) ?? '';
};
-const isSameAuth = (a: ReturnType, b: ReturnType) => {
- if (a.tokens?.account_id || b.tokens?.account_id) {
- return a.tokens?.account_id === b.tokens?.account_id;
- }
- return !!a.OPENAI_API_KEY && a.OPENAI_API_KEY === b.OPENAI_API_KEY;
+const isSameAuth = (a: ReturnType, b: ReturnType) => {
+ const aIdentity = codexAuthIdentity(a);
+ const bIdentity = codexAuthIdentity(b);
+ return Boolean(aIdentity && bIdentity && aIdentity === bIdentity);
};
const hasNoUsageLeft = (quota: LimitResult | null) => {
@@ -33,112 +35,143 @@ const hasNoUsageLeft = (quota: LimitResult | null) => {
return limits.length > 0 && limits.every(([, model]) => model.percentage <= 0);
};
-const sortEntries = (entries: T[]) => {
- return [...entries].sort((a, b) => {
- if (a.active !== b.active) {
- return a.active ? -1 : 1;
- }
- if (hasNoUsageLeft(a.quota) !== hasNoUsageLeft(b.quota)) {
- return hasNoUsageLeft(a.quota) ? 1 : -1;
- }
- return a.key.localeCompare(b.key);
- });
-};
+const isReadableSnapshot = (snapshot: CodexSnapshot) => parseCodexAuth(snapshot.auth) !== null;
-const updateMissingOrStaleLimits = async (vault: AppVault, force: boolean, targetKey?: string) => {
- let changed = false;
- if (targetKey && !vault.codex.data[targetKey]) {
- throw publicError(404, `No Codex auth named ${targetKey}`);
+const assertReadableAccount = (section: CodexVault, key: string) => {
+ if (section.corruptions?.[key]) {
+ throw publicError(409, CORRUPTED_ACCOUNT_ERROR);
}
-
- for (const [key, snap] of Object.entries(vault.codex.data)) {
- if ((targetKey && key !== targetKey) || (!force && vault.codex.limits[key])) {
- continue;
- }
- const result = await fetchCodexLimits(snap.auth).catch((error) => ({
- quota: cleanLimitError(error),
- }));
- vault.codex.limits[key] = { fetchedAt: new Date().toISOString(), quota: result.quota };
- changed = true;
+ const snapshot = section.data[key];
+ if (!snapshot) {
+ throw publicError(404, `No Codex auth named ${key}`);
}
+ if (!isReadableSnapshot(snapshot)) {
+ throw publicError(409, CORRUPTED_ACCOUNT_ERROR);
+ }
+ return snapshot;
+};
- return changed;
+const fetchCodexLimitUpdates = async (section: CodexVault, force: boolean, targetKey?: string) => {
+ if (targetKey) {
+ assertReadableAccount(section, targetKey);
+ }
+ const readableData = Object.fromEntries(
+ Object.entries(section.data).filter(([, snapshot]) => isReadableSnapshot(snapshot)),
+ );
+ const selected = selectRefreshEntries(readableData, section.limits, targetKey ? { force, targetKey } : { force });
+ return boundedMap(selected, async ([key, snapshot]): Promise => {
+ const result = await fetchCodexLimits(snapshot.auth).catch((error) => ({ quota: cleanLimitError(error) }));
+ return {
+ key,
+ quota: result.quota,
+ sourceLimitVersion: stateVersion(section.limits[key] ?? null),
+ sourceSnapshotVersion: stateVersion(snapshot),
+ };
+ });
};
export const saveCodex = async (key: string) => {
const safeKey = assertAccountKey(key);
- const authFile = Bun.file(CODEX_AUTH_PATH);
- if (!(await authFile.exists())) {
+ const auth = await readBoundedLocalText(CODEX_AUTH_PATH);
+ if (auth === null) {
throw publicError(404, `${CODEX_AUTH_PATH} does not exist`);
}
- const auth = await authFile.text();
if (!auth.trim()) {
throw publicError(400, `${CODEX_AUTH_PATH} is empty`);
}
- try {
- JSON.parse(auth);
- } catch {
- throw publicError(400, `${CODEX_AUTH_PATH} is not valid JSON`);
+ if (!parseCodexAuth(auth)) {
+ throw publicError(400, `${CODEX_AUTH_PATH} is not valid Codex auth JSON`);
}
- await updateVault(async (vault) => {
- const existing = vault.codex.data[safeKey];
+ await updateVaultSection('codex', (section) => {
+ const existing = section.data[safeKey];
+ if (section.corruptions?.[safeKey] || (existing && !isReadableSnapshot(existing))) {
+ throw publicError(409, CORRUPTED_ACCOUNT_ERROR);
+ }
const now = new Date().toISOString();
- vault.codex.data[safeKey] = {
+ section.data[safeKey] = {
auth,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
- delete vault.codex.limits[safeKey];
+ delete section.limits[safeKey];
return { result: undefined };
});
};
export const loadCodex = async (key: string) => {
const safeKey = assertAccountKey(key);
- const snap = (await readVault()).codex.data[safeKey];
- if (!snap) {
- throw publicError(404, `No Codex auth named ${safeKey}`);
- }
-
- await writePrivateFile(CODEX_AUTH_PATH, snap.auth);
+ const snapshot = assertReadableAccount(await readVaultSection('codex'), safeKey);
+ await writePrivateFile(CODEX_AUTH_PATH, snapshot.auth);
};
export const deleteCodex = async (key: string) => {
const safeKey = assertAccountKey(key);
- await updateVault(async (vault) => {
- if (!vault.codex.data[safeKey]) {
+ await updateVaultSection('codex', (section) => {
+ if (!section.data[safeKey] && !section.corruptions?.[safeKey]) {
throw publicError(404, `No Codex auth named ${safeKey}`);
}
- delete vault.codex.data[safeKey];
- delete vault.codex.limits[safeKey];
+ delete section.data[safeKey];
+ delete section.limits[safeKey];
+ if (section.corruptions) {
+ delete section.corruptions[safeKey];
+ }
return { result: undefined };
});
};
export const codexState = async (options: { refreshLimitKey?: string; refreshLimits?: boolean } = {}) => {
const refreshLimitKey = options.refreshLimitKey ? assertAccountKey(options.refreshLimitKey) : undefined;
- const vault = await updateVault(async (current) => {
- const changed = await updateMissingOrStaleLimits(current, options.refreshLimits === true, refreshLimitKey);
- return { result: current, write: changed };
- });
- const activeAuth = await liveAuth().catch(() => '');
- const active = parseAuth(activeAuth);
+ const snapshot = await readVaultSection('codex');
+ const updates = await fetchCodexLimitUpdates(snapshot, options.refreshLimits === true, refreshLimitKey);
+ const section =
+ updates.length === 0
+ ? snapshot
+ : await updateVaultSection('codex', (current) => {
+ let changed = false;
+ for (const update of updates) {
+ const saved = current.data[update.key];
+ if (
+ !saved ||
+ stateVersion(saved) !== update.sourceSnapshotVersion ||
+ stateVersion(current.limits[update.key] ?? null) !== update.sourceLimitVersion
+ ) {
+ continue;
+ }
+ current.limits[update.key] = { fetchedAt: new Date().toISOString(), quota: update.quota };
+ changed = true;
+ }
+ return { result: current, write: changed };
+ });
+ const active = parseCodexAuth(await liveAuth().catch(() => ''));
+ const healthyEntries = Object.entries(section.data)
+ .filter(([, saved]) => isReadableSnapshot(saved))
+ .map(([key, saved]: [string, CodexSnapshot]) => {
+ const cached = section.limits[key];
+ return {
+ active: isSameAuth(active, parseCodexAuth(saved.auth)),
+ key,
+ limitUpdatedAt: cached?.fetchedAt ?? '',
+ quota: cached?.quota ?? null,
+ updatedAt: saved.updatedAt,
+ };
+ });
+ const semanticCorruptions = Object.entries(section.data)
+ .filter(([, saved]) => !isReadableSnapshot(saved))
+ .map(([key]) => key);
+ const corruptedEntries = [...Object.keys(section.corruptions ?? {}), ...semanticCorruptions].map((key) => ({
+ active: false,
+ corrupted: true as const,
+ error: CORRUPTED_ACCOUNT_ERROR,
+ key,
+ limitUpdatedAt: '',
+ quota: null,
+ updatedAt: '',
+ }));
return {
authPath: CODEX_AUTH_PATH,
- entries: sortEntries(
- Object.entries(vault.codex.data).map(([key, snap]: [string, CodexSnapshot]) => {
- const cached = vault.codex.limits[key];
- return {
- active: isSameAuth(active, parseAuth(snap.auth)),
- key,
- limitUpdatedAt: cached?.fetchedAt ?? '',
- quota: cached?.quota ?? null,
- updatedAt: snap.updatedAt,
- };
- }),
- ),
+ entries: sortAccountEntries([...healthyEntries, ...corruptedEntries], hasNoUsageLeft),
vaultPath: VAULT_PATH,
};
};
diff --git a/src/codex/usage.test.ts b/src/codex/usage.test.ts
index e881a31..491f17f 100644
--- a/src/codex/usage.test.ts
+++ b/src/codex/usage.test.ts
@@ -1,4 +1,5 @@
import { afterEach, expect, it } from 'bun:test';
+import { codexAuthIdentity, parseCodexAuth } from './auth.ts';
import { fetchCodexLimits, usageToLimitResult } from './usage.ts';
const jwt = (payload: object) =>
@@ -48,6 +49,38 @@ it('should not render a zero-minute usage window suffix', () => {
expect(result.models['codex-primary']?.displayName).toBe('Primary Limit');
});
+it('should ignore hostile Codex usage field types', () => {
+ const result = usageToLimitResult({
+ credits: { balance: { secret: true }, has_credits: 'yes', unlimited: 1 },
+ plan_type: { name: 'plus' },
+ rate_limit: {
+ primary_window: { limit_window_seconds: '18000', reset_at: {}, used_percent: 25 },
+ secondary_window: { used_percent: '50' },
+ },
+ });
+
+ expect(result).toEqual({
+ expires: '',
+ models: {
+ 'codex-primary': {
+ displayName: 'Primary Limit',
+ percentage: 75,
+ resetTime: '',
+ },
+ },
+ ok: true,
+ tier: '',
+ });
+});
+
+it('should reject Codex usage without validated quota fields', () => {
+ expect(usageToLimitResult({})).toEqual({ error: 'Codex usage returned no quota fields', ok: false });
+ expect(usageToLimitResult({ rate_limit: { primary_window: { used_percent: '25' } } })).toEqual({
+ error: 'Codex usage returned no quota fields',
+ ok: false,
+ });
+});
+
it('should not refresh Codex OAuth tokens while fetching usage', async () => {
const calls: string[] = [];
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
@@ -61,7 +94,7 @@ it('should not refresh Codex OAuth tokens while fetching usage', async () => {
primary_window: { limit_window_seconds: 18_000, reset_at: 1_800_000_000, used_percent: 25 },
},
});
- }) as typeof fetch;
+ }) as unknown as typeof fetch;
const result = await fetchCodexLimits(
JSON.stringify({
@@ -69,6 +102,7 @@ it('should not refresh Codex OAuth tokens while fetching usage', async () => {
tokens: {
access_token: jwt({ exp: Math.floor(Date.now() / 1000) + 3600 }),
account_id: 'account',
+ id_token: 'id-token',
refresh_token: 'refresh',
},
}),
@@ -79,6 +113,151 @@ it('should not refresh Codex OAuth tokens while fetching usage', async () => {
expect(calls.some((call) => call.includes('/oauth/token'))).toBe(false);
});
+it('should use ChatGPT usage when a ChatGPT auth also contains a stale API key', async () => {
+ let requests = 0;
+ globalThis.fetch = (async () => {
+ requests += 1;
+ return Response.json({ rate_limit: { primary_window: { used_percent: 25 } } });
+ }) as unknown as typeof fetch;
+
+ const result = await fetchCodexLimits(
+ JSON.stringify({
+ auth_mode: 'chatgpt',
+ OPENAI_API_KEY: 'stale-api-key',
+ tokens: {
+ access_token: jwt({ exp: Math.floor(Date.now() / 1000) + 3600 }),
+ account_id: 'account',
+ id_token: 'id-token',
+ refresh_token: 'refresh',
+ },
+ }),
+ );
+
+ expect(result.quota.ok).toBe(true);
+ expect(requests).toBe(1);
+});
+
+it('should accept current ChatGPT token data when account_id is omitted', async () => {
+ globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
+ const headers = init?.headers as Record | undefined;
+ expect(headers?.['chatgpt-account-id']).toBeUndefined();
+ return Response.json({ rate_limit: { primary_window: { used_percent: 25 } } });
+ }) as typeof fetch;
+
+ const result = await fetchCodexLimits(
+ JSON.stringify({
+ auth_mode: 'chatgpt',
+ tokens: {
+ access_token: jwt({ exp: Math.floor(Date.now() / 1000) + 3600 }),
+ id_token: 'id-token',
+ refresh_token: 'refresh',
+ },
+ }),
+ );
+
+ expect(result.quota.ok).toBe(true);
+});
+
+it('should keep Codex identity stable across token rotation when account_id is omitted', () => {
+ const idToken = (expires: number, signature: string) =>
+ [
+ 'header',
+ Buffer.from(
+ JSON.stringify({
+ exp: expires,
+ 'https://api.openai.com/auth': { chatgpt_account_id: 'stable-account' },
+ }),
+ ).toString('base64url'),
+ signature,
+ ].join('.');
+ const auth = (expires: number, suffix: string) =>
+ parseCodexAuth(
+ JSON.stringify({
+ auth_mode: 'chatgpt',
+ tokens: {
+ access_token: `access-${suffix}`,
+ id_token: idToken(expires, suffix),
+ refresh_token: `refresh-${suffix}`,
+ },
+ }),
+ );
+
+ expect(codexAuthIdentity(auth(100, 'old'))).toBe('stable-account');
+ expect(codexAuthIdentity(auth(200, 'new'))).toBe('stable-account');
+});
+
+it('should ignore hostile non-UTF-8 Codex JWT claims', () => {
+ const hostile = `header.${Buffer.from([0xc3, 0x28]).toString('base64url')}.signature`;
+ const auth = parseCodexAuth(
+ JSON.stringify({
+ auth_mode: 'chatgpt',
+ tokens: { access_token: hostile, id_token: hostile, refresh_token: 'refresh' },
+ }),
+ );
+
+ expect(codexAuthIdentity(auth)).toBe(hostile);
+});
+
+it('should accept null for current optional Codex auth fields', async () => {
+ globalThis.fetch = (async () =>
+ Response.json({ rate_limit: { primary_window: { used_percent: 25 } } })) as unknown as typeof fetch;
+
+ const result = await fetchCodexLimits(
+ JSON.stringify({
+ auth_mode: 'chatgpt',
+ last_refresh: null,
+ tokens: {
+ access_token: jwt({ exp: Math.floor(Date.now() / 1000) + 3600 }),
+ account_id: null,
+ id_token: 'id-token',
+ refresh_token: 'refresh',
+ },
+ }),
+ );
+
+ expect(result.quota.ok).toBe(true);
+ await expect(
+ fetchCodexLimits(JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: 'key', tokens: null })),
+ ).resolves.toMatchObject({ quota: { ok: false } });
+});
+
+it('should accept only the current apikey auth mode spelling', async () => {
+ await expect(fetchCodexLimits(JSON.stringify({ auth_mode: 'api_key', OPENAI_API_KEY: 'key' }))).rejects.toThrow(
+ 'invalid or incomplete',
+ );
+ await expect(fetchCodexLimits(JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: 'key' }))).resolves.toEqual({
+ quota: { error: 'Codex usage is only available for ChatGPT login accounts', ok: false },
+ });
+});
+
+it('should reject incomplete Codex auth structures before network access', async () => {
+ let requests = 0;
+ globalThis.fetch = (async () => {
+ requests += 1;
+ return Response.json({});
+ }) as unknown as typeof fetch;
+
+ await expect(fetchCodexLimits('{')).rejects.toThrow('invalid or incomplete');
+ await expect(
+ fetchCodexLimits(JSON.stringify({ auth_mode: 'chatgpt', tokens: { access_token: 'access' } })),
+ ).rejects.toThrow('invalid or incomplete');
+ await expect(
+ fetchCodexLimits(
+ JSON.stringify({
+ auth_mode: 'chatgpt',
+ OPENAI_API_KEY: 123,
+ tokens: {
+ access_token: 'access',
+ account_id: 'account',
+ id_token: 'id',
+ refresh_token: 'refresh',
+ },
+ }),
+ ),
+ ).rejects.toThrow('invalid or incomplete');
+ expect(requests).toBe(0);
+});
+
it('should surface Codex usage 401 without using the refresh token', async () => {
const calls: string[] = [];
globalThis.fetch = (async (url: string | URL | Request) => {
@@ -93,6 +272,7 @@ it('should surface Codex usage 401 without using the refresh token', async () =>
tokens: {
access_token: jwt({ exp: Math.floor(Date.now() / 1000) + 3600 }),
account_id: 'account',
+ id_token: 'id-token',
refresh_token: 'refresh',
},
}),
@@ -119,6 +299,7 @@ it('should not call Codex usage when the saved access token is expired', async (
tokens: {
access_token: jwt({ exp: 1 }),
account_id: 'account',
+ id_token: 'id-token',
refresh_token: 'refresh',
},
}),
diff --git a/src/codex/usage.ts b/src/codex/usage.ts
index 5593656..f63f25f 100644
--- a/src/codex/usage.ts
+++ b/src/codex/usage.ts
@@ -1,37 +1,9 @@
import { CODEX_USAGE_URL, CODEX_USER_AGENT } from '../config.ts';
import { publicError } from '../errors.ts';
+import { discardResponse, readBoundedResponseJson } from '../http.ts';
+import { decodeJwtPayload } from '../jwt.ts';
import type { LimitResult } from '../types.ts';
-
-type CodexAuth = {
- OPENAI_API_KEY?: string | null;
- auth_mode?: string;
- tokens?: {
- access_token?: string;
- account_id?: string;
- id_token?: string;
- refresh_token?: string;
- };
- last_refresh?: string;
-};
-
-type CodexUsagePayload = {
- plan_type?: string;
- rate_limit?: {
- primary_window?: CodexWindow | null;
- secondary_window?: CodexWindow | null;
- } | null;
- credits?: {
- balance?: string | null;
- has_credits?: boolean;
- unlimited?: boolean;
- } | null;
-};
-
-type CodexWindow = {
- limit_window_seconds?: number | null;
- reset_at?: number | null;
- used_percent?: number;
-};
+import { type CodexAuth, parseCodexAuth } from './auth.ts';
type CodexLimitFetch = {
quota: LimitResult;
@@ -40,28 +12,12 @@ type CodexLimitFetch = {
const REQUEST_TIMEOUT_MS = 15_000;
const EXPIRY_GRACE_SECONDS = 60;
-const parseAuth = (auth: string): CodexAuth => {
- try {
- return JSON.parse(auth) as CodexAuth;
- } catch {
- throw publicError(400, 'Saved Codex auth JSON is malformed');
- }
-};
-
-const parseJwtPayload = (token: string): Record | null => {
- const part = token.split('.')[1];
- if (!part) {
- return null;
- }
- try {
- return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as Record;
- } catch {
- return null;
- }
+const isRecord = (value: unknown): value is Record => {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
};
const accessTokenExpired = (token: string) => {
- const exp = parseJwtPayload(token)?.exp;
+ const exp = decodeJwtPayload(token)?.exp;
return typeof exp === 'number' && exp <= Math.floor(Date.now() / 1000) + EXPIRY_GRACE_SECONDS;
};
@@ -76,7 +32,13 @@ const codexHeaders = (accessToken: string, accountId?: string) => ({
'User-Agent': CODEX_USER_AGENT,
});
-const resetIso = (resetAt?: number | null) => (resetAt ? new Date(resetAt * 1000).toISOString() : '');
+const resetIso = (resetAt?: number | null) => {
+ if (typeof resetAt !== 'number' || !Number.isFinite(resetAt) || resetAt <= 0) {
+ return '';
+ }
+ const date = new Date(resetAt * 1_000);
+ return Number.isNaN(date.valueOf()) ? '' : date.toISOString();
+};
const windowSuffix = (minutes: number) => {
if (minutes <= 0) {
@@ -101,23 +63,29 @@ const windowLabel = (fallback: string, suffix: string) => {
return fallback;
};
-const windowLimit = (fallbackLabel: string, window?: CodexWindow | null) => {
- if (!window || typeof window.used_percent !== 'number') {
+const windowLimit = (fallbackLabel: string, value: unknown) => {
+ if (!isRecord(value) || typeof value.used_percent !== 'number' || !Number.isFinite(value.used_percent)) {
return null;
}
- const minutes = window.limit_window_seconds ? Math.ceil(window.limit_window_seconds / 60) : 0;
+ const seconds = value.limit_window_seconds;
+ const minutes =
+ typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds / 60) : 0;
const suffix = windowSuffix(minutes);
return {
displayName: `${windowLabel(fallbackLabel, suffix)}${suffix ? ` (${suffix})` : ''}`,
- percentage: Math.max(0, Math.min(100, Math.round(100 - window.used_percent))),
- resetTime: resetIso(window.reset_at),
+ percentage: Math.max(0, Math.min(100, Math.round(100 - value.used_percent))),
+ resetTime: resetIso(typeof value.reset_at === 'number' ? value.reset_at : undefined),
};
};
-export const usageToLimitResult = (payload: CodexUsagePayload): LimitResult => {
+export const usageToLimitResult = (payload: unknown): LimitResult => {
+ const record = isRecord(payload) ? payload : {};
+ const rateLimit = isRecord(record.rate_limit) ? record.rate_limit : {};
+ const credits = isRecord(record.credits) ? record.credits : {};
+ const balance = typeof credits.balance === 'string' && credits.balance ? credits.balance : undefined;
const entries: [string, NonNullable>][] = [];
- const primary = windowLimit('Primary Limit', payload.rate_limit?.primary_window);
- const secondary = windowLimit('Secondary Limit', payload.rate_limit?.secondary_window);
+ const primary = windowLimit('Primary Limit', rateLimit.primary_window);
+ const secondary = windowLimit('Secondary Limit', rateLimit.secondary_window);
if (primary) {
entries.push(['codex-primary', primary]);
@@ -125,22 +93,26 @@ export const usageToLimitResult = (payload: CodexUsagePayload): LimitResult => {
if (secondary) {
entries.push(['codex-secondary', secondary]);
}
- if (payload.credits?.balance) {
+ if (balance) {
entries.push([
'codex-credits',
{
- displayName: `Credits ${payload.credits.balance}`,
- percentage: payload.credits.unlimited ? 100 : payload.credits.has_credits ? 100 : 0,
+ displayName: `Credits ${balance}`,
+ percentage: credits.unlimited === true || credits.has_credits === true ? 100 : 0,
resetTime: '',
},
]);
}
+ if (entries.length === 0) {
+ return { error: 'Codex usage returned no quota fields', ok: false };
+ }
+
return {
expires: '',
models: Object.fromEntries(entries),
ok: true,
- tier: payload.plan_type ?? '',
+ tier: typeof record.plan_type === 'string' ? record.plan_type : '',
};
};
@@ -154,21 +126,25 @@ const requestUsage = async (auth: CodexAuth) => {
}
const res = await fetch(CODEX_USAGE_URL, {
- headers: codexHeaders(accessToken, auth.tokens?.account_id),
+ headers: codexHeaders(accessToken, auth.tokens?.account_id ?? undefined),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) {
+ await discardResponse(res);
if (res.status === 401) {
return staleTokenQuota('Codex');
}
throw new Error(`HTTP ${res.status}`);
}
- return usageToLimitResult((await res.json()) as CodexUsagePayload);
+ return usageToLimitResult(await readBoundedResponseJson(res, 'Codex usage'));
};
export const fetchCodexLimits = async (authText: string): Promise => {
- const auth = parseAuth(authText);
- if (auth.auth_mode === 'apikey' || auth.auth_mode === 'api_key' || auth.OPENAI_API_KEY) {
+ const auth = parseCodexAuth(authText);
+ if (!auth) {
+ throw publicError(400, 'Saved Codex auth JSON is invalid or incomplete');
+ }
+ if (auth.auth_mode === 'apikey') {
return { quota: { error: 'Codex usage is only available for ChatGPT login accounts', ok: false } };
}
diff --git a/src/config.test.ts b/src/config.test.ts
new file mode 100644
index 0000000..08d05e0
--- /dev/null
+++ b/src/config.test.ts
@@ -0,0 +1,46 @@
+import { expect, it } from 'bun:test';
+
+it('should ignore the removed ANTIGRAVITY_VAULT compatibility variable', async () => {
+ const script = `
+ delete process.env.DONDO_VAULT;
+ process.env.ANTIGRAVITY_VAULT = '/tmp/legacy-antigravity-vault.json';
+ const { VAULT_PATH } = await import('./src/config.ts');
+ console.log(JSON.stringify({ vaultPath: VAULT_PATH }));
+ `;
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ expect(JSON.parse(stdout).vaultPath).not.toBe('/tmp/legacy-antigravity-vault.json');
+});
+
+it('uses the Antigravity macOS process name by default', async () => {
+ const script = `
+ delete process.env.ANTIGRAVITY_PROCESS_NAME;
+ const { ANTIGRAVITY_PROCESS_NAME } = await import('./src/config.ts');
+ console.log(JSON.stringify({ processName: ANTIGRAVITY_PROCESS_NAME }));
+ `;
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ expect(JSON.parse(stdout)).toEqual({ processName: 'Antigravity' });
+});
diff --git a/src/config.ts b/src/config.ts
index d080ccb..946b4c1 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -6,14 +6,12 @@ const env = (key: string) => {
return value ? value : undefined;
};
+if (process.platform !== 'darwin') {
+ throw new Error(`Dondo supports macOS only; unsupported platform: ${process.platform}`);
+}
+
const appDataDir = () => {
- if (process.platform === 'darwin') {
- return join(homedir(), 'Library', 'Application Support', 'Dondo');
- }
- if (process.platform === 'win32') {
- return join(env('LOCALAPPDATA') ?? join(homedir(), 'AppData', 'Local'), 'Dondo', 'Data');
- }
- return join(env('XDG_DATA_HOME') ?? join(homedir(), '.local', 'share'), 'dondo');
+ return join(homedir(), 'Library', 'Application Support', 'Dondo');
};
const parsePort = () => {
@@ -28,37 +26,24 @@ const parsePort = () => {
export const HOST = '127.0.0.1';
export const PORT = parsePort();
export const DATA_DIR = env('DONDO_DATA_DIR') ?? appDataDir();
-export const VAULT_PATH = env('DONDO_VAULT') ?? env('ANTIGRAVITY_VAULT') ?? join(DATA_DIR, 'vault.json');
+export const VAULT_PATH = env('DONDO_VAULT') ?? join(DATA_DIR, 'vault.json');
export const CODEX_AUTH_PATH = env('CODEX_AUTH_PATH') ?? join(homedir(), '.codex', 'auth.json');
-export const CLINE_SECRETS_PATH =
- env('CLINE_SECRETS_PATH') ?? join(homedir(), '.cline', 'data', 'secrets.json');
+export const CLINE_PROVIDERS_PATH =
+ env('CLINE_PROVIDERS_PATH') ?? join(homedir(), '.cline', 'data', 'settings', 'providers.json');
export const KIRO_AUTH_PATH = env('KIRO_AUTH_PATH') ?? join(homedir(), '.aws', 'sso', 'cache', 'kiro-auth-token.json');
export const KIRO_PROFILE_PATH =
env('KIRO_PROFILE_PATH') ??
- (process.platform === 'darwin'
- ? join(
- homedir(),
- 'Library',
- 'Application Support',
- 'Kiro',
- 'User',
- 'globalStorage',
- 'kiro.kiroagent',
- 'profile.json',
- )
- : process.platform === 'win32'
- ? join(
- env('APPDATA') ?? join(homedir(), 'AppData', 'Roaming'),
- 'Kiro',
- 'User',
- 'globalStorage',
- 'kiro.kiroagent',
- 'profile.json',
- )
- : join(homedir(), '.config', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent', 'profile.json'));
-export const KIRO_PROCESS_NAME =
- env('KIRO_PROCESS_NAME') ??
- (process.platform === 'darwin' ? 'Kiro' : process.platform === 'win32' ? 'Kiro.exe' : 'kiro');
+ join(
+ homedir(),
+ 'Library',
+ 'Application Support',
+ 'Kiro',
+ 'User',
+ 'globalStorage',
+ 'kiro.kiroagent',
+ 'profile.json',
+ );
+export const KIRO_PROCESS_NAME = env('KIRO_PROCESS_NAME') ?? 'Kiro';
export const KIRO_AUTH_REFRESH_URL =
env('KIRO_AUTH_REFRESH_URL') ?? 'https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken';
export const KIRO_USAGE_URL = env('KIRO_USAGE_URL');
@@ -75,12 +60,12 @@ export const MINIMAX_LOCAL_STORAGE_PATH =
export const VAULT_KEY_SERVICE = 'dondo';
export const VAULT_KEY_ACCOUNT = 'vault-key';
-export const ANTIGRAVITY_KEYCHAIN = env('ANTIGRAVITY_KEYCHAIN') ?? 'login.keychain-db';
export const ANTIGRAVITY_SERVICE = env('ANTIGRAVITY_SERVICE') ?? 'gemini';
export const ANTIGRAVITY_ACCOUNT = env('ANTIGRAVITY_ACCOUNT') ?? 'antigravity';
export const ANTIGRAVITY_VERSION = env('ANTIGRAVITY_VERSION') ?? '2.0.3';
-export const ANTIGRAVITY_LANGUAGE_SERVER_PATH = env('ANTIGRAVITY_LANGUAGE_SERVER_PATH') ?? '';
+export const ANTIGRAVITY_PROCESS_NAME = env('ANTIGRAVITY_PROCESS_NAME') ?? 'Antigravity';
+export const antigravityLanguageServerPath = () => env('ANTIGRAVITY_LANGUAGE_SERVER_PATH') ?? '';
export const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
export const LOAD_PROJECT_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist';
export const QUOTA_URLS = [
diff --git a/src/errors.test.ts b/src/errors.test.ts
index 1c4611a..6ac49ab 100644
--- a/src/errors.test.ts
+++ b/src/errors.test.ts
@@ -1,21 +1,63 @@
import { expect, it } from 'bun:test';
-import { assertAccountKey, redactSecrets } from './errors.ts';
+import { assertAccountKey, cleanLimitError, errorStatus, isPublicError, publicError, redactSecrets } from './errors.ts';
it('should redact token-shaped values from public errors', () => {
const redacted = redactSecrets(
- 'Bearer ya29.secret_token {"access_token":"abc","refresh_token":"def","accessToken":"ghi"} password: "plain"',
+ [
+ 'Bearer ya29.bearer',
+ '{"access_token":"ZZZ1","refresh_token":"ZZZ2","id_token":"ZZZ3"}',
+ '{"accessToken":"ZZZ4","refreshToken":"ZZZ5","idToken":"ZZZ6"}',
+ '{"clientSecret":"ZZZ7","apiKey":"ZZZ8","OPENAI_API_KEY":"ZZZ9"}',
+ '{"password":"ZZZ10","authorization":"ZZZ11"}',
+ 'password: "ZZZ12"',
+ 'token=ZZZ13&safe=value',
+ 'refreshToken=ZZZ14',
+ ].join(' '),
);
- expect(redacted).not.toContain('secret_token');
- expect(redacted).not.toContain('abc');
- expect(redacted).not.toContain('def');
- expect(redacted).not.toContain('ghi');
- expect(redacted).not.toContain('plain');
+ for (const secret of ['ya29.bearer', ...Array.from({ length: 14 }, (_, index) => `ZZZ${index + 1}`)]) {
+ expect(redacted).not.toContain(secret);
+ }
expect(redacted).toContain('Bearer [redacted]');
});
+it('should not redact ordinary prose containing credential-related words', () => {
+ const prose = 'Refresh token handling and password rotation are documented; authorization is required.';
+ expect(redactSecrets(prose)).toBe(prose);
+});
+
it('should reject whitespace-only and padded account keys', () => {
expect(() => assertAccountKey(' ')).toThrow();
expect(() => assertAccountKey(' account ')).toThrow();
expect(assertAccountKey('account.one@example.com')).toBe('account.one@example.com');
});
+
+it('should reject prototype-related account keys', () => {
+ for (const key of ['__proto__', 'constructor', 'prototype']) {
+ expect(() => assertAccountKey(key)).toThrow();
+ }
+});
+
+it('should accept only integer client and server error statuses', () => {
+ const clientError = publicError(400, 'client');
+ expect(isPublicError(clientError)).toBe(true);
+ expect(errorStatus(clientError)).toBe(400);
+ expect(errorStatus(publicError(599, 'server'))).toBe(599);
+
+ for (const status of [399, 600, 400.5, Number.NaN, Number.POSITIVE_INFINITY, '404', null]) {
+ expect(errorStatus({ status })).toBe(500);
+ }
+ expect(isPublicError({ public: true, status: 400 })).toBe(false);
+ expect(errorStatus(new Error('missing status'))).toBe(500);
+});
+
+it('should not turn unexpected refresh failures into public state data', () => {
+ expect(cleanLimitError(new Error('request failed for /private/path with Bearer ya29.secret'))).toEqual({
+ error: 'Could not refresh usage limits',
+ ok: false,
+ });
+ expect(cleanLimitError(publicError(502, 'Usage provider is unavailable'))).toEqual({
+ error: 'Usage provider is unavailable',
+ ok: false,
+ });
+});
diff --git a/src/errors.ts b/src/errors.ts
index 277b759..ebb084a 100644
--- a/src/errors.ts
+++ b/src/errors.ts
@@ -1,11 +1,20 @@
import type { LimitResult } from './types.ts';
export type PublicError = Error & {
+ public: true;
status: number;
};
export const publicError = (status: number, message: string): PublicError => {
- return Object.assign(new Error(message), { status });
+ return Object.assign(new Error(message), { public: true as const, status });
+};
+
+export const isPublicError = (error: unknown): error is PublicError => {
+ if (!(error instanceof Error) || !('public' in error) || error.public !== true || !('status' in error)) {
+ return false;
+ }
+ const status = error.status;
+ return typeof status === 'number' && Number.isInteger(status) && status >= 400 && status <= 599;
};
export const redactSecrets = (value: unknown) => {
@@ -13,9 +22,14 @@ export const redactSecrets = (value: unknown) => {
.replace(/Bearer\s+[^"\s]+/g, 'Bearer [redacted]')
.replace(/password:\s*"[^"]*"/gi, 'password: "[redacted]"')
.replace(
- /(["']?(?:access_token|refresh_token|id_token|accessToken|OPENAI_API_KEY)["']?\s*[:=]\s*["'])[^"']+(["'])/gi,
+ /(["']?(?:access_?token|refresh_?token|id_?token|client_?secret|api_?key|openai_api_key|password|authorization)["']?\s*[:=]\s*["'])[^"']*(["'])/gi,
'$1[redacted]$2',
- );
+ )
+ .replace(
+ /(["']?(?:access_?token|refresh_?token|id_?token|client_?secret|api_?key|openai_api_key|password|authorization)["']?\s*[:=]\s*)(?!["'])[^&\s,;}]+/gi,
+ '$1[redacted]',
+ )
+ .replace(/(\btoken\s*=\s*)[^&\s]+/gi, '$1[redacted]');
};
export const errorMessage = (error: unknown) => {
@@ -23,19 +37,18 @@ export const errorMessage = (error: unknown) => {
};
export const errorStatus = (error: unknown) => {
- return typeof error === 'object' && error !== null && 'status' in error
- ? Number((error as { status: unknown }).status) || 500
- : 500;
+ return isPublicError(error) ? error.status : 500;
};
export const cleanLimitError = (error: unknown): LimitResult => ({
- error: errorMessage(error),
+ error: isPublicError(error) ? errorMessage(error) : 'Could not refresh usage limits',
ok: false,
});
export const assertAccountKey = (key: string) => {
const trimmed = key.trim();
- if (trimmed !== key || !/^[\w .@-]{1,80}$/.test(trimmed)) {
+ const reserved = ['__proto__', 'constructor', 'prototype'].includes(trimmed);
+ if (reserved || trimmed !== key || !/^[\w .@-]{1,80}$/.test(trimmed)) {
throw publicError(400, 'Use 1-80 letters, numbers, spaces, dots, @, _ or - with no leading/trailing spaces');
}
return trimmed;
diff --git a/src/http.test.ts b/src/http.test.ts
new file mode 100644
index 0000000..3efb824
--- /dev/null
+++ b/src/http.test.ts
@@ -0,0 +1,109 @@
+import { expect, it } from 'bun:test';
+import { discardResponse, readBoundedResponseJson, readBoundedResponseText } from './http.ts';
+
+it('should cancel a response body that will not be consumed', async () => {
+ let cancelled = false;
+ const response = new Response(
+ new ReadableStream({
+ cancel: () => {
+ cancelled = true;
+ },
+ }),
+ );
+
+ await discardResponse(response);
+
+ expect(cancelled).toBe(true);
+});
+
+it('should accept a response exactly at the configured byte limit', async () => {
+ const text = 'éé';
+ expect(await readBoundedResponseText(new Response(text), 'Test', Buffer.byteLength(text))).toBe(text);
+});
+
+it('should decode a multibyte character split across response chunks', async () => {
+ const encoded = Buffer.from('€');
+ const response = new Response(
+ new ReadableStream({
+ start: (controller) => {
+ controller.enqueue(encoded.subarray(0, 1));
+ controller.enqueue(encoded.subarray(1));
+ controller.close();
+ },
+ }),
+ );
+ expect(await readBoundedResponseText(response, 'Provider', 3)).toBe('€');
+});
+
+it('should assemble a highly chunked response without losing bytes', async () => {
+ const response = new Response(
+ new ReadableStream({
+ start: (controller) => {
+ for (let index = 0; index < 4_096; index += 1) {
+ controller.enqueue(Uint8Array.of(97 + (index % 26)));
+ }
+ controller.close();
+ },
+ }),
+ );
+ const expected = Array.from({ length: 4_096 }, (_, index) => String.fromCharCode(97 + (index % 26))).join('');
+
+ expect(await readBoundedResponseText(response, 'Provider', 4_096)).toBe(expected);
+});
+
+it('should reject and cancel a streamed response above the byte limit', async () => {
+ let cancelled = false;
+ const response = new Response(
+ new ReadableStream({
+ cancel: () => {
+ cancelled = true;
+ },
+ start: (controller) => {
+ controller.enqueue(Buffer.from('1234'));
+ controller.enqueue(Buffer.from('5'));
+ },
+ }),
+ );
+
+ await expect(readBoundedResponseText(response, 'Provider', 4)).rejects.toThrow(
+ 'Provider response exceeded the 4 bytes size limit',
+ );
+ expect(cancelled).toBe(true);
+});
+
+it('should reject declared oversized responses and cancel without reading', async () => {
+ let cancelled = false;
+ const response = new Response(
+ new ReadableStream({
+ cancel: () => {
+ cancelled = true;
+ },
+ }),
+ { headers: { 'content-length': '5' } },
+ );
+ await expect(readBoundedResponseText(response, 'Provider', 4)).rejects.toThrow(
+ 'Provider response exceeded the 4 bytes size limit',
+ );
+ expect(cancelled).toBe(true);
+});
+
+it('should reject invalid UTF-8 and invalid byte limits without exposing content', async () => {
+ await expect(readBoundedResponseText(new Response(Uint8Array.from([0xc3, 0x28])), 'Provider', 2)).rejects.toThrow(
+ 'Provider response was not valid UTF-8',
+ );
+ await expect(readBoundedResponseText(new Response('ok'), 'Provider', Number.NaN)).rejects.toThrow(
+ 'non-negative safe integer',
+ );
+ await expect(readBoundedResponseText(new Response('ok'), 'Provider', -1)).rejects.toThrow(
+ 'non-negative safe integer',
+ );
+});
+
+it('should parse bounded JSON and redact malformed response content', async () => {
+ await expect(readBoundedResponseJson(new Response('{'), 'Codex')).rejects.toThrow(
+ 'Codex response was not valid JSON',
+ );
+ await expect(readBoundedResponseJson<{ ok: boolean }>(Response.json({ ok: true }), 'Codex')).resolves.toEqual({
+ ok: true,
+ });
+});
diff --git a/src/http.ts b/src/http.ts
new file mode 100644
index 0000000..bdeae56
--- /dev/null
+++ b/src/http.ts
@@ -0,0 +1,83 @@
+export const MAX_HTTP_RESPONSE_BYTES = 1024 * 1024;
+
+const sizeLabel = (maxBytes: number) => {
+ if (maxBytes > 0 && maxBytes % (1024 * 1024) === 0) {
+ return `${maxBytes / (1024 * 1024)} MiB`;
+ }
+ return `${maxBytes} ${maxBytes === 1 ? 'byte' : 'bytes'}`;
+};
+
+const sizeError = (label: string, maxBytes: number) =>
+ new Error(`${label} response exceeded the ${sizeLabel(maxBytes)} size limit`);
+const invalidUtf8Error = (label: string) => new Error(`${label} response was not valid UTF-8`);
+
+const assertMaxBytes = (maxBytes: number) => {
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
+ throw new Error('HTTP response byte limit must be a non-negative safe integer');
+ }
+};
+
+export const discardResponse = async (response: Response) => {
+ await response.body?.cancel().catch(() => {});
+};
+
+export const readBoundedResponseText = async (
+ response: Response,
+ label: string,
+ maxBytes = MAX_HTTP_RESPONSE_BYTES,
+) => {
+ assertMaxBytes(maxBytes);
+ const contentLengthHeader = response.headers.get('content-length');
+ const contentLength = contentLengthHeader === null ? Number.NaN : Number(contentLengthHeader);
+ if (Number.isSafeInteger(contentLength) && contentLength >= 0 && contentLength > maxBytes) {
+ await discardResponse(response);
+ throw sizeError(label, maxBytes);
+ }
+ if (!response.body) {
+ return '';
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder('utf-8', { fatal: true });
+ const chunks: string[] = [];
+ let size = 0;
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ try {
+ chunks.push(decoder.decode());
+ return chunks.join('');
+ } catch {
+ throw invalidUtf8Error(label);
+ }
+ }
+ size += value.byteLength;
+ if (size > maxBytes) {
+ await reader.cancel().catch(() => {});
+ throw sizeError(label, maxBytes);
+ }
+ try {
+ chunks.push(decoder.decode(value, { stream: true }));
+ } catch {
+ await reader.cancel().catch(() => {});
+ throw invalidUtf8Error(label);
+ }
+ }
+ } finally {
+ reader.releaseLock();
+ }
+};
+
+export const readBoundedResponseJson = async (
+ response: Response,
+ label: string,
+ maxBytes = MAX_HTTP_RESPONSE_BYTES,
+): Promise => {
+ const text = await readBoundedResponseText(response, label, maxBytes);
+ try {
+ return JSON.parse(text) as Result;
+ } catch {
+ throw new Error(`${label} response was not valid JSON`);
+ }
+};
diff --git a/src/jwt.test.ts b/src/jwt.test.ts
new file mode 100644
index 0000000..6abe1a3
--- /dev/null
+++ b/src/jwt.test.ts
@@ -0,0 +1,12 @@
+import { expect, it } from 'bun:test';
+import { decodeJwtPayload } from './jwt.ts';
+
+it('decodes only canonical JWT object payloads with valid UTF-8', () => {
+ const token = (payload: Uint8Array) => `header.${Buffer.from(payload).toString('base64url')}.signature`;
+
+ expect(decodeJwtPayload(token(Buffer.from(JSON.stringify({ sub: 'account' }))))).toEqual({ sub: 'account' });
+ expect(decodeJwtPayload(`header.${Buffer.from('{}').toString('base64url')}=.signature`)).toBeNull();
+ expect(decodeJwtPayload(token(Uint8Array.from([0xc3, 0x28])))).toBeNull();
+ expect(decodeJwtPayload(`${token(Buffer.from('{}'))}.extra`)).toBeNull();
+ expect(decodeJwtPayload(token(Buffer.from('[]')))).toBeNull();
+});
diff --git a/src/jwt.ts b/src/jwt.ts
new file mode 100644
index 0000000..144fae0
--- /dev/null
+++ b/src/jwt.ts
@@ -0,0 +1,19 @@
+export const decodeJwtPayload = (token: string): Record | null => {
+ const parts = token.split('.');
+ const encoded = parts[1];
+ if (parts.length !== 3 || parts.some((part) => !part) || !encoded || !/^[A-Za-z0-9_-]+$/u.test(encoded)) {
+ return null;
+ }
+ try {
+ const decoded = Buffer.from(encoded, 'base64url');
+ if (decoded.toString('base64url') !== encoded) {
+ return null;
+ }
+ const value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(decoded)) as unknown;
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : null;
+ } catch {
+ return null;
+ }
+};
diff --git a/src/kiro/auth.ts b/src/kiro/auth.ts
new file mode 100644
index 0000000..b4d110e
--- /dev/null
+++ b/src/kiro/auth.ts
@@ -0,0 +1,62 @@
+export type KiroAuth = {
+ [key: string]: unknown;
+ accessToken?: string;
+ authMethod?: string;
+ clientIdHash?: string;
+ expiresAt?: string;
+ profileArn?: string;
+ provider?: string;
+ refreshToken: string;
+};
+
+type KiroSnapshotConfig = {
+ auth: string;
+ clientRegistration?: string;
+ profile?: string;
+};
+
+const isRecord = (value: unknown): value is Record => {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+};
+
+export const parseKiroAuth = (auth: string): KiroAuth | null => {
+ try {
+ const value = JSON.parse(auth) as unknown;
+ if (!isRecord(value)) {
+ return null;
+ }
+ for (const field of [
+ 'accessToken',
+ 'authMethod',
+ 'clientIdHash',
+ 'expiresAt',
+ 'profileArn',
+ 'provider',
+ 'refreshToken',
+ ] as const) {
+ if (value[field] !== undefined && typeof value[field] !== 'string') {
+ return null;
+ }
+ }
+ return typeof value.refreshToken === 'string' && value.refreshToken.trim() ? (value as KiroAuth) : null;
+ } catch {
+ return null;
+ }
+};
+
+export const parseKiroJsonObject = (text: string) => {
+ try {
+ const value = JSON.parse(text) as unknown;
+ return isRecord(value) ? value : null;
+ } catch {
+ return null;
+ }
+};
+
+export const isKiroSnapshotConfigValid = (snapshot: KiroSnapshotConfig) => {
+ return (
+ parseKiroAuth(snapshot.auth) !== null &&
+ (snapshot.profile === undefined || parseKiroJsonObject(snapshot.profile) !== null) &&
+ (snapshot.clientRegistration === undefined || parseKiroJsonObject(snapshot.clientRegistration) !== null)
+ );
+};
diff --git a/src/kiro/service.test.ts b/src/kiro/service.test.ts
index 3f0bb3d..25002ff 100644
--- a/src/kiro/service.test.ts
+++ b/src/kiro/service.test.ts
@@ -126,7 +126,7 @@ it('should save and load Kiro auth with encrypted vault storage', async () => {
expect(result.activeBeforeLoad).toBe(false);
expect(result.activeAfterLoad).toBe(true);
- expect(result.activeAfterLiveRotation).toBe(true);
+ expect(result.activeAfterLiveRotation).toBe(false);
expect(result.cleared).toBe(true);
expect(result.clearedProfile).toBe(true);
expect(result.deleted).toBe(true);
@@ -300,3 +300,631 @@ it('should not replace live Kiro auth when a saved session was revoked', async (
await rm(dir, { force: true, recursive: true });
}
});
+
+it('should reject hostile Kiro refresh response shapes', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-kiro-malformed-refresh-test-'));
+ const authPath = join(dir, 'kiro-auth-token.json');
+ const vaultPath = join(dir, 'vault.json');
+ const responses = [
+ null,
+ [],
+ 7,
+ {
+ accessToken: 'next-access',
+ expiresIn: 3600,
+ profileArn: { value: 'arn:poison' },
+ refreshToken: 'next-refresh',
+ },
+ ];
+ const refreshServer = Bun.serve({
+ fetch: () => Response.json(responses.shift()),
+ port: 0,
+ });
+ const script = `
+ const { loadKiro, saveKiro } = await import('./src/kiro/service.ts');
+ const authPath = process.env.KIRO_AUTH_PATH;
+ await saveKiro('saved');
+ const original = await Bun.file(authPath).text();
+ const errors = [];
+ for (let index = 0; index < 4; index += 1) {
+ errors.push(await loadKiro('saved').catch((value) => String(value)));
+ }
+ console.log(JSON.stringify({ errors, unchanged: await Bun.file(authPath).text() === original }));
+ `;
+ try {
+ await Bun.write(
+ authPath,
+ JSON.stringify({
+ accessToken: 'access',
+ authMethod: 'social',
+ profileArn: 'arn:saved',
+ refreshToken: 'refresh',
+ }),
+ );
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: {
+ ...process.env,
+ DONDO_VAULT: vaultPath,
+ KIRO_AUTH_PATH: authPath,
+ KIRO_AUTH_REFRESH_URL: `http://127.0.0.1:${refreshServer.port}/refreshToken`,
+ KIRO_PROCESS_NAME: 'dondo-kiro-test-not-running',
+ KIRO_PROFILE_PATH: join(dir, 'profile.json'),
+ },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ const result = JSON.parse(stdout) as { errors: string[]; unchanged: boolean };
+ expect(result.errors).toHaveLength(4);
+ expect(result.errors.every((error) => error.includes('incomplete session refresh response'))).toBe(true);
+ expect(result.unchanged).toBe(true);
+ } finally {
+ refreshServer.stop(true);
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should reject malformed optional Kiro auth fields', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-kiro-malformed-auth-test-'));
+ const authPath = join(dir, 'kiro-auth-token.json');
+ const script = `
+ const { saveKiro } = await import('./src/kiro/service.ts');
+ const authPath = process.env.KIRO_AUTH_PATH;
+ const profilePath = process.env.KIRO_PROFILE_PATH;
+ const authError = await saveKiro('broken').catch((value) => String(value));
+ await Bun.write(authPath, JSON.stringify({ refreshToken: 'refresh' }));
+ await Bun.write(profilePath, '[]');
+ const profileError = await saveKiro('broken').catch((value) => String(value));
+ console.log(JSON.stringify({ authError, profileError }));
+ `;
+ try {
+ await Bun.write(authPath, JSON.stringify({ accessToken: 123, refreshToken: 'refresh' }));
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: {
+ ...process.env,
+ DONDO_VAULT: join(dir, 'vault.json'),
+ KIRO_AUTH_PATH: authPath,
+ KIRO_PROCESS_NAME: 'dondo-kiro-test-not-running',
+ KIRO_PROFILE_PATH: join(dir, 'profile.json'),
+ },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ const result = JSON.parse(stdout) as { authError: string; profileError: string };
+ expect(result.authError).toContain('not valid Kiro auth JSON');
+ expect(result.profileError).toContain('not a valid JSON object');
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should expose semantic-invalid saved Kiro supporting files as deletable corruption', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-kiro-semantic-corruption-test-'));
+ const authPath = join(dir, 'kiro-auth-token.json');
+ const profilePath = join(dir, 'profile.json');
+ const script = `
+ const { deleteKiro, kiroState, loadKiro, saveKiro } = await import('./src/kiro/service.ts');
+ const { updateVaultSection } = await import('./src/storage/vault.ts');
+ await saveKiro('saved');
+ await updateVaultSection('kiro', (section) => {
+ section.data.saved.profile = '[]';
+ return { result: undefined };
+ });
+ const saveError = await saveKiro('saved').catch((error) => String(error));
+ const state = await kiroState({ refreshLimits: true });
+ const refreshError = await kiroState({ refreshLimitKey: 'saved' }).catch((error) => String(error));
+ const loadError = await loadKiro('saved').catch((error) => String(error));
+ await deleteKiro('saved');
+ console.log(JSON.stringify({
+ corrupted: state.entries[0]?.corrupted ?? false,
+ deleted: (await kiroState()).entries.length === 0,
+ loadError,
+ refreshError,
+ saveError,
+ }));
+ `;
+ try {
+ await Promise.all([
+ Bun.write(authPath, JSON.stringify({ authMethod: 'IdC', refreshToken: 'refresh' })),
+ Bun.write(profilePath, JSON.stringify({ id: 'profile' })),
+ ]);
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: {
+ ...process.env,
+ DONDO_VAULT: join(dir, 'vault.json'),
+ KIRO_AUTH_PATH: authPath,
+ KIRO_PROCESS_NAME: 'dondo-kiro-test-not-running',
+ KIRO_PROFILE_PATH: profilePath,
+ },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ const result = JSON.parse(stdout) as {
+ corrupted: boolean;
+ deleted: boolean;
+ loadError: string;
+ refreshError: string;
+ saveError: string;
+ };
+ expect(result).toEqual({
+ corrupted: true,
+ deleted: true,
+ loadError: 'Error: Saved account data is corrupted',
+ refreshError: 'Error: Saved account data is corrupted',
+ saveError: 'Error: Saved account data is corrupted',
+ });
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should serialize overlapping Kiro loads without splitting supporting files', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-kiro-concurrent-load-test-'));
+ const authPath = join(dir, 'kiro-auth-token.json');
+ const profilePath = join(dir, 'profile.json');
+ const script = `
+ const { clearKiro, loadKiro, saveKiro } = await import('./src/kiro/service.ts');
+ const authPath = process.env.KIRO_AUTH_PATH;
+ const profilePath = process.env.KIRO_PROFILE_PATH;
+ const writeLive = async (id) => {
+ await Bun.write(authPath, JSON.stringify({ authMethod: 'IdC', profileArn: 'arn:' + id, refreshToken: 'refresh-' + id }));
+ await Bun.write(profilePath, JSON.stringify({ id }));
+ };
+ await writeLive('first');
+ await saveKiro('first');
+ await writeLive('second');
+ await saveKiro('second');
+ await clearKiro();
+ await Promise.all([loadKiro('first'), loadKiro('second')]);
+ const auth = JSON.parse(await Bun.file(authPath).text());
+ const profile = JSON.parse(await Bun.file(profilePath).text());
+ console.log(JSON.stringify({ profile: profile.id, profileArn: auth.profileArn }));
+ `;
+ try {
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: {
+ ...process.env,
+ DONDO_VAULT: join(dir, 'vault.json'),
+ KIRO_AUTH_PATH: authPath,
+ KIRO_PROCESS_NAME: 'dondo-kiro-test-not-running',
+ KIRO_PROFILE_PATH: profilePath,
+ },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ expect(JSON.parse(stdout)).toEqual({ profile: 'second', profileArn: 'arn:second' });
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should report a fixed error when Kiro session rollback is incomplete', async () => {
+ const script = `
+ const { mock } = await import('bun:test');
+ const authPath = process.env.KIRO_AUTH_PATH;
+ const profilePath = process.env.KIRO_PROFILE_PATH;
+ const currentAuth = JSON.stringify({ authMethod: 'IdC', profileArn: 'arn:current', refreshToken: 'current' });
+ const savedAuth = JSON.stringify({ authMethod: 'IdC', profileArn: 'arn:saved', refreshToken: 'saved' });
+ const realFs = await import('node:fs/promises');
+ let finished = false;
+ let lateWrite = false;
+ let privateWrites = 0;
+ mock.module('./src/storage/file.ts', () => ({
+ readBoundedLocalText: async (path) => path === authPath ? currentAuth : JSON.stringify({ id: 'current' }),
+ writePrivateFile: async () => {
+ privateWrites += 1;
+ if (privateWrites === 3) throw new Error('token=rollback-secret');
+ if (privateWrites === 4) {
+ await Bun.sleep(30);
+ if (finished) lateWrite = true;
+ }
+ },
+ }));
+ mock.module('node:fs/promises', () => ({
+ ...realFs,
+ chmod: async () => { throw new Error('token=commit-secret'); },
+ rename: async () => {},
+ rm: async () => {},
+ }));
+ mock.module('./src/process.ts', () => ({ isProcessRunning: async () => false }));
+ mock.module('./src/storage/vault.ts', () => ({
+ readVaultSection: async () => ({
+ data: { saved: { auth: savedAuth, createdAt: '', profile: JSON.stringify({ id: 'saved' }), updatedAt: '' } },
+ limits: {},
+ }),
+ updateVaultSection: async (_platform, operation) => operation({
+ data: { saved: { auth: savedAuth, createdAt: '', profile: JSON.stringify({ id: 'saved' }), updatedAt: '' } },
+ limits: {},
+ }).result,
+ }));
+ const { loadKiro } = await import('./src/kiro/service.ts');
+ const error = await loadKiro('saved').catch((value) => String(value));
+ finished = true;
+ await Bun.sleep(50);
+ console.log(JSON.stringify({ error, lateWrite }));
+ `;
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: {
+ ...process.env,
+ KIRO_AUTH_PATH: join(process.cwd(), 'unused-auth.json'),
+ KIRO_PROCESS_NAME: 'dondo-kiro-test-not-running',
+ KIRO_PROFILE_PATH: join(process.cwd(), 'unused-profile.json'),
+ },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ expect(JSON.parse(stdout)).toEqual({
+ error: 'Error: Kiro session replacement failed and rollback was incomplete',
+ lateWrite: false,
+ });
+});
+
+it('should finish all Kiro staging work before rollback returns', async () => {
+ const script = `
+ const { mock } = await import('bun:test');
+ const authPath = process.env.KIRO_AUTH_PATH;
+ const profilePath = process.env.KIRO_PROFILE_PATH;
+ const registrationPath = authPath.replace(/[^/]+$/, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.json');
+ const currentAuth = JSON.stringify({ authMethod: 'IdC', profileArn: 'arn:current', refreshToken: 'current' });
+ const savedAuth = JSON.stringify({
+ authMethod: 'IdC', clientIdHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ profileArn: 'arn:saved', refreshToken: 'saved',
+ });
+ let finished = false;
+ let lateWrite = false;
+ mock.module('./src/storage/file.ts', () => ({
+ readBoundedLocalText: async (path) => {
+ if (path === profilePath) {
+ await Bun.sleep(30);
+ return JSON.stringify({ id: 'current' });
+ }
+ if (path === registrationPath) throw new Error('registration read failed');
+ return currentAuth;
+ },
+ writePrivateFile: async () => { if (finished) lateWrite = true; },
+ }));
+ mock.module('node:fs/promises', () => ({
+ chmod: async () => {},
+ rename: async () => {},
+ rm: async () => {},
+ }));
+ mock.module('./src/process.ts', () => ({ isProcessRunning: async () => false }));
+ const section = {
+ data: {
+ saved: {
+ auth: savedAuth, clientRegistration: JSON.stringify({ client: 'saved' }), createdAt: '',
+ profile: JSON.stringify({ id: 'saved' }), updatedAt: '',
+ },
+ },
+ limits: {},
+ };
+ mock.module('./src/storage/vault.ts', () => ({
+ readVaultSection: async () => section,
+ updateVaultSection: async (_platform, operation) => operation(section).result,
+ }));
+ const { loadKiro } = await import('./src/kiro/service.ts');
+ const error = await loadKiro('saved').catch((value) => String(value));
+ finished = true;
+ await Bun.sleep(50);
+ console.log(JSON.stringify({ error, lateWrite }));
+ `;
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: {
+ ...process.env,
+ KIRO_AUTH_PATH: join(process.cwd(), 'unused-auth.json'),
+ KIRO_PROCESS_NAME: 'dondo-kiro-test-not-running',
+ KIRO_PROFILE_PATH: join(process.cwd(), 'unused-profile.json'),
+ },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ expect(JSON.parse(stdout)).toEqual({ error: 'Error: registration read failed', lateWrite: false });
+});
+
+it('should preserve Kiro auth when supporting-state cleanup fails', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-kiro-clear-failure-test-'));
+ const authPath = join(dir, 'kiro-auth-token.json');
+ const profilePath = dir;
+ const script = `
+ const authPath = process.env.KIRO_AUTH_PATH;
+ const { clearKiro } = await import('./src/kiro/service.ts');
+ let failed = false;
+ try { await clearKiro(); } catch { failed = true; }
+ console.log(JSON.stringify({
+ authExists: await Bun.file(authPath).exists(),
+ failed,
+ }));
+ `;
+ try {
+ await Bun.write(authPath, '{}');
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: {
+ ...process.env,
+ KIRO_AUTH_PATH: authPath,
+ KIRO_PROCESS_NAME: 'dondo-kiro-test-not-running',
+ KIRO_PROFILE_PATH: profilePath,
+ },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ expect(JSON.parse(stdout)).toEqual({ authExists: true, failed: true });
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should read live Kiro auth after an earlier queued load completes', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-kiro-state-load-race-test-'));
+ const authPath = join(dir, 'kiro-auth-token.json');
+ const server = Bun.serve({
+ fetch: async (request) => {
+ if (new URL(request.url).pathname === '/refreshToken') {
+ await Bun.sleep(100);
+ return Response.json({
+ accessToken: 'loaded-access',
+ expiresIn: 3600,
+ profileArn: 'arn:saved',
+ refreshToken: 'loaded-refresh',
+ });
+ }
+ return Response.json({
+ usageBreakdownList: [{ currentUsage: 1, resourceType: 'CREDIT', usageLimit: 10 }],
+ });
+ },
+ port: 0,
+ });
+ const script = `
+ const { kiroState, loadKiro, saveKiro } = await import('./src/kiro/service.ts');
+ const authPath = process.env.KIRO_AUTH_PATH;
+ await saveKiro('saved');
+ await Bun.write(authPath, JSON.stringify({
+ accessToken: 'other-access', authMethod: 'social', profileArn: 'arn:other', refreshToken: 'other-refresh',
+ }));
+ const load = loadKiro('saved');
+ await Bun.sleep(20);
+ const state = kiroState();
+ const [, resolvedState] = await Promise.all([load, state]);
+ const live = JSON.parse(await Bun.file(authPath).text());
+ console.log(JSON.stringify({
+ active: resolvedState.entries.find((entry) => entry.key === 'saved')?.active ?? false,
+ liveProfileArn: live.profileArn,
+ }));
+ `;
+ try {
+ await Bun.write(
+ authPath,
+ JSON.stringify({
+ accessToken: 'saved-access',
+ authMethod: 'social',
+ profileArn: 'arn:saved',
+ refreshToken: 'saved-refresh',
+ }),
+ );
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: {
+ ...process.env,
+ DONDO_VAULT: join(dir, 'vault.json'),
+ KIRO_AUTH_PATH: authPath,
+ KIRO_AUTH_REFRESH_URL: `http://127.0.0.1:${server.port}/refreshToken`,
+ KIRO_PROCESS_NAME: 'dondo-kiro-test-not-running',
+ KIRO_PROFILE_PATH: join(dir, 'profile.json'),
+ KIRO_USAGE_URL: `http://127.0.0.1:${server.port}/getUsageLimits`,
+ },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ expect(JSON.parse(stdout)).toEqual({ active: true, liveProfileArn: 'arn:saved' });
+ } finally {
+ server.stop(true);
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should not treat a shared Kiro profile ARN as account identity', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-kiro-active-identity-test-'));
+ const authPath = join(dir, 'kiro-auth-token.json');
+ const profilePath = join(dir, 'profile.json');
+ const script = `
+ const { mock } = await import('bun:test');
+ mock.module('./src/kiro/usage.ts', () => ({
+ fetchKiroLimits: async () => ({ expires: '', models: {}, ok: true, tier: '' }),
+ }));
+ const { kiroState, saveKiro } = await import('./src/kiro/service.ts');
+ const auth = (refreshToken) => JSON.stringify({
+ accessToken: refreshToken + '-access',
+ authMethod: 'idc',
+ profileArn: 'arn:shared-profile',
+ refreshToken,
+ });
+ await Bun.write(process.env.KIRO_AUTH_PATH, auth('first-refresh'));
+ await saveKiro('first');
+ await Bun.write(process.env.KIRO_AUTH_PATH, auth('second-refresh'));
+ await saveKiro('second');
+ await Bun.write(process.env.KIRO_AUTH_PATH, auth('first-refresh'));
+ const state = await kiroState();
+ console.log(JSON.stringify(state.entries.filter((entry) => entry.active).map((entry) => entry.key)));
+ `;
+ try {
+ await Bun.write(profilePath, JSON.stringify({ profile: 'shared' }));
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: {
+ ...process.env,
+ DONDO_VAULT: join(dir, 'vault.json'),
+ KIRO_AUTH_PATH: authPath,
+ KIRO_PROCESS_NAME: 'dondo-kiro-test-not-running',
+ KIRO_PROFILE_PATH: profilePath,
+ },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ expect(JSON.parse(stdout)).toEqual(['first']);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should refresh expired active Kiro usage and persist rotated credentials', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-kiro-active-refresh-test-'));
+ const authPath = join(dir, 'kiro-auth-token.json');
+ let refreshRequests = 0;
+ const server = Bun.serve({
+ fetch: async (request) => {
+ const url = new URL(request.url);
+ if (url.pathname === '/refreshToken') {
+ refreshRequests += 1;
+ return Response.json({
+ accessToken: 'refreshed-access',
+ expiresIn: 3600,
+ profileArn: 'arn:aws:codewhisperer:us-east-1:profile/test',
+ refreshToken: 'refreshed-refresh',
+ });
+ }
+ if (request.headers.get('authorization') === 'Bearer refreshed-access') {
+ return Response.json({
+ usageBreakdownList: [{ currentUsage: 1, resourceType: 'CREDIT', usageLimit: 10 }],
+ });
+ }
+ return new Response('', { status: 401 });
+ },
+ port: 0,
+ });
+ const script = `
+ const { kiroState, saveKiro } = await import('./src/kiro/service.ts');
+ const { readVaultSection } = await import('./src/storage/vault.ts');
+ const before = await Bun.file(process.env.KIRO_AUTH_PATH).text();
+ await saveKiro('saved');
+ const state = await kiroState();
+ const after = await Bun.file(process.env.KIRO_AUTH_PATH).text();
+ const entry = state.entries.find((candidate) => candidate.key === 'saved');
+ console.log(JSON.stringify({
+ active: entry?.active ?? false,
+ liveUnchanged: before === after,
+ savedRefresh: JSON.parse((await readVaultSection('kiro')).data.saved.auth).refreshToken,
+ quotaOk: entry?.quota?.ok ?? false,
+ }));
+ `;
+ try {
+ await Bun.write(
+ authPath,
+ JSON.stringify({
+ accessToken: 'expired-access',
+ authMethod: 'social',
+ expiresAt: '2000-01-01T00:00:00.000Z',
+ profileArn: 'arn:aws:codewhisperer:us-east-1:profile/test',
+ refreshToken: 'saved-refresh',
+ }),
+ );
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: {
+ ...process.env,
+ DONDO_VAULT: join(dir, 'vault.json'),
+ KIRO_AUTH_PATH: authPath,
+ KIRO_AUTH_REFRESH_URL: `http://127.0.0.1:${server.port}/refreshToken`,
+ KIRO_PROCESS_NAME: 'dondo-kiro-test-not-running',
+ KIRO_PROFILE_PATH: join(dir, 'profile.json'),
+ KIRO_USAGE_URL: `http://127.0.0.1:${server.port}/getUsageLimits`,
+ },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ expect(JSON.parse(stdout)).toEqual({
+ active: true,
+ liveUnchanged: true,
+ quotaOk: true,
+ savedRefresh: 'refreshed-refresh',
+ });
+ expect(refreshRequests).toBe(1);
+ } finally {
+ server.stop(true);
+ await rm(dir, { force: true, recursive: true });
+ }
+});
diff --git a/src/kiro/service.ts b/src/kiro/service.ts
index 9fadc59..9b15ca3 100644
--- a/src/kiro/service.ts
+++ b/src/kiro/service.ts
@@ -1,5 +1,14 @@
-import { rm } from 'node:fs/promises';
+import { randomUUID } from 'node:crypto';
+import { chmod, rename, rm } from 'node:fs/promises';
import { dirname, join } from 'node:path';
+import {
+ boundedMap,
+ CORRUPTED_ACCOUNT_ERROR,
+ selectRefreshEntries,
+ sortAccountEntries,
+ stateVersion,
+} from '../account-state.ts';
+import { createAsyncQueue, waitForAll } from '../async-queue.ts';
import {
KIRO_AUTH_PATH,
KIRO_AUTH_REFRESH_URL,
@@ -9,49 +18,37 @@ import {
VAULT_PATH,
} from '../config.ts';
import { assertAccountKey, cleanLimitError, publicError } from '../errors.ts';
-import { writePrivateFile } from '../storage/file.ts';
-import { readVault, updateVault } from '../storage/vault.ts';
-import type { AppVault, KiroSnapshot, LimitResult } from '../types.ts';
+import { discardResponse, readBoundedResponseJson } from '../http.ts';
+import { isProcessRunning } from '../process.ts';
+import { readBoundedLocalText, writePrivateFile } from '../storage/file.ts';
+import { readVaultSection, updateVaultSection } from '../storage/vault.ts';
+import type { KiroSnapshot, KiroVault, LimitResult } from '../types.ts';
+import { isKiroSnapshotConfigValid, type KiroAuth, parseKiroAuth, parseKiroJsonObject } from './auth.ts';
import { fetchKiroLimits } from './usage.ts';
-type KiroAuth = {
- accessToken?: string;
- authMethod?: string;
- clientIdHash?: string;
- expiresAt?: string;
- profileArn?: string;
- provider?: string;
- refreshToken: string;
+type KiroLimitUpdate = {
+ auth?: string;
+ key: string;
+ quota: LimitResult;
+ sourceLimitVersion: string;
+ sourceSnapshotVersion: string;
};
-type KiroRefreshResponse = {
- accessToken: string;
- expiresIn: number;
- profileArn?: string;
- refreshToken: string;
+type KiroSessionFiles = {
+ auth: string;
+ clientRegistration?: string;
+ profile?: string;
};
let activeKiroKey: string | undefined;
+const queueKiroMutation = createAsyncQueue();
-const isKiroRunning = async () => {
- if (process.platform === 'win32') {
- const proc = Bun.spawn(['tasklist', '/FI', `IMAGENAME eq ${KIRO_PROCESS_NAME}`, '/NH'], {
- stderr: 'ignore',
- stdout: 'pipe',
- });
- const output = await new Response(proc.stdout).text();
- return (await proc.exited) === 0 && output.toLowerCase().includes(KIRO_PROCESS_NAME.toLowerCase());
- }
-
- const proc = Bun.spawn(['pgrep', '-x', KIRO_PROCESS_NAME], {
- stderr: 'ignore',
- stdout: 'ignore',
- });
- return (await proc.exited) === 0;
+const isRecord = (value: unknown): value is Record => {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
};
const assertKiroClosed = async () => {
- if (await isKiroRunning()) {
+ if (await isProcessRunning(KIRO_PROCESS_NAME)) {
throw publicError(
409,
'Quit Kiro completely before clearing or loading an account. Kiro must be closed while Dondo replaces its local login files.',
@@ -59,32 +56,12 @@ const assertKiroClosed = async () => {
}
};
-const parseAuth = (auth: string): KiroAuth | null => {
- try {
- const value = JSON.parse(auth) as unknown;
- if (
- typeof value !== 'object' ||
- value === null ||
- Array.isArray(value) ||
- typeof (value as { refreshToken?: unknown }).refreshToken !== 'string' ||
- !(value as { refreshToken: string }).refreshToken
- ) {
- return null;
- }
- return value as KiroAuth;
- } catch {
- return null;
- }
-};
-
const liveAuth = async () => {
- const file = Bun.file(KIRO_AUTH_PATH);
- return (await file.exists()) ? await file.text() : '';
+ return (await readBoundedLocalText(KIRO_AUTH_PATH)) ?? '';
};
const optionalFile = async (path: string) => {
- const file = Bun.file(path);
- return (await file.exists()) ? await file.text() : undefined;
+ return (await readBoundedLocalText(path)) ?? undefined;
};
const clientRegistrationPath = (auth: KiroAuth | null) => {
@@ -94,17 +71,44 @@ const clientRegistrationPath = (auth: KiroAuth | null) => {
};
const clearLiveFiles = async () => {
- const auth = parseAuth(await liveAuth().catch(() => ''));
+ const auth = parseKiroAuth(await liveAuth().catch(() => ''));
const registrationPath = clientRegistrationPath(auth);
- const paths = [KIRO_AUTH_PATH, KIRO_PROFILE_PATH, ...(registrationPath ? [registrationPath] : [])];
- await Promise.all(paths.map((path) => rm(path, { force: true })));
+ const supportingPaths = [KIRO_PROFILE_PATH, ...(registrationPath ? [registrationPath] : [])];
+ await waitForAll(supportingPaths.map((path) => rm(path, { force: true })));
+ await rm(KIRO_AUTH_PATH, { force: true });
+};
+
+const authMatchScore = (a: KiroAuth | null, b: KiroAuth | null) => {
+ if (!a || !b) {
+ return 0;
+ }
+ if (a.accessToken && b.accessToken && a.accessToken === b.accessToken) {
+ return 2;
+ }
+ return a.refreshToken === b.refreshToken ? 1 : 0;
};
const isSameAuth = (a: KiroAuth | null, b: KiroAuth | null) => {
- return Boolean(a && b && a.refreshToken === b.refreshToken);
+ return authMatchScore(a, b) > 0;
+};
+
+const matchingKiroEntry = (section: KiroVault, auth: KiroAuth | null) => {
+ const entries = Object.entries(section.data).filter(([, snapshot]) => isKiroSnapshotConfigValid(snapshot));
+ const preferred = activeKiroKey ? entries.find(([key]) => key === activeKiroKey) : undefined;
+ const candidates = preferred ? [preferred, ...entries.filter(([key]) => key !== activeKiroKey)] : entries;
+ let best: [string, KiroSnapshot] | undefined;
+ let bestScore = 0;
+ for (const entry of candidates) {
+ const score = authMatchScore(auth, parseKiroAuth(entry[1].auth));
+ if (score > bestScore) {
+ best = entry;
+ bestScore = score;
+ }
+ }
+ return best;
};
-const refreshSocialAuth = async (auth: KiroAuth, key: string) => {
+const refreshSocialAuth = async (auth: KiroAuth, key: string): Promise => {
if (auth.authMethod !== 'social') {
return auth;
}
@@ -121,6 +125,7 @@ const refreshSocialAuth = async (auth: KiroAuth, key: string) => {
throw publicError(502, 'Could not reach Kiro to validate the saved session');
});
if (!response.ok) {
+ await discardResponse(response);
if (response.status === 400 || response.status === 401 || response.status === 403) {
throw publicError(
409,
@@ -132,260 +137,364 @@ const refreshSocialAuth = async (auth: KiroAuth, key: string) => {
let value: unknown;
try {
- value = await response.json();
+ value = await readBoundedResponseJson(response, 'Kiro session refresh');
} catch {
throw publicError(502, 'Kiro returned an invalid session refresh response');
}
- const refreshed = value as Partial;
+ if (!isRecord(value)) {
+ throw publicError(502, 'Kiro returned an incomplete session refresh response');
+ }
+ const refreshed = value;
if (
typeof refreshed.accessToken !== 'string' ||
- !refreshed.accessToken ||
+ !refreshed.accessToken.trim() ||
typeof refreshed.refreshToken !== 'string' ||
- !refreshed.refreshToken ||
+ !refreshed.refreshToken.trim() ||
typeof refreshed.expiresIn !== 'number' ||
!Number.isFinite(refreshed.expiresIn) ||
- refreshed.expiresIn <= 0
+ refreshed.expiresIn <= 0 ||
+ refreshed.expiresIn > 31_536_000 ||
+ (refreshed.profileArn !== undefined &&
+ (typeof refreshed.profileArn !== 'string' || !refreshed.profileArn.trim()))
) {
throw publicError(502, 'Kiro returned an incomplete session refresh response');
}
+ const profileArn = refreshed.profileArn ?? auth.profileArn;
return {
...auth,
accessToken: refreshed.accessToken,
expiresAt: new Date(Date.now() + refreshed.expiresIn * 1_000).toISOString(),
- profileArn: refreshed.profileArn ?? auth.profileArn,
+ ...(profileArn ? { profileArn } : {}),
refreshToken: refreshed.refreshToken,
};
};
const readValidLiveAuth = async () => {
- const authFile = Bun.file(KIRO_AUTH_PATH);
- if (!(await authFile.exists())) {
+ const auth = await readBoundedLocalText(KIRO_AUTH_PATH);
+ if (auth === null) {
throw publicError(404, 'No live Kiro session found. Launch Kiro, sign in, then use Save current.');
}
- const auth = await authFile.text();
if (!auth.trim()) {
throw publicError(400, `${KIRO_AUTH_PATH} is empty`);
}
- if (!parseAuth(auth)) {
+ if (!parseKiroAuth(auth)) {
throw publicError(400, `${KIRO_AUTH_PATH} is not valid Kiro auth JSON`);
}
return auth;
};
-const syncActiveKiro = async () => {
- if (!activeKiroKey) {
+const assertReadableAccount = (section: KiroVault, key: string) => {
+ if (section.corruptions?.[key]) {
+ throw publicError(409, CORRUPTED_ACCOUNT_ERROR);
+ }
+ const snapshot = section.data[key];
+ if (!snapshot) {
+ throw publicError(404, `No Kiro auth named ${key}`);
+ }
+ if (!isKiroSnapshotConfigValid(snapshot)) {
+ throw publicError(409, CORRUPTED_ACCOUNT_ERROR);
+ }
+ return snapshot;
+};
+
+const syncMatchingLiveKiro = async (providedAuthText?: string) => {
+ const authText = providedAuthText ?? (await liveAuth().catch(() => ''));
+ const auth = parseKiroAuth(authText);
+ if (!auth) {
+ activeKiroKey = undefined;
return;
}
- const auth = await liveAuth().catch(() => '');
- if (!parseAuth(auth)) {
+ const section = await readVaultSection('kiro');
+ const match = matchingKiroEntry(section, auth);
+ if (!match?.[1]) {
activeKiroKey = undefined;
return;
}
-
- const key = activeKiroKey;
- await updateVault(async (vault) => {
- const snap = vault.kiro.data[key];
- if (!snap || snap.auth === auth) {
+ const [key, source] = match;
+ await updateVaultSection('kiro', (current) => {
+ const snapshot = current.data[key];
+ if (
+ !snapshot ||
+ !isKiroSnapshotConfigValid(snapshot) ||
+ snapshot.auth !== source.auth ||
+ !isSameAuth(auth, parseKiroAuth(snapshot.auth))
+ ) {
+ return { result: undefined, write: false };
+ }
+ if (snapshot.auth === authText) {
return { result: undefined, write: false };
}
- snap.auth = auth;
- snap.updatedAt = new Date().toISOString();
+ snapshot.auth = authText;
+ snapshot.updatedAt = new Date().toISOString();
+ delete current.limits[key];
return { result: undefined };
});
-};
-
-type KiroLimitUpdate = {
- auth?: string;
- quota: LimitResult;
+ activeKiroKey = key;
};
const fetchKiroLimitUpdates = async (
- vault: AppVault,
+ section: KiroVault,
force: boolean,
targetKey: string | undefined,
activeAuth: KiroAuth | null,
) => {
- const updates = new Map();
- if (targetKey && !vault.kiro.data[targetKey]) {
- throw publicError(404, `No Kiro auth named ${targetKey}`);
+ if (targetKey) {
+ assertReadableAccount(section, targetKey);
}
-
- for (const [key, snap] of Object.entries(vault.kiro.data)) {
- if ((targetKey && key !== targetKey) || (!force && vault.kiro.limits[key])) {
- continue;
- }
- const auth = parseAuth(snap.auth);
- if (!auth) {
- updates.set(key, {
- quota: { error: 'Saved Kiro auth JSON is invalid', ok: false },
- });
- continue;
- }
+ const readableData = Object.fromEntries(
+ Object.entries(section.data).filter(([, snapshot]) => isKiroSnapshotConfigValid(snapshot)),
+ );
+ const selected = selectRefreshEntries(readableData, section.limits, targetKey ? { force, targetKey } : { force });
+ return boundedMap(selected, async ([key, snapshot]): Promise => {
+ const auth = parseKiroAuth(snapshot.auth) as KiroAuth;
let refreshedAuth: string | undefined;
+ let quota: LimitResult;
try {
- let quota = await fetchKiroLimits(auth);
- if (
- !quota.ok &&
- quota.error === 'Saved Kiro access token is expired or rejected' &&
- !isSameAuth(activeAuth, auth)
- ) {
+ quota = await fetchKiroLimits(auth);
+ if (!quota.ok && quota.error === 'Saved Kiro access token is expired or rejected') {
const refreshed = await refreshSocialAuth(auth, key);
- refreshedAuth = JSON.stringify(refreshed, null, 2);
+ if (isSameAuth(activeAuth, auth)) {
+ refreshedAuth = JSON.stringify(refreshed, null, 2);
+ }
quota = await fetchKiroLimits(refreshed);
}
- updates.set(key, { auth: refreshedAuth, quota });
} catch (error) {
- updates.set(key, { auth: refreshedAuth, quota: cleanLimitError(error) });
+ quota = cleanLimitError(error);
}
+ return {
+ ...(refreshedAuth ? { auth: refreshedAuth } : {}),
+ key,
+ quota,
+ sourceLimitVersion: stateVersion(section.limits[key] ?? null),
+ sourceSnapshotVersion: stateVersion(snapshot),
+ };
+ });
+};
+
+const stagedPath = (path: string) => `${path}.${process.pid}.${randomUUID()}.stage`;
+
+const rollbackLiveSession = async (originals: Map) => {
+ const rollback = await Promise.allSettled(
+ [...originals].map(([path, text]) =>
+ text === undefined ? rm(path, { force: true }) : writePrivateFile(path, text),
+ ),
+ );
+ if (rollback.some((result) => result.status === 'rejected')) {
+ throw publicError(500, 'Kiro session replacement failed and rollback was incomplete');
}
+};
+
+const commitLiveSession = async (session: KiroSessionFiles) => {
+ const currentAuth = parseKiroAuth(await liveAuth().catch(() => ''));
+ const oldRegistrationPath = clientRegistrationPath(currentAuth);
+ const newRegistrationPath = clientRegistrationPath(parseKiroAuth(session.auth));
+ const desired = new Map([
+ [KIRO_PROFILE_PATH, session.profile],
+ ...(newRegistrationPath ? [[newRegistrationPath, session.clientRegistration] as const] : []),
+ ...(oldRegistrationPath && oldRegistrationPath !== newRegistrationPath
+ ? [[oldRegistrationPath, undefined] as const]
+ : []),
+ ]);
+ const originals = new Map();
+ const staged = new Map();
+ const authStage = stagedPath(KIRO_AUTH_PATH);
+ let liveMutationStarted = false;
- return updates;
+ try {
+ originals.set(KIRO_AUTH_PATH, await optionalFile(KIRO_AUTH_PATH));
+ for (const [path, text] of desired) {
+ originals.set(path, await optionalFile(path));
+ if (text !== undefined) {
+ const stage = stagedPath(path);
+ staged.set(path, stage);
+ await writePrivateFile(stage, text);
+ }
+ }
+ await writePrivateFile(authStage, session.auth);
+ liveMutationStarted = true;
+ for (const [path, text] of desired) {
+ const stage = staged.get(path);
+ if (text === undefined || !stage) {
+ await rm(path, { force: true });
+ } else {
+ await rename(stage, path);
+ await chmod(path, 0o600);
+ }
+ }
+ await rename(authStage, KIRO_AUTH_PATH);
+ await chmod(KIRO_AUTH_PATH, 0o600);
+ } catch (error) {
+ if (!liveMutationStarted) {
+ throw error;
+ }
+ await rollbackLiveSession(originals);
+ throw error;
+ } finally {
+ await waitForAll([...staged.values(), authStage].map((path) => rm(path, { force: true }))).catch(() => {});
+ }
};
-export const saveKiro = async (key: string) => {
+const saveKiroMutation = async (key: string) => {
const safeKey = assertAccountKey(key);
const auth = await readValidLiveAuth();
- const registrationPath = clientRegistrationPath(parseAuth(auth));
+ const registrationPath = clientRegistrationPath(parseKiroAuth(auth));
const [profile, clientRegistration] = await Promise.all([
optionalFile(KIRO_PROFILE_PATH),
registrationPath ? optionalFile(registrationPath) : undefined,
]);
+ if (profile !== undefined && !parseKiroJsonObject(profile)) {
+ throw publicError(400, `${KIRO_PROFILE_PATH} is not a valid JSON object`);
+ }
+ if (clientRegistration !== undefined && !parseKiroJsonObject(clientRegistration)) {
+ throw publicError(400, `${registrationPath} is not a valid JSON object`);
+ }
- await updateVault(async (vault) => {
- const existing = vault.kiro.data[safeKey];
+ await updateVaultSection('kiro', (section) => {
+ const existing = section.data[safeKey];
+ if (section.corruptions?.[safeKey] || (existing && !isKiroSnapshotConfigValid(existing))) {
+ throw publicError(409, CORRUPTED_ACCOUNT_ERROR);
+ }
const now = new Date().toISOString();
- vault.kiro.data[safeKey] = {
+ section.data[safeKey] = {
auth,
- clientRegistration,
+ ...(clientRegistration ? { clientRegistration } : {}),
createdAt: existing?.createdAt ?? now,
- profile,
+ ...(profile ? { profile } : {}),
updatedAt: now,
};
- delete vault.kiro.limits[safeKey];
+ delete section.limits[safeKey];
return { result: undefined };
});
activeKiroKey = safeKey;
};
-export const loadKiro = async (key: string) => {
+export const saveKiro = async (key: string) => queueKiroMutation(() => saveKiroMutation(key));
+
+const loadKiroMutation = async (key: string) => {
const safeKey = assertAccountKey(key);
await assertKiroClosed();
- await syncActiveKiro();
- const session = await updateVault(async (vault) => {
- const snap = vault.kiro.data[safeKey];
- if (!snap) {
- throw publicError(404, `No Kiro auth named ${safeKey}`);
- }
- const parsed = parseAuth(snap.auth);
- if (!parsed) {
- throw publicError(500, `Saved Kiro auth named ${safeKey} is not valid Kiro auth JSON`);
+ await syncMatchingLiveKiro();
+ const section = await readVaultSection('kiro');
+ const source = assertReadableAccount(section, safeKey);
+ const parsed = parseKiroAuth(source.auth) as KiroAuth;
+ const refreshed = await refreshSocialAuth(parsed, safeKey);
+ const serialized = JSON.stringify(refreshed, null, 2);
+ const session = await updateVaultSection('kiro', (current) => {
+ const snapshot = assertReadableAccount(current, safeKey);
+ if (snapshot.auth !== source.auth || snapshot.updatedAt !== source.updatedAt) {
+ throw publicError(409, 'Saved Kiro account changed while it was being validated. Try loading it again.');
}
-
- const refreshed = await refreshSocialAuth(parsed, safeKey);
- const serialized = JSON.stringify(refreshed, null, 2);
- snap.auth = serialized;
- snap.updatedAt = new Date().toISOString();
+ snapshot.auth = serialized;
+ snapshot.updatedAt = new Date().toISOString();
+ delete current.limits[safeKey];
return {
result: {
auth: serialized,
- clientRegistration: snap.clientRegistration,
- profile: snap.profile,
+ ...(snapshot.clientRegistration ? { clientRegistration: snapshot.clientRegistration } : {}),
+ ...(snapshot.profile ? { profile: snapshot.profile } : {}),
},
};
});
- await clearLiveFiles();
- await writePrivateFile(KIRO_AUTH_PATH, session.auth);
- const registrationPath = clientRegistrationPath(parseAuth(session.auth));
- await Promise.all([
- session.profile ? writePrivateFile(KIRO_PROFILE_PATH, session.profile) : undefined,
- registrationPath && session.clientRegistration
- ? writePrivateFile(registrationPath, session.clientRegistration)
- : undefined,
- ]);
+ await commitLiveSession(session);
activeKiroKey = safeKey;
};
-export const clearKiro = async () => {
+export const loadKiro = async (key: string) => queueKiroMutation(() => loadKiroMutation(key));
+
+const clearKiroMutation = async () => {
await assertKiroClosed();
- activeKiroKey = undefined;
+ await syncMatchingLiveKiro();
await clearLiveFiles();
+ activeKiroKey = undefined;
};
-export const deleteKiro = async (key: string) => {
+export const clearKiro = async () => queueKiroMutation(clearKiroMutation);
+
+const deleteKiroMutation = async (key: string) => {
const safeKey = assertAccountKey(key);
if (activeKiroKey === safeKey) {
activeKiroKey = undefined;
}
- await updateVault(async (vault) => {
- if (!vault.kiro.data[safeKey]) {
+ await updateVaultSection('kiro', (section) => {
+ if (!section.data[safeKey] && !section.corruptions?.[safeKey]) {
throw publicError(404, `No Kiro auth named ${safeKey}`);
}
- delete vault.kiro.data[safeKey];
- delete vault.kiro.limits[safeKey];
+ delete section.data[safeKey];
+ delete section.limits[safeKey];
+ if (section.corruptions) {
+ delete section.corruptions[safeKey];
+ }
return { result: undefined };
});
};
+export const deleteKiro = async (key: string) => queueKiroMutation(() => deleteKiroMutation(key));
+
export const kiroState = async (options: { refreshLimitKey?: string; refreshLimits?: boolean } = {}) => {
- await syncActiveKiro();
- const activeAuth = parseAuth(await liveAuth().catch(() => ''));
- const refreshLimitKey = options.refreshLimitKey ? assertAccountKey(options.refreshLimitKey) : undefined;
- const snapshot = await readVault();
- const updates = await fetchKiroLimitUpdates(
- snapshot,
- options.refreshLimits === true,
- refreshLimitKey,
- activeAuth,
- );
- const vault = await updateVault(async (current) => {
- if (refreshLimitKey && !current.kiro.data[refreshLimitKey]) {
- throw publicError(404, `No Kiro auth named ${refreshLimitKey}`);
- }
- let changed = false;
- for (const [key, update] of updates) {
- const snap = current.kiro.data[key];
- if (!snap) {
- continue;
- }
- if (update.auth) {
- snap.auth = update.auth;
- snap.updatedAt = new Date().toISOString();
- }
- current.kiro.limits[key] = {
- fetchedAt: new Date().toISOString(),
- quota: update.quota,
- };
- changed = true;
- }
- return { result: current, write: changed };
+ const liveAuthText = await queueKiroMutation(async () => {
+ const current = await liveAuth().catch(() => '');
+ await syncMatchingLiveKiro(current);
+ return current;
});
- const matchingKey = Object.entries(vault.kiro.data).find(([, snap]) =>
- isSameAuth(activeAuth, parseAuth(snap.auth)),
- )?.[0];
- if (matchingKey) {
- activeKiroKey = matchingKey;
- } else if (!activeAuth) {
- activeKiroKey = undefined;
- }
+ const activeAuth = parseKiroAuth(liveAuthText);
+ const refreshLimitKey = options.refreshLimitKey ? assertAccountKey(options.refreshLimitKey) : undefined;
+ const snapshot = await readVaultSection('kiro');
+ const updates = await fetchKiroLimitUpdates(snapshot, options.refreshLimits === true, refreshLimitKey, activeAuth);
+ const section =
+ updates.length === 0
+ ? snapshot
+ : await updateVaultSection('kiro', (current) => {
+ let changed = false;
+ for (const update of updates) {
+ const saved = current.data[update.key];
+ if (
+ !saved ||
+ stateVersion(saved) !== update.sourceSnapshotVersion ||
+ stateVersion(current.limits[update.key] ?? null) !== update.sourceLimitVersion
+ ) {
+ continue;
+ }
+ if (update.auth && update.auth !== saved.auth) {
+ saved.auth = update.auth;
+ saved.updatedAt = new Date().toISOString();
+ }
+ current.limits[update.key] = { fetchedAt: new Date().toISOString(), quota: update.quota };
+ changed = true;
+ }
+ return { result: current, write: changed };
+ });
+ const preferredActive = activeKiroKey ? section.data[activeKiroKey] : undefined;
+ const matchingKey =
+ matchingKiroEntry(section, activeAuth)?.[0] ??
+ (activeKiroKey && preferredActive && isKiroSnapshotConfigValid(preferredActive) ? activeKiroKey : undefined);
+ activeKiroKey = matchingKey;
+ const healthyEntries = Object.entries(section.data)
+ .filter(([, saved]) => isKiroSnapshotConfigValid(saved))
+ .map(([key, saved]: [string, KiroSnapshot]) => ({
+ active: key === matchingKey,
+ key,
+ limitUpdatedAt: section.limits[key]?.fetchedAt ?? '',
+ quota: section.limits[key]?.quota ?? null,
+ updatedAt: saved.updatedAt,
+ }));
+ const semanticCorruptions = Object.entries(section.data)
+ .filter(([, saved]) => !isKiroSnapshotConfigValid(saved))
+ .map(([key]) => key);
+ const corruptedEntries = [...Object.keys(section.corruptions ?? {}), ...semanticCorruptions].map((key) => ({
+ active: false,
+ corrupted: true as const,
+ error: CORRUPTED_ACCOUNT_ERROR,
+ key,
+ limitUpdatedAt: '',
+ quota: null,
+ updatedAt: '',
+ }));
return {
authPath: KIRO_AUTH_PATH,
- entries: Object.entries(vault.kiro.data)
- .map(([key, snap]: [string, KiroSnapshot]) => ({
- active: isSameAuth(activeAuth, parseAuth(snap.auth)),
- key,
- limitUpdatedAt: vault.kiro.limits[key]?.fetchedAt ?? '',
- quota: vault.kiro.limits[key]?.quota ?? null,
- updatedAt: snap.updatedAt,
- }))
- .sort((a, b) => {
- if (a.active !== b.active) {
- return a.active ? -1 : 1;
- }
- return a.key.localeCompare(b.key);
- }),
+ entries: sortAccountEntries([...healthyEntries, ...corruptedEntries]),
vaultPath: VAULT_PATH,
};
};
diff --git a/src/kiro/usage.test.ts b/src/kiro/usage.test.ts
index d489ac9..010ab62 100644
--- a/src/kiro/usage.test.ts
+++ b/src/kiro/usage.test.ts
@@ -47,6 +47,17 @@ it('should omit Kiro usage entries without a positive limit', () => {
});
});
+it('should clamp negative Kiro usage values to zero', () => {
+ const result = usageToLimitResult({
+ usageBreakdownList: [{ currentUsage: -5, resourceType: 'CREDIT', usageLimit: 10 }],
+ });
+
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.models.credit).toMatchObject({ limit: 10, percentage: 100, used: 0 });
+ }
+});
+
it('should reject Kiro usage when no positive limit is available', () => {
expect(usageToLimitResult({})).toEqual({ error: 'Kiro usage returned no quota fields', ok: false });
expect(usageToLimitResult({ usageBreakdownList: [] })).toEqual({
@@ -54,3 +65,33 @@ it('should reject Kiro usage when no positive limit is available', () => {
ok: false,
});
});
+
+it('should omit invalid or oversized Kiro reset timestamps', () => {
+ const result = usageToLimitResult({
+ nextDateReset: Number.MAX_VALUE,
+ usageBreakdownList: [{ currentUsage: 1, resourceType: 'CREDIT', usageLimit: 10 }],
+ });
+
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.models.credit?.resetTime).toBe('');
+ }
+});
+
+it('should fail cleanly for hostile Kiro usage field types', () => {
+ expect(
+ usageToLimitResult({
+ nextDateReset: {},
+ subscriptionInfo: { subscriptionTitle: { label: 'poison' } },
+ usageBreakdownList: [
+ null,
+ {
+ currentUsage: 'two',
+ displayName: { label: 'Credits' },
+ resourceType: 123,
+ usageLimit: 'ten',
+ },
+ ],
+ }),
+ ).toEqual({ error: 'Kiro usage returned no quota fields', ok: false });
+});
diff --git a/src/kiro/usage.ts b/src/kiro/usage.ts
index 757c0f9..71d869d 100644
--- a/src/kiro/usage.ts
+++ b/src/kiro/usage.ts
@@ -1,4 +1,5 @@
import { KIRO_USAGE_URL, KIRO_USER_AGENT } from '../config.ts';
+import { discardResponse, readBoundedResponseJson } from '../http.ts';
import type { LimitResult, ModelLimit } from '../types.ts';
type KiroUsageAuth = {
@@ -6,35 +7,29 @@ type KiroUsageAuth = {
profileArn?: string;
};
-type UsageBreakdown = {
- currentUsage?: number;
- currentUsageWithPrecision?: number;
- displayName?: string;
- displayNamePlural?: string;
- nextDateReset?: number | string;
- resourceType?: string;
- usageLimit?: number;
- usageLimitWithPrecision?: number;
-};
-
-type KiroUsagePayload = {
- nextDateReset?: number | string;
- subscriptionInfo?: { subscriptionTitle?: string };
- usageBreakdownList?: UsageBreakdown[];
-};
-
const REQUEST_TIMEOUT_MS = 15_000;
const AWS_REGION_RE = /^[a-z]{2}(?:-[a-z]+)+-\d+$/u;
-const resetIso = (value: number | string | undefined) => {
- const timestamp =
- typeof value === 'number' ? value * 1_000 : typeof value === 'string' ? Date.parse(value) : Number.NaN;
- return Number.isFinite(timestamp) && timestamp > 0 ? new Date(timestamp).toISOString() : '';
+const isRecord = (value: unknown): value is Record => {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
};
-const usageValue = (value: UsageBreakdown, precise: keyof UsageBreakdown, fallback: keyof UsageBreakdown) => {
+const resetIso = (value: unknown) => {
+ const date =
+ typeof value === 'number'
+ ? Number.isFinite(value) && value > 0
+ ? new Date(value * 1_000)
+ : new Date(Number.NaN)
+ : typeof value === 'string' && value.trim()
+ ? new Date(value)
+ : new Date(Number.NaN);
+ const timestamp = date.getTime();
+ return Number.isFinite(timestamp) && timestamp > 0 ? date.toISOString() : '';
+};
+
+const usageValue = (value: Record, precise: string, fallback: string) => {
const result = value[precise] ?? value[fallback];
- return typeof result === 'number' && Number.isFinite(result) ? result : 0;
+ return typeof result === 'number' && Number.isFinite(result) ? Math.max(0, result) : 0;
};
const usageEndpoint = (profileArn: string) => {
@@ -46,22 +41,42 @@ const usageEndpoint = (profileArn: string) => {
return `https://management.${region}.kiro.dev/getUsageLimits`;
};
-export const usageToLimitResult = (payload: KiroUsagePayload): LimitResult => {
- const models: Record = {};
- for (const [index, breakdown] of (payload.usageBreakdownList ?? []).entries()) {
- const used = usageValue(breakdown, 'currentUsageWithPrecision', 'currentUsage');
- const limit = usageValue(breakdown, 'usageLimitWithPrecision', 'usageLimit');
- if (limit <= 0) {
- continue;
- }
- const key = breakdown.resourceType?.toLowerCase() || `kiro-${index + 1}`;
- models[key] = {
- displayName: breakdown.displayNamePlural ?? breakdown.displayName ?? breakdown.resourceType ?? 'Usage',
+const breakdownModel = (value: unknown, index: number, defaultReset: unknown): [string, ModelLimit] | null => {
+ if (!isRecord(value)) {
+ return null;
+ }
+ const used = usageValue(value, 'currentUsageWithPrecision', 'currentUsage');
+ const limit = usageValue(value, 'usageLimitWithPrecision', 'usageLimit');
+ if (limit <= 0) {
+ return null;
+ }
+ const resourceType = typeof value.resourceType === 'string' ? value.resourceType : '';
+ const displayName =
+ (typeof value.displayNamePlural === 'string' && value.displayNamePlural) ||
+ (typeof value.displayName === 'string' && value.displayName) ||
+ resourceType ||
+ 'Usage';
+ return [
+ resourceType.toLowerCase() || `kiro-${index + 1}`,
+ {
+ displayName,
limit,
percentage: Math.max(0, Math.min(100, Math.round((1 - used / limit) * 100))),
- resetTime: resetIso(breakdown.nextDateReset ?? payload.nextDateReset),
+ resetTime: resetIso(value.nextDateReset ?? defaultReset),
used,
- };
+ },
+ ];
+};
+
+export const usageToLimitResult = (payload: unknown): LimitResult => {
+ const record = isRecord(payload) ? payload : {};
+ const rawBreakdowns = Array.isArray(record.usageBreakdownList) ? record.usageBreakdownList : [];
+ const models: Record = {};
+ for (const [index, rawBreakdown] of rawBreakdowns.entries()) {
+ const entry = breakdownModel(rawBreakdown, index, record.nextDateReset);
+ if (entry) {
+ models[entry[0]] = entry[1];
+ }
}
if (Object.keys(models).length === 0) {
@@ -69,10 +84,13 @@ export const usageToLimitResult = (payload: KiroUsagePayload): LimitResult => {
}
return {
- expires: resetIso(payload.nextDateReset),
+ expires: resetIso(record.nextDateReset),
models,
ok: true,
- tier: payload.subscriptionInfo?.subscriptionTitle ?? 'Kiro',
+ tier:
+ isRecord(record.subscriptionInfo) && typeof record.subscriptionInfo.subscriptionTitle === 'string'
+ ? record.subscriptionInfo.subscriptionTitle
+ : 'Kiro',
};
};
@@ -93,10 +111,11 @@ export const fetchKiroLimits = async (auth: KiroUsageAuth): Promise
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
+ await discardResponse(response);
if (response.status === 401 || response.status === 403) {
return { error: 'Saved Kiro access token is expired or rejected', ok: false };
}
throw new Error(`Kiro usage request failed with HTTP ${response.status}`);
}
- return usageToLimitResult((await response.json()) as KiroUsagePayload);
+ return usageToLimitResult(await readBoundedResponseJson(response, 'Kiro usage'));
};
diff --git a/src/minimax/service.test.ts b/src/minimax/service.test.ts
index e11e2b1..405a95a 100644
--- a/src/minimax/service.test.ts
+++ b/src/minimax/service.test.ts
@@ -2,29 +2,59 @@ import { expect, it } from 'bun:test';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
+import type { MinimaxVault } from '../types.ts';
+import { invalidateMiniMaxIdentityLimits } from './service.ts';
+
+const tokenFor = (identity: string, suffix: string) =>
+ `header.${Buffer.from(JSON.stringify({ user: { id: identity } })).toString('base64url')}.${suffix}`;
+
+it('should invalidate limits for every saved MiniMax label with the claimed identity', () => {
+ const snapshot = (identity: string, suffix: string) => ({
+ config: JSON.stringify({ tokens: { accessToken: tokenFor(identity, suffix) } }),
+ createdAt: 'created',
+ updatedAt: 'updated',
+ });
+ const quota = { fetchedAt: 'now', quota: { error: 'stale', ok: false as const } };
+ const section: MinimaxVault = {
+ data: {
+ first: snapshot('shared', 'first'),
+ other: snapshot('other', 'other'),
+ second: snapshot('shared', 'second'),
+ },
+ limits: { first: quota, other: quota, second: quota },
+ };
+
+ expect(invalidateMiniMaxIdentityLimits(section, 'shared')).toBe(true);
+ expect(Object.keys(section.limits)).toEqual(['other']);
+});
const runMiniMaxScript = async (env: Record) => {
const script = `
- const { loadMinimax, minimaxState, saveMinimax } = await import('./src/minimax/service.ts');
+ const { checkInMinimax, loadMinimax, minimaxState, saveMinimax } = await import('./src/minimax/service.ts');
const configPath = process.env.MINIMAX_CONFIG_PATH;
const vaultPath = process.env.DONDO_VAULT;
await saveMinimax('saved');
const savedToken = 'header.' + Buffer.from(JSON.stringify({ user: { id: 'saved-user' } })).toString('base64url') + '.saved';
const liveToken = 'header.' + Buffer.from(JSON.stringify({ user: { id: 'saved-user' } })).toString('base64url') + '.live';
- await Bun.write(configPath, JSON.stringify({ user: { userID: 'other' }, tokens: { accessToken: liveToken } }));
+ await Bun.write(configPath, JSON.stringify({ tokens: { accessToken: liveToken } }));
const before = await minimaxState();
await loadMinimax('saved');
const after = await minimaxState();
+ const checkIn = await checkInMinimax();
const loadedConfig = JSON.parse(await Bun.file(configPath).text());
+ const loadedIdentity = JSON.parse(Buffer.from(loadedConfig.tokens.accessToken.split('.')[1], 'base64url').toString()).user.id;
const vaultText = await Bun.file(vaultPath).text();
console.log(JSON.stringify({
activeBeforeLoad: before.entries[0]?.active ?? null,
activeAfterLoad: after.entries[0]?.active ?? null,
- loadedUserID: loadedConfig.user?.userID ?? '',
+ loadedIdentity,
quotaOk: after.entries[0]?.quota?.ok ?? null,
limitUpdatedAt: after.entries[0]?.limitUpdatedAt ?? '',
fiveHourRemaining: after.entries[0]?.quota?.ok ? after.entries[0].quota.models['minimax-5-hour']?.percentage ?? null : null,
weeklyRemaining: after.entries[0]?.quota?.ok ? after.entries[0].quota.models['minimax-weekly']?.percentage ?? null : null,
+ creditDetail: after.entries[0]?.quota?.ok ? after.entries[0].quota.models['minimax-credits']?.detail ?? null : null,
+ checkInClaimed: checkIn.claimed,
+ checkInPoints: checkIn.points,
vaultHasPlainToken: vaultText.includes(savedToken),
}));
`;
@@ -43,10 +73,13 @@ const runMiniMaxScript = async (env: Record) => {
activeAfterLoad: boolean;
activeBeforeLoad: boolean;
limitUpdatedAt: string;
- loadedUserID: string;
+ loadedIdentity: string;
quotaOk: boolean;
fiveHourRemaining: number | null;
weeklyRemaining: number | null;
+ creditDetail: string | null;
+ checkInClaimed: boolean;
+ checkInPoints: number;
vaultHasPlainToken: boolean;
};
};
@@ -67,6 +100,32 @@ it('should save and load MiniMax configs with MiniMax Code quota limits', async
workspaces: [{ has_token_plan: true, opcredit_balance: 0, selected: true }],
});
}
+ if (pathname.endsWith('/matrix/api/v1/commerce/get_membership_info')) {
+ return Response.json({
+ base_resp: { status_code: 0, status_msg: 'success' },
+ op_credit_summary: { total_remaining_amount: '312.106' },
+ });
+ }
+ if (pathname.endsWith('/minimax-cloud/api/v1/signin/status')) {
+ return Response.json({
+ base_resp: { status_code: 0, status_msg: 'ok' },
+ data: {
+ days: [{ day_no: 1, is_today: true, points: 400, status: 2 }],
+ scene: 2,
+ },
+ });
+ }
+ if (pathname.endsWith('/minimax-cloud/api/v1/signin/claim')) {
+ return Response.json({
+ base_resp: { status_code: 0, status_msg: 'ok' },
+ data: {
+ claim_result: 1,
+ day_no: 1,
+ panel: { days: [{ day_no: 1, is_today: true, points: 400, status: 3 }], scene: 2 },
+ points: 400,
+ },
+ });
+ }
if (pathname.endsWith('/v1/api/openplatform/coding_plan/remains')) {
return Response.json({
model_remains: [
@@ -91,30 +150,179 @@ it('should save and load MiniMax configs with MiniMax Code quota limits', async
JSON.stringify({
tokens: {
accessToken:
- 'header.' + Buffer.from(JSON.stringify({ user: { id: 'saved-user' } })).toString('base64url') + '.saved',
+ 'header.' +
+ Buffer.from(JSON.stringify({ user: { id: 'saved-user' } })).toString('base64url') +
+ '.saved',
},
- user: { userID: 'saved-user' },
}),
);
const result = await runMiniMaxScript({
DONDO_VAULT: vaultPath,
- MINIMAX_CONFIG_PATH: configPath,
MINIMAX_AGENT_URL: `http://127.0.0.1:${server.port}`,
- MINIMAX_UUID: '00000000-0000-4000-8000-000000000000',
+ MINIMAX_CONFIG_PATH: configPath,
MINIMAX_PLATFORM_URL: `http://127.0.0.1:${server.port}`,
+ MINIMAX_UUID: '00000000-0000-4000-8000-000000000000',
});
expect(result.activeBeforeLoad).toBe(true);
expect(result.activeAfterLoad).toBe(true);
- expect(result.loadedUserID).toBe('saved-user');
+ expect(result.loadedIdentity).toBe('saved-user');
expect(result.quotaOk).toBe(true);
expect(result.limitUpdatedAt).toBeTruthy();
expect(result.fiveHourRemaining).toBe(48);
expect(result.weeklyRemaining).toBe(75);
+ expect(result.creditDetail).toBe('Credit: 312');
+ expect(result.checkInClaimed).toBe(true);
+ expect(result.checkInPoints).toBe(400);
expect(result.vaultHasPlainToken).toBe(false);
} finally {
server.stop(true);
await rm(dir, { force: true, recursive: true });
}
});
+
+it('should reject invalid MiniMax configs on save and again before load', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-minimax-validation-test-'));
+ const configPath = join(dir, 'minimax-agent-config.json');
+ const vaultPath = join(dir, 'vault.json');
+ const script = `
+ const { deleteMinimax, loadMinimax, minimaxState, saveMinimax } = await import('./src/minimax/service.ts');
+ const { updateVaultSection } = await import('./src/storage/vault.ts');
+ const configPath = process.env.MINIMAX_CONFIG_PATH;
+ const valid = JSON.stringify({
+ tokens: {
+ accessToken: 'header.' + Buffer.from(JSON.stringify({ user: { id: 'valid-user' } })).toString('base64url') + '.sig',
+ },
+ });
+ await Bun.write(configPath, '{');
+ const saveError = await saveMinimax('invalid').catch((error) => String(error));
+ await Bun.write(configPath, valid);
+ await saveMinimax('saved');
+ await updateVaultSection('minimax', (section) => {
+ section.data.saved.config = '{';
+ return { result: undefined };
+ });
+ const replacementSaveError = await saveMinimax('saved').catch((error) => String(error));
+ const state = await minimaxState({ refreshLimits: true });
+ const refreshError = await minimaxState({ refreshLimitKey: 'saved' }).catch((error) => String(error));
+ const loadError = await loadMinimax('saved').catch((error) => String(error));
+ await deleteMinimax('saved');
+ console.log(JSON.stringify({
+ corrupted: state.entries[0]?.corrupted ?? false,
+ deleted: (await minimaxState()).entries.length === 0,
+ liveUnchanged: await Bun.file(configPath).text() === valid,
+ loadError,
+ refreshError,
+ replacementSaveError,
+ saveError,
+ }));
+ `;
+ try {
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: { ...process.env, DONDO_VAULT: vaultPath, MINIMAX_CONFIG_PATH: configPath },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ const result = JSON.parse(stdout) as {
+ corrupted: boolean;
+ deleted: boolean;
+ liveUnchanged: boolean;
+ loadError: string;
+ refreshError: string;
+ replacementSaveError: string;
+ saveError: string;
+ };
+ expect(result.saveError).toContain('not valid MiniMax config JSON');
+ expect(result.loadError).toContain('Saved account data is corrupted');
+ expect(result.refreshError).toContain('Saved account data is corrupted');
+ expect(result.replacementSaveError).toContain('Saved account data is corrupted');
+ expect(result.corrupted).toBe(true);
+ expect(result.deleted).toBe(true);
+ expect(result.liveUnchanged).toBe(true);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should not attach a stale MiniMax refresh to a replacement account', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-minimax-stale-refresh-test-'));
+ const configPath = join(dir, 'minimax-agent-config.json');
+ const vaultPath = join(dir, 'vault.json');
+ const script = `
+ let markStarted;
+ let release;
+ const started = new Promise((resolve) => { markStarted = resolve; });
+ const gate = new Promise((resolve) => { release = resolve; });
+ let firstIdentity = true;
+ const server = Bun.serve({
+ port: 0,
+ async fetch(request) {
+ const path = new URL(request.url).pathname;
+ if (path.endsWith('/v1/api/user/info')) {
+ if (firstIdentity) {
+ firstIdentity = false;
+ markStarted();
+ await gate;
+ }
+ return Response.json({ data: { userInfo: { realUserID: 'old-user' } } });
+ }
+ if (path.endsWith('/matrix/api/v1/user/get_user_extra_info')) {
+ return Response.json({ workspaces: [{ has_token_plan: false, selected: true }] });
+ }
+ if (path.endsWith('/matrix/api/v1/commerce/get_membership_info')) {
+ return Response.json({ op_credit_summary: { total_remaining_amount: 10 } });
+ }
+ return new Response('', { status: 404 });
+ },
+ });
+ process.env.MINIMAX_AGENT_URL = 'http://127.0.0.1:' + server.port;
+ process.env.MINIMAX_PLATFORM_URL = 'http://127.0.0.1:' + server.port;
+ process.env.MINIMAX_UUID = '00000000-0000-4000-8000-000000000000';
+ const { minimaxState, saveMinimax } = await import('./src/minimax/service.ts');
+ const token = (id, suffix) => 'header.' + Buffer.from(JSON.stringify({ user: { id } })).toString('base64url') + '.' + suffix;
+ try {
+ await Bun.write(process.env.MINIMAX_CONFIG_PATH, JSON.stringify({ tokens: { accessToken: token('old-user', 'old') } }));
+ await saveMinimax('saved');
+ const refresh = minimaxState({ refreshLimits: true });
+ await started;
+ await Bun.write(process.env.MINIMAX_CONFIG_PATH, JSON.stringify({ tokens: { accessToken: token('new-user', 'new') } }));
+ await saveMinimax('saved');
+ release();
+ const state = await refresh;
+ const saved = state.entries.find((entry) => entry.key === 'saved');
+ console.log(JSON.stringify({ limitUpdatedAt: saved?.limitUpdatedAt ?? '', quota: saved?.quota ?? null }));
+ } finally {
+ release();
+ server.stop(true);
+ }
+ `;
+ try {
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: { ...process.env, DONDO_VAULT: vaultPath, MINIMAX_CONFIG_PATH: configPath },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ expect(JSON.parse(stdout)).toEqual({ limitUpdatedAt: '', quota: null });
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
diff --git a/src/minimax/service.ts b/src/minimax/service.ts
index e49e5d6..70bf2e2 100644
--- a/src/minimax/service.ts
+++ b/src/minimax/service.ts
@@ -1,187 +1,230 @@
+import {
+ boundedMap,
+ CORRUPTED_ACCOUNT_ERROR,
+ selectRefreshEntries,
+ sortAccountEntries,
+ stateVersion,
+} from '../account-state.ts';
import { MINIMAX_CONFIG_PATH, VAULT_PATH } from '../config.ts';
import { assertAccountKey, cleanLimitError, publicError } from '../errors.ts';
-import { writePrivateFile } from '../storage/file.ts';
-import { readVault, updateVault } from '../storage/vault.ts';
-import type { AppVault, LimitCache, MinimaxSnapshot } from '../types.ts';
-import { fetchMiniMaxLimits, miniMaxTokenIdentity, type MiniMaxConfig } from './usage.ts';
-
-type StoredMinimaxConfig = MiniMaxConfig & {
- user?: {
- email?: string;
- realUserID?: string;
- userID?: string;
- userMail?: string;
- userName?: string;
- username?: string;
- };
+import { readBoundedLocalText, writePrivateFile } from '../storage/file.ts';
+import { readVaultSection, updateVaultSection } from '../storage/vault.ts';
+import type { LimitResult, MinimaxSnapshot, MinimaxVault } from '../types.ts';
+import {
+ checkInMiniMax,
+ fetchMiniMaxLimits,
+ type MiniMaxConfig,
+ miniMaxTokenIdentity,
+ parseMiniMaxConfig,
+} from './usage.ts';
+
+type MiniMaxLimitUpdate = {
+ key: string;
+ quota: LimitResult;
+ sourceLimitVersion: string;
+ sourceSnapshotVersion: string;
};
const liveConfig = async () => {
- const file = Bun.file(MINIMAX_CONFIG_PATH);
- return (await file.exists()) ? await file.text() : '';
+ return (await readBoundedLocalText(MINIMAX_CONFIG_PATH)) ?? '';
};
-const parseConfig = (config: string): StoredMinimaxConfig => {
- try {
- return JSON.parse(config) as StoredMinimaxConfig;
- } catch {
- return {};
- }
+const parseConfig = (config: string) => {
+ return parseMiniMaxConfig(config);
};
-const stringValue = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
-
-const identity = (config: StoredMinimaxConfig) => {
- return (
- stringValue(config.tokens?.accessToken ? miniMaxTokenIdentity(config.tokens.accessToken) : '') ??
- stringValue(config.user?.realUserID) ??
- stringValue(config.user?.userID) ??
- stringValue(config.user?.userMail) ??
- stringValue(config.user?.email) ??
- stringValue(config.user?.username) ??
- stringValue(config.user?.userName) ??
- ''
- );
-};
+const identity = (config: MiniMaxConfig | null) => (config ? miniMaxTokenIdentity(config.tokens.accessToken) : '');
-const isSameConfig = (a: StoredMinimaxConfig, b: StoredMinimaxConfig) => {
+const isSameConfig = (a: MiniMaxConfig | null, b: MiniMaxConfig | null) => {
const aIdentity = identity(a);
const bIdentity = identity(b);
return Boolean(aIdentity && bIdentity && aIdentity === bIdentity);
};
-const hasLegacyPlaceholderLimit = (vault: AppVault, key: string) => {
- const quota = vault.minimax.limits[key]?.quota;
- return quota?.ok && 'minimax-loaded-at' in quota.models;
+export const invalidateMiniMaxIdentityLimits = (section: MinimaxVault, tokenIdentity: string) => {
+ let changed = false;
+ for (const [key, snapshot] of Object.entries(section.data)) {
+ if (identity(parseConfig(snapshot.config)) !== tokenIdentity || !section.limits[key]) {
+ continue;
+ }
+ delete section.limits[key];
+ changed = true;
+ }
+ return changed;
};
-const hasMislabelledFreeQuotaLimit = (vault: AppVault, key: string) => {
- const quota = vault.minimax.limits[key]?.quota;
- const detail = quota?.ok ? quota.models['minimax-free-daily']?.detail : undefined;
- return detail?.includes('free daily credit') ?? false;
-};
+const isReadableSnapshot = (snapshot: MinimaxSnapshot) => parseConfig(snapshot.config) !== null;
-const fetchMiniMaxLimitUpdates = async (vault: AppVault, force: boolean, targetKey?: string) => {
- const updates = new Map();
- if (targetKey && !vault.minimax.data[targetKey]) {
- throw publicError(404, `No MiniMax config named ${targetKey}`);
+const assertReadableAccount = (section: MinimaxVault, key: string) => {
+ if (section.corruptions?.[key]) {
+ throw publicError(409, CORRUPTED_ACCOUNT_ERROR);
}
- for (const [key, snap] of Object.entries(vault.minimax.data)) {
- if (
- (targetKey && key !== targetKey) ||
- (!force &&
- vault.minimax.limits[key] &&
- !hasLegacyPlaceholderLimit(vault, key) &&
- !hasMislabelledFreeQuotaLimit(vault, key))
- ) {
- continue;
- }
- try {
- updates.set(key, {
- fetchedAt: new Date().toISOString(),
- quota: await fetchMiniMaxLimits(parseConfig(snap.config)),
- });
- } catch (error) {
- updates.set(key, { fetchedAt: new Date().toISOString(), quota: cleanLimitError(error) });
- }
+ const snapshot = section.data[key];
+ if (!snapshot) {
+ throw publicError(404, `No MiniMax config named ${key}`);
}
+ if (!isReadableSnapshot(snapshot)) {
+ throw publicError(409, CORRUPTED_ACCOUNT_ERROR);
+ }
+ return snapshot;
+};
- return updates;
+const fetchMiniMaxLimitUpdates = async (section: MinimaxVault, force: boolean, targetKey?: string) => {
+ if (targetKey) {
+ assertReadableAccount(section, targetKey);
+ }
+ const readableData = Object.fromEntries(
+ Object.entries(section.data).filter(([, snapshot]) => isReadableSnapshot(snapshot)),
+ );
+ const selected = selectRefreshEntries(readableData, section.limits, targetKey ? { force, targetKey } : { force });
+ return boundedMap(selected, async ([key, snapshot]): Promise => {
+ const parsed = parseConfig(snapshot.config) as MiniMaxConfig;
+ const quota = await fetchMiniMaxLimits(parsed).catch((error) => cleanLimitError(error));
+ return {
+ key,
+ quota,
+ sourceLimitVersion: stateVersion(section.limits[key] ?? null),
+ sourceSnapshotVersion: stateVersion(snapshot),
+ };
+ });
};
export const saveMinimax = async (key: string) => {
const safeKey = assertAccountKey(key);
- const configFile = Bun.file(MINIMAX_CONFIG_PATH);
- if (!(await configFile.exists())) {
+ const config = await readBoundedLocalText(MINIMAX_CONFIG_PATH);
+ if (config === null) {
throw publicError(404, `${MINIMAX_CONFIG_PATH} does not exist`);
}
- const config = await configFile.text();
if (!config.trim()) {
throw publicError(400, `${MINIMAX_CONFIG_PATH} is empty`);
}
- try {
- JSON.parse(config);
- } catch {
- throw publicError(400, `${MINIMAX_CONFIG_PATH} is not valid JSON`);
+ if (!parseConfig(config)) {
+ throw publicError(400, `${MINIMAX_CONFIG_PATH} is not valid MiniMax config JSON`);
}
- await updateVault(async (vault) => {
- const existing = vault.minimax.data[safeKey];
+ await updateVaultSection('minimax', (section) => {
+ const existing = section.data[safeKey];
+ if (section.corruptions?.[safeKey] || (existing && !isReadableSnapshot(existing))) {
+ throw publicError(409, CORRUPTED_ACCOUNT_ERROR);
+ }
const now = new Date().toISOString();
- vault.minimax.data[safeKey] = {
+ section.data[safeKey] = {
config,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
- delete vault.minimax.limits[safeKey];
+ delete section.limits[safeKey];
return { result: undefined };
});
};
export const loadMinimax = async (key: string) => {
const safeKey = assertAccountKey(key);
- const snap = (await readVault()).minimax.data[safeKey];
- if (!snap) {
- throw publicError(404, `No MiniMax config named ${safeKey}`);
- }
-
- await writePrivateFile(MINIMAX_CONFIG_PATH, snap.config);
- await updateVault(async (vault) => {
- delete vault.minimax.limits[safeKey];
+ const snapshot = assertReadableAccount(await readVaultSection('minimax'), safeKey);
+ await writePrivateFile(MINIMAX_CONFIG_PATH, snapshot.config);
+ await updateVaultSection('minimax', (section) => {
+ const current = section.data[safeKey];
+ if (!current || current.config !== snapshot.config || current.updatedAt !== snapshot.updatedAt) {
+ return { result: undefined, write: false };
+ }
+ delete section.limits[safeKey];
return { result: undefined };
});
};
export const deleteMinimax = async (key: string) => {
const safeKey = assertAccountKey(key);
- await updateVault(async (vault) => {
- if (!vault.minimax.data[safeKey]) {
+ await updateVaultSection('minimax', (section) => {
+ if (!section.data[safeKey] && !section.corruptions?.[safeKey]) {
throw publicError(404, `No MiniMax config named ${safeKey}`);
}
- delete vault.minimax.data[safeKey];
- delete vault.minimax.limits[safeKey];
+ delete section.data[safeKey];
+ delete section.limits[safeKey];
+ if (section.corruptions) {
+ delete section.corruptions[safeKey];
+ }
return { result: undefined };
});
};
+export const checkInMinimax = async (key?: string) => {
+ const safeKey = key ? assertAccountKey(key) : undefined;
+ const section = await readVaultSection('minimax');
+ let configText: string;
+
+ if (safeKey) {
+ configText = assertReadableAccount(section, safeKey).config;
+ } else {
+ configText = await liveConfig();
+ }
+
+ const config = parseConfig(configText);
+ if (!config) {
+ throw publicError(404, 'No valid live MiniMax session found. Sign into MiniMax, then save the account.');
+ }
+ const result = await checkInMiniMax(config);
+ const tokenIdentity = identity(config);
+ if (result.claimed && tokenIdentity) {
+ await updateVaultSection('minimax', (current) => {
+ return { result: undefined, write: invalidateMiniMaxIdentityLimits(current, tokenIdentity) };
+ });
+ }
+ return result;
+};
+
export const minimaxState = async (options: { refreshLimitKey?: string; refreshLimits?: boolean } = {}) => {
const refreshLimitKey = options.refreshLimitKey ? assertAccountKey(options.refreshLimitKey) : undefined;
- const snapshot = await readVault();
+ const snapshot = await readVaultSection('minimax');
const updates = await fetchMiniMaxLimitUpdates(snapshot, options.refreshLimits === true, refreshLimitKey);
- const vault = await updateVault(async (current) => {
- if (refreshLimitKey && !current.minimax.data[refreshLimitKey]) {
- throw publicError(404, `No MiniMax config named ${refreshLimitKey}`);
- }
- let changed = false;
- for (const [key, update] of updates) {
- if (!current.minimax.data[key]) {
- continue;
- }
- current.minimax.limits[key] = update;
- changed = true;
- }
- return { result: current, write: changed };
- });
+ const section =
+ updates.length === 0
+ ? snapshot
+ : await updateVaultSection('minimax', (current) => {
+ let changed = false;
+ for (const update of updates) {
+ const saved = current.data[update.key];
+ if (
+ !saved ||
+ stateVersion(saved) !== update.sourceSnapshotVersion ||
+ stateVersion(current.limits[update.key] ?? null) !== update.sourceLimitVersion
+ ) {
+ continue;
+ }
+ current.limits[update.key] = { fetchedAt: new Date().toISOString(), quota: update.quota };
+ changed = true;
+ }
+ return { result: current, write: changed };
+ });
const activeConfig = parseConfig(await liveConfig().catch(() => ''));
+ const parsedEntries = Object.entries(section.data).map(
+ ([key, saved]) => [key, saved, parseConfig(saved.config)] as const,
+ );
+ const healthyEntries = parsedEntries
+ .filter(([, , config]) => config !== null)
+ .map(([key, saved, config]) => {
+ const cached = section.limits[key];
+ return {
+ active: isSameConfig(activeConfig, config),
+ key,
+ limitUpdatedAt: cached?.fetchedAt ?? '',
+ quota: cached?.quota ?? null,
+ updatedAt: saved.updatedAt,
+ };
+ });
+ const semanticCorruptions = parsedEntries.filter(([, , config]) => config === null).map(([key]) => key);
+ const corruptedEntries = [...Object.keys(section.corruptions ?? {}), ...semanticCorruptions].map((key) => ({
+ active: false,
+ corrupted: true as const,
+ error: CORRUPTED_ACCOUNT_ERROR,
+ key,
+ limitUpdatedAt: '',
+ quota: null,
+ updatedAt: '',
+ }));
+
return {
configPath: MINIMAX_CONFIG_PATH,
- entries: Object.entries(vault.minimax.data)
- .map(([key, snap]: [string, MinimaxSnapshot]) => {
- const cached = vault.minimax.limits[key];
- return {
- active: isSameConfig(activeConfig, parseConfig(snap.config)),
- key,
- limitUpdatedAt: cached?.fetchedAt ?? '',
- quota: cached?.quota ?? null,
- updatedAt: snap.updatedAt,
- };
- })
- .sort((a, b) => {
- if (a.active !== b.active) {
- return a.active ? -1 : 1;
- }
- return a.key.localeCompare(b.key);
- }),
+ entries: sortAccountEntries([...healthyEntries, ...corruptedEntries]),
vaultPath: VAULT_PATH,
};
};
diff --git a/src/minimax/usage.test.ts b/src/minimax/usage.test.ts
index 2eaa39e..a382e7f 100644
--- a/src/minimax/usage.test.ts
+++ b/src/minimax/usage.test.ts
@@ -1,21 +1,36 @@
-import { expect, it } from 'bun:test';
-import { usageToLimitResult, workspaceToLimitResult } from './usage.ts';
+import { afterEach, expect, it } from 'bun:test';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import {
+ checkInMiniMax,
+ fetchMiniMaxLimits,
+ parseMiniMaxConfig,
+ scanMiniMaxUniqueUserId,
+ usageToLimitResult,
+ workspaceToLimitResult,
+} from './usage.ts';
+
+const originalFetch = globalThis.fetch;
+const accessToken = `header.${Buffer.from(JSON.stringify({ user: { id: 'account-id' } })).toString('base64url')}.signature`;
+
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+});
it('maps MiniMax Code 5-hour and weekly quota fields', () => {
- const result = usageToLimitResult(
- {
- model_remains: [
- {
- current_interval_remaining_percent: 48.4,
- current_weekly_remaining_percent: 75,
- end_time: 1_800_000_000,
- interval_boost_permille: 500,
- weekly_boost_permille: 1_000,
- weekly_end_time: 1_800_400_000,
- },
- ],
- },
- );
+ const result = usageToLimitResult({
+ model_remains: [
+ {
+ current_interval_remaining_percent: 48.4,
+ current_weekly_remaining_percent: 75,
+ end_time: 1_800_000_000,
+ interval_boost_permille: 500,
+ weekly_boost_permille: 1_000,
+ weekly_end_time: 1_800_400_000,
+ },
+ ],
+ });
expect(result).toEqual({
expires: '2027-01-19T23:06:40.000Z',
@@ -79,7 +94,6 @@ it('treats MiniMax non-plan access as valid without inventing a numeric quota',
status_code: 2062,
status_msg: 'no active token plan subscription',
},
- model_remains: undefined,
});
expect(result).toEqual({
@@ -97,7 +111,7 @@ it('treats MiniMax non-plan access as valid without inventing a numeric quota',
});
});
-it('does not mistake the commerce credit balance for a free daily quota', () => {
+it('maps the commerce credit balance as a numeric credit limit', () => {
expect(
workspaceToLimitResult({
creditBalance: 0,
@@ -106,9 +120,9 @@ it('does not mistake the commerce credit balance for a free daily quota', () =>
).toEqual({
expires: '',
models: {
- 'minimax-free-daily': {
- detail: 'Token valid · MiniMax does not report a free daily quota',
- displayName: 'Free daily quota',
+ 'minimax-credits': {
+ detail: 'Credit: 0',
+ displayName: 'Credits',
percentage: 100,
resetTime: '',
},
@@ -117,3 +131,446 @@ it('does not mistake the commerce credit balance for a free daily quota', () =>
tier: 'MiniMax Code · free access',
});
});
+
+it('rejects invalid or incomplete MiniMax config JSON', () => {
+ expect(parseMiniMaxConfig('{')).toBeNull();
+ expect(parseMiniMaxConfig('{}')).toBeNull();
+ expect(parseMiniMaxConfig(JSON.stringify({ tokens: { accessToken: 'opaque' } }))).toBeNull();
+ expect(parseMiniMaxConfig(JSON.stringify({ tokens: { accessToken: `${accessToken}.extra` } }))).toBeNull();
+ expect(
+ parseMiniMaxConfig(
+ JSON.stringify({
+ tokens: { accessToken: `header.${Buffer.from([0xc3, 0x28]).toString('base64url')}.sig` },
+ }),
+ ),
+ ).toBeNull();
+ for (const payload of [null, false, 1, 'text', [], { user: null }]) {
+ const malformedToken = `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.signature`;
+ expect(parseMiniMaxConfig(JSON.stringify({ tokens: { accessToken: malformedToken } }))).toBeNull();
+ }
+ expect(parseMiniMaxConfig(JSON.stringify({ tokens: { accessToken, refreshToken: 123 } }))).toBeNull();
+ expect(parseMiniMaxConfig(JSON.stringify({ tokens: { accessToken, refreshToken: 'legacy' } }))).toBeNull();
+ expect(parseMiniMaxConfig(JSON.stringify({ tokens: { accessToken } }))).not.toBeNull();
+});
+
+it('omits non-finite MiniMax quota values', () => {
+ const result = usageToLimitResult({
+ model_remains: [
+ {
+ current_interval_remaining_percent: Number.POSITIVE_INFINITY,
+ current_weekly_remaining_percent: 55,
+ interval_boost_permille: Number.NaN,
+ weekly_boost_permille: Number.POSITIVE_INFINITY,
+ },
+ ],
+ });
+
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.models['minimax-5-hour']).toBeUndefined();
+ expect(result.models['minimax-weekly']).toEqual({
+ displayName: 'Weekly quota',
+ percentage: 55,
+ resetTime: '',
+ });
+ }
+});
+
+it('omits invented MiniMax usage totals when boost data is absent', () => {
+ const result = usageToLimitResult({
+ model_remains: [{ current_interval_remaining_percent: 40 }],
+ });
+
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.models['minimax-5-hour']).toEqual({
+ displayName: '5-hour quota',
+ percentage: 40,
+ resetTime: '',
+ });
+ }
+});
+
+it('normalizes hostile MiniMax provider status messages', () => {
+ expect(usageToLimitResult({ base_resp: { status_code: 1, status_msg: { secret: true } } })).toEqual({
+ error: 'MiniMax usage request was rejected',
+ ok: false,
+ });
+});
+
+it('does not expose hostile MiniMax check-in status messages', async () => {
+ globalThis.fetch = (async (input: string | URL | Request) => {
+ const path = new URL(String(input)).pathname;
+ if (path.endsWith('/v1/api/user/info')) {
+ return Response.json({ data: { userInfo: { realUserID: 'real-user' } } });
+ }
+ return Response.json({
+ base_resp: { status_code: 9, status_msg: 'token=secret-provider-value' },
+ });
+ }) as typeof fetch;
+
+ const error = await checkInMiniMax({ tokens: { accessToken } }).catch((value) => String(value));
+ expect(error).toContain('MiniMax check-in status request was rejected');
+ expect(error).not.toContain('secret-provider-value');
+});
+
+it('requires a non-empty string MiniMax agent identity', async () => {
+ let requests = 0;
+ globalThis.fetch = (async () => {
+ requests += 1;
+ return Response.json({ data: { userInfo: { realUserID: { secret: true } } } });
+ }) as unknown as typeof fetch;
+
+ expect(await fetchMiniMaxLimits({ tokens: { accessToken } })).toEqual({
+ error: 'Saved MiniMax access token has no readable user identity',
+ ok: false,
+ });
+ expect(requests).toBe(1);
+});
+
+it('fetches MiniMax plan membership and usage concurrently', async () => {
+ const started = new Set();
+ const signedTimes: Array<[string, string]> = [];
+ let releaseRequests = () => {};
+ let markConcurrent = () => {};
+ const gate = new Promise((resolve) => {
+ releaseRequests = resolve;
+ });
+ const concurrent = new Promise((resolve) => {
+ markConcurrent = resolve;
+ });
+ const waitForPeer = async (name: string, payload: unknown) => {
+ started.add(name);
+ if (started.size === 2) {
+ markConcurrent();
+ }
+ await gate;
+ return Response.json(payload);
+ };
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
+ const url = new URL(String(input));
+ const path = url.pathname;
+ if (path.endsWith('/matrix/api/v1/commerce/get_membership_info')) {
+ const headers = new Headers(init?.headers);
+ signedTimes.push([url.searchParams.get('unix') ?? '', headers.get('x-timestamp') ?? '']);
+ }
+ if (path.endsWith('/v1/api/user/info')) {
+ return Response.json({ data: { userInfo: { realUserID: 'real-user' } } });
+ }
+ if (path.endsWith('/matrix/api/v1/user/get_user_extra_info')) {
+ return Response.json({ workspaces: [{ has_token_plan: true, selected: true }] });
+ }
+ if (path.endsWith('/matrix/api/v1/commerce/get_membership_info')) {
+ return waitForPeer('membership', { op_credit_summary: { total_remaining_amount: 10 } });
+ }
+ if (path.endsWith('/v1/api/openplatform/coding_plan/remains')) {
+ return waitForPeer('usage', {
+ model_remains: [{ current_interval_remaining_percent: 50, interval_boost_permille: 100 }],
+ });
+ }
+ return new Response('', { status: 404 });
+ }) as typeof fetch;
+
+ const resultPromise = fetchMiniMaxLimits({ tokens: { accessToken } });
+ try {
+ await Promise.race([
+ concurrent,
+ Bun.sleep(250).then(() => {
+ throw new Error('MiniMax plan requests did not start concurrently');
+ }),
+ ]);
+ expect([...started].sort()).toEqual(['membership', 'usage']);
+ } finally {
+ releaseRequests();
+ }
+ expect((await resultPromise).ok).toBe(true);
+ expect(signedTimes.length).toBe(1);
+ expect(signedTimes.every(([unix, header]) => unix && unix === header)).toBe(true);
+});
+
+it('does not claim when MiniMax check-in status is already claimed', async () => {
+ const calls: string[] = [];
+ globalThis.fetch = (async (input: string | URL | Request) => {
+ const path = new URL(String(input)).pathname;
+ calls.push(path);
+ if (path.endsWith('/v1/api/user/info')) {
+ return Response.json({ data: { userInfo: { realUserID: 'real-user' } } });
+ }
+ if (path.endsWith('/signin/status')) {
+ return Response.json({ data: { days: [{ day_no: 2, is_today: true, points: 100, status: 3 }], scene: 2 } });
+ }
+ return new Response('', { status: 500 });
+ }) as typeof fetch;
+
+ const result = await checkInMiniMax({ tokens: { accessToken } });
+
+ expect(result).toMatchObject({ alreadyClaimed: true, claimed: false, status: 'claimed' });
+ expect(calls.some((path) => path.endsWith('/signin/claim'))).toBe(false);
+});
+
+it('maps an idempotent MiniMax claim response as already claimed', async () => {
+ globalThis.fetch = (async (input: string | URL | Request) => {
+ const path = new URL(String(input)).pathname;
+ if (path.endsWith('/v1/api/user/info')) {
+ return Response.json({ data: { userInfo: { realUserID: 'real-user' } } });
+ }
+ if (path.endsWith('/signin/status')) {
+ return Response.json({ data: { days: [{ day_no: 2, is_today: true, points: 100, status: 2 }], scene: 2 } });
+ }
+ if (path.endsWith('/signin/claim')) {
+ return Response.json({ data: { claim_result: 2, day_no: 2, points: 100 } });
+ }
+ return new Response('', { status: 500 });
+ }) as typeof fetch;
+
+ expect(await checkInMiniMax({ tokens: { accessToken } })).toMatchObject({
+ alreadyClaimed: true,
+ claimed: false,
+ status: 'claimed',
+ });
+});
+
+it('deduplicates concurrent MiniMax check-ins for the same token identity', async () => {
+ const calls = { claim: 0, identity: 0, status: 0 };
+ globalThis.fetch = (async (input: string | URL | Request) => {
+ const path = new URL(String(input)).pathname;
+ if (path.endsWith('/v1/api/user/info')) {
+ calls.identity += 1;
+ return Response.json({ data: { userInfo: { realUserID: 'real-user' } } });
+ }
+ if (path.endsWith('/signin/status')) {
+ calls.status += 1;
+ return Response.json({ data: { days: [{ day_no: 2, is_today: true, points: 100, status: 2 }], scene: 2 } });
+ }
+ if (path.endsWith('/signin/claim')) {
+ calls.claim += 1;
+ return Response.json({ data: { claim_result: 1, day_no: 2, points: 100 } });
+ }
+ return new Response('', { status: 500 });
+ }) as typeof fetch;
+
+ const results = await Promise.all([
+ checkInMiniMax({ tokens: { accessToken } }),
+ checkInMiniMax({ tokens: { accessToken } }),
+ checkInMiniMax({ tokens: { accessToken } }),
+ ]);
+
+ expect(results.every((result) => result.claimed)).toBe(true);
+ expect(calls).toEqual({ claim: 1, identity: 1, status: 1 });
+});
+
+it('cancels a MiniMax LevelDB stream after finding the UUID across chunks', async () => {
+ const uuid = '12345678-1234-4234-8234-123456789abc';
+ let cancelled = false;
+ const stream = new ReadableStream({
+ cancel: () => {
+ cancelled = true;
+ },
+ start: (controller) => {
+ controller.enqueue(Buffer.from('prefix UNI'));
+ controller.enqueue(Buffer.from(`QUE payload ${uuid} trailing`));
+ },
+ });
+
+ expect(await scanMiniMaxUniqueUserId(stream)).toBe(uuid);
+ expect(cancelled).toBe(true);
+});
+
+it('cancels a MiniMax LevelDB stream at its per-file scan ceiling', async () => {
+ let cancelled = false;
+ const uuid = '12345678-1234-4234-8234-123456789abc';
+ const stream = new ReadableStream({
+ cancel: () => {
+ cancelled = true;
+ },
+ start: (controller) => {
+ controller.enqueue(Buffer.from('1234'));
+ controller.enqueue(Buffer.from(`UNIQUE ${uuid}`));
+ },
+ });
+
+ expect(await scanMiniMaxUniqueUserId(stream, 5)).toBe('');
+ expect(cancelled).toBe(true);
+ await expect(scanMiniMaxUniqueUserId(new ReadableStream(), -1)).rejects.toThrow(
+ 'MiniMax LevelDB scan byte limit must be a non-negative safe integer',
+ );
+});
+
+it('coalesces MiniMax UUID scans and caches empty discovery until explicitly cleared', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-minimax-uuid-test-'));
+ const storagePath = join(dir, 'leveldb');
+ const script = `
+ const { mkdir, rm } = await import('node:fs/promises');
+ const { join } = await import('node:path');
+ const storagePath = process.env.MINIMAX_LOCAL_STORAGE_PATH;
+ await mkdir(storagePath, { recursive: true });
+ const usage = await import('./src/minimax/usage.ts');
+ const first = await usage.miniMaxUniqueUserId();
+ const target = join(storagePath, '000001.ldb');
+ const uuid = '12345678-1234-4234-8234-123456789abc';
+ await Bun.write(target, 'prefix UNIQUE value ' + uuid + ' suffix');
+ const originalFile = Bun.file;
+ let streams = 0;
+ Bun.file = ((input, options) => {
+ const file = options === undefined ? originalFile(input) : originalFile(input, options);
+ if (String(input) !== target) return file;
+ return new Proxy(file, {
+ get(current, property) {
+ if (property === 'stream') return () => { streams += 1; return current.stream(); };
+ const value = Reflect.get(current, property, current);
+ return typeof value === 'function' ? value.bind(current) : value;
+ },
+ });
+ });
+ const values = await Promise.all([
+ usage.miniMaxUniqueUserId(),
+ usage.miniMaxUniqueUserId(),
+ usage.miniMaxUniqueUserId(),
+ ]);
+ usage.clearMiniMaxUniqueUserIdCache();
+ const discovered = await Promise.all([
+ usage.miniMaxUniqueUserId(),
+ usage.miniMaxUniqueUserId(),
+ usage.miniMaxUniqueUserId(),
+ ]);
+ await rm(target, { force: true });
+ const cached = await usage.miniMaxUniqueUserId();
+ console.log(JSON.stringify({ cached, discovered, first, streams, values }));
+ `;
+ try {
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: { ...process.env, MINIMAX_LOCAL_STORAGE_PATH: storagePath, MINIMAX_UUID: '' },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ expect(JSON.parse(stdout)).toEqual({
+ cached: '12345678-1234-4234-8234-123456789abc',
+ discovered: Array(3).fill('12345678-1234-4234-8234-123456789abc'),
+ first: '',
+ streams: 1,
+ values: Array(3).fill(''),
+ });
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('bounds MiniMax UUID discovery to the newest 64 LevelDB files', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-minimax-uuid-bound-test-'));
+ const storagePath = join(dir, 'leveldb');
+ const script = `
+ const { mkdir } = await import('node:fs/promises');
+ const { join } = await import('node:path');
+ const storagePath = process.env.MINIMAX_LOCAL_STORAGE_PATH;
+ await mkdir(storagePath, { recursive: true });
+ for (let index = 0; index < 70; index += 1) {
+ const content = index === 0
+ ? 'UNIQUE 12345678-1234-4234-8234-123456789abc'
+ : 'no identity';
+ await Bun.write(join(storagePath, String(index).padStart(6, '0') + '.ldb'), content);
+ }
+ const { miniMaxUniqueUserId } = await import('./src/minimax/usage.ts');
+ console.log(JSON.stringify({ value: await miniMaxUniqueUserId() }));
+ `;
+ try {
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: { ...process.env, MINIMAX_LOCAL_STORAGE_PATH: storagePath, MINIMAX_UUID: '' },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ expect(JSON.parse(stdout)).toEqual({ value: '' });
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('bounds total MiniMax UUID discovery I/O across LevelDB files', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-minimax-uuid-total-bound-test-'));
+ const storagePath = join(dir, 'leveldb');
+ const script = `
+ const { mkdir, truncate } = await import('node:fs/promises');
+ const { join } = await import('node:path');
+ const storagePath = process.env.MINIMAX_LOCAL_STORAGE_PATH;
+ await mkdir(storagePath, { recursive: true });
+ const uuid = '12345678-1234-4234-8234-123456789abc';
+ for (let index = 1; index <= 5; index += 1) {
+ const path = join(storagePath, String(index).padStart(6, '0') + '.ldb');
+ await Bun.write(path, index === 1 ? 'UNIQUE ' + uuid : '');
+ await truncate(path, 16 * 1024 * 1024);
+ }
+ const { miniMaxUniqueUserId } = await import('./src/minimax/usage.ts');
+ console.log(JSON.stringify({ value: await miniMaxUniqueUserId() }));
+ `;
+ try {
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: { ...process.env, MINIMAX_LOCAL_STORAGE_PATH: storagePath, MINIMAX_UUID: '' },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ expect(JSON.parse(stdout)).toEqual({ value: '' });
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('continues MiniMax UUID discovery after an unreadable newer LevelDB entry', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-minimax-uuid-unreadable-test-'));
+ const storagePath = join(dir, 'leveldb');
+ const script = `
+ const { mkdir } = await import('node:fs/promises');
+ const { join } = await import('node:path');
+ const storagePath = process.env.MINIMAX_LOCAL_STORAGE_PATH;
+ await mkdir(storagePath, { recursive: true });
+ const uuid = '12345678-1234-4234-8234-123456789abc';
+ await Bun.write(join(storagePath, '000001.ldb'), 'UNIQUE ' + uuid);
+ await mkdir(join(storagePath, '000002.ldb'));
+ const { miniMaxUniqueUserId } = await import('./src/minimax/usage.ts');
+ console.log(JSON.stringify({ value: await miniMaxUniqueUserId() }));
+ `;
+ try {
+ const proc = Bun.spawn([process.execPath, '--eval', script], {
+ cwd: process.cwd(),
+ env: { ...process.env, MINIMAX_LOCAL_STORAGE_PATH: storagePath, MINIMAX_UUID: '' },
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const [exitCode, stdout, stderr] = await Promise.all([
+ proc.exited,
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(stderr);
+ }
+ expect(JSON.parse(stdout)).toEqual({ value: '12345678-1234-4234-8234-123456789abc' });
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
diff --git a/src/minimax/usage.ts b/src/minimax/usage.ts
index 49cbe81..63b43f8 100644
--- a/src/minimax/usage.ts
+++ b/src/minimax/usage.ts
@@ -3,27 +3,13 @@ import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { MINIMAX_AGENT_URL, MINIMAX_LOCAL_STORAGE_PATH, MINIMAX_PLATFORM_URL, MINIMAX_UUID } from '../config.ts';
+import { discardResponse, readBoundedResponseJson } from '../http.ts';
+import { decodeJwtPayload } from '../jwt.ts';
import type { LimitResult, ModelLimit } from '../types.ts';
export type MiniMaxConfig = {
- tokens?: { accessToken?: string };
-};
-
-type ModelRemains = {
- current_interval_remaining_percent?: number;
- current_interval_status?: number;
- current_weekly_remaining_percent?: number;
- current_weekly_status?: number;
- end_time?: number | string;
- interval_boost_permille?: number;
- model_name?: string;
- weekly_boost_permille?: number;
- weekly_end_time?: number | string;
-};
-
-type UsagePayload = {
- base_resp?: { status_code?: number; status_msg?: string };
- model_remains?: ModelRemains[];
+ [key: string]: unknown;
+ tokens: { accessToken: string };
};
type Workspace = {
@@ -35,29 +21,63 @@ type Workspace = {
};
type WorkspacePayload = {
- base_resp?: { status_code?: number; status_msg?: string };
+ base_resp?: { status_code?: number };
workspaces?: Workspace[];
};
-type UserInfoPayload = {
- data?: {
- userInfo?: {
- realUserID?: string;
- };
+type MembershipPayload = {
+ base_resp?: { status_code?: number };
+ op_credit_summary?: {
+ total_remaining_amount?: number | string;
};
};
type WorkspaceState = {
- creditBalance: number;
hasTokenPlan: boolean;
};
+type WorkspaceCreditState = WorkspaceState & {
+ creditBalance: number;
+};
+
+export type MiniMaxCheckInDay = {
+ dayNo: number;
+ isToday: boolean;
+ points: number;
+ status: number;
+};
+
+export type MiniMaxCheckInPanel = {
+ days: MiniMaxCheckInDay[];
+ scene: number;
+};
+
+export type MiniMaxCheckInResult = {
+ alreadyClaimed: boolean;
+ claimed: boolean;
+ dayNo: number;
+ panel: MiniMaxCheckInPanel;
+ points: number;
+ status: 'claimed' | 'claimable' | 'disabled' | 'upcoming';
+};
+
const REQUEST_TIMEOUT_MS = 15_000;
const REMAINS_PATH = '/v1/api/openplatform/coding_plan/remains';
const NO_TOKEN_PLAN_STATUS = 2062;
const USER_INFO_PATH = '/v1/api/user/info';
const USER_EXTRA_INFO_PATH = '/matrix/api/v1/user/get_user_extra_info';
+const MEMBERSHIP_PATH = '/matrix/api/v1/commerce/get_membership_info';
+const SIGN_IN_STATUS_PATH = '/minimax-cloud/api/v1/signin/status';
+const SIGN_IN_CLAIM_PATH = '/minimax-cloud/api/v1/signin/claim';
const SIGNATURE_SECRET = 'I*7Cf%WZ#S&%1RlZJ&C2';
+const LEVELDB_SCAN_TAIL_BYTES = 320;
+const MAX_LEVELDB_FILE_SCAN_BYTES = 16 * 1024 * 1024;
+const MAX_LEVELDB_SCAN_FILES = 64;
+const MAX_LEVELDB_TOTAL_SCAN_BYTES = 64 * 1024 * 1024;
+const UNIQUE_ID_PATTERN = /UNIQUE[\s\S]{0,220}?([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/iu;
+
+let uniqueUserIdPromise: Promise | undefined;
+const checkInPromises = new Map>();
const finiteNumber = (value: unknown) => {
if (typeof value === 'number' && Number.isFinite(value)) {
@@ -70,37 +90,118 @@ const finiteNumber = (value: unknown) => {
return undefined;
};
-const decodeTokenPayload = (accessToken: string) => {
+export const miniMaxTokenIdentity = (accessToken: string) => {
+ const user = decodeJwtPayload(accessToken)?.user;
+ const identity = isRecord(user) ? user.id : undefined;
+ return typeof identity === 'string' && identity ? identity : '';
+};
+
+export const parseMiniMaxConfig = (text: string): MiniMaxConfig | null => {
+ let value: unknown;
try {
- const encodedPayload = accessToken.split('.')[1];
- if (!encodedPayload) {
- return {} as { user?: { id?: unknown } };
- }
- return JSON.parse(Buffer.from(encodedPayload, 'base64url').toString()) as { user?: { id?: unknown } };
+ value = JSON.parse(text);
} catch {
- return {} as { user?: { id?: unknown } };
+ return null;
+ }
+ if (!isRecord(value) || !isRecord(value.tokens)) {
+ return null;
}
+ for (const [key, tokenValue] of Object.entries(value.tokens)) {
+ if (key !== 'accessToken' || typeof tokenValue !== 'string') {
+ return null;
+ }
+ }
+ const accessToken = value.tokens.accessToken;
+ if (typeof accessToken !== 'string' || !accessToken.trim() || !miniMaxTokenIdentity(accessToken)) {
+ return null;
+ }
+ return value as MiniMaxConfig;
};
-export const miniMaxTokenIdentity = (accessToken: string) => {
- const identity = decodeTokenPayload(accessToken).user?.id;
- return typeof identity === 'string' && identity ? identity : '';
+const md5 = (value: string) => createHash('md5').update(value).digest('hex');
+
+const isRecord = (value: unknown): value is Record => {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
};
-const md5 = (value: string) => createHash('md5').update(value).digest('hex');
+type LevelDbScanResult = {
+ bytesRead: number;
+ userId: string;
+};
+
+const scanMiniMaxUniqueUserIdWithUsage = async (
+ stream: ReadableStream,
+ maxBytes = MAX_LEVELDB_FILE_SCAN_BYTES,
+): Promise => {
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
+ throw new TypeError('MiniMax LevelDB scan byte limit must be a non-negative safe integer');
+ }
+ const reader = stream.getReader();
+ let tail = '';
+ let bytesRead = 0;
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ return { bytesRead, userId: '' };
+ }
+ const remaining = maxBytes - bytesRead;
+ if (remaining <= 0) {
+ await reader.cancel();
+ return { bytesRead, userId: '' };
+ }
+ const boundedValue = value.subarray(0, remaining);
+ const content = `${tail}${Buffer.from(boundedValue).toString('latin1')}`;
+ bytesRead += boundedValue.byteLength;
+ const candidate = content.match(UNIQUE_ID_PATTERN)?.[1];
+ if (candidate) {
+ await reader.cancel();
+ return { bytesRead, userId: candidate };
+ }
+ tail = content.slice(-LEVELDB_SCAN_TAIL_BYTES);
+ if (boundedValue.byteLength < value.byteLength || bytesRead >= maxBytes) {
+ await reader.cancel();
+ return { bytesRead, userId: '' };
+ }
+ }
+ } finally {
+ reader.releaseLock();
+ }
+};
-const uniqueUserId = async () => {
+export const scanMiniMaxUniqueUserId = async (
+ stream: ReadableStream,
+ maxBytes = MAX_LEVELDB_FILE_SCAN_BYTES,
+) => {
+ return (await scanMiniMaxUniqueUserIdWithUsage(stream, maxBytes)).userId;
+};
+
+const uniqueUserIdInFile = async (path: string, maxBytes: number) => {
+ return scanMiniMaxUniqueUserIdWithUsage(Bun.file(path).stream(), maxBytes);
+};
+
+const discoverUniqueUserId = async () => {
if (MINIMAX_UUID) {
return MINIMAX_UUID;
}
try {
const names = (await readdir(MINIMAX_LOCAL_STORAGE_PATH)).filter((name) => /\.(ldb|log)$/u.test(name));
- for (const name of names.sort().reverse()) {
- const text = Buffer.from(await Bun.file(join(MINIMAX_LOCAL_STORAGE_PATH, name)).arrayBuffer()).toString('latin1');
- const marker = text.indexOf('UNIQUE');
- const candidate = marker >= 0 ? text.slice(marker, marker + 220).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/iu)?.[0] : undefined;
- if (candidate) {
- return candidate;
+ let remainingBytes = MAX_LEVELDB_TOTAL_SCAN_BYTES;
+ for (const name of names.sort().reverse().slice(0, MAX_LEVELDB_SCAN_FILES)) {
+ const maxBytes = Math.min(MAX_LEVELDB_FILE_SCAN_BYTES, remainingBytes);
+ if (maxBytes <= 0) {
+ break;
+ }
+ let result: LevelDbScanResult;
+ try {
+ result = await uniqueUserIdInFile(join(MINIMAX_LOCAL_STORAGE_PATH, name), maxBytes);
+ } catch {
+ remainingBytes -= maxBytes;
+ continue;
+ }
+ remainingBytes -= result.bytesRead;
+ if (result.userId) {
+ return result.userId;
}
}
} catch {
@@ -109,27 +210,46 @@ const uniqueUserId = async () => {
return '';
};
+export const clearMiniMaxUniqueUserIdCache = () => {
+ uniqueUserIdPromise = undefined;
+};
+
+export const miniMaxUniqueUserId = async () => {
+ const discovery = uniqueUserIdPromise ?? discoverUniqueUserId();
+ uniqueUserIdPromise = discovery;
+ try {
+ return await discovery;
+ } catch (error) {
+ if (uniqueUserIdPromise === discovery) {
+ uniqueUserIdPromise = undefined;
+ }
+ throw error;
+ }
+};
+
const signedAgentRequest = async (
accessToken: string,
path: string,
method: 'GET' | 'POST',
userId: string,
): Promise => {
- const uuid = await uniqueUserId();
- if (!userId || !uuid) {
+ if (!userId) {
return null;
}
- const unix = Math.floor(Date.now() / 1_000);
+ const timestamp = Math.floor(Date.now() / 1_000) * 1_000;
+ const unix = Math.floor(timestamp / 1_000);
+ const uuid = (await miniMaxUniqueUserId()) || '0';
const params = new URLSearchParams({
app_id: '3001',
biz_id: '3',
browser_name: 'Chrome',
device_id: '12345678',
device_platform: 'web',
+ is_desktop: '1',
lang: 'en',
os_name: 'macOS',
sys_language: 'en',
- timezone_offset: String(-new Date().getTimezoneOffset()),
+ timezone_offset: String(-60 * new Date().getTimezoneOffset()),
token: accessToken,
unix: String(unix),
user_id: userId,
@@ -137,14 +257,15 @@ const signedAgentRequest = async (
version_code: '22201',
});
const requestPath = `${path}?${params}`;
- const body = '{}';
+ const body = method === 'POST' ? '{}' : '';
return fetch(new URL(requestPath, MINIMAX_AGENT_URL), {
headers: {
'Content-Type': 'application/json',
+ client: 'desktop',
token: accessToken,
'x-signature': md5(`${unix}${SIGNATURE_SECRET}${body}`),
'x-timestamp': String(unix),
- yy: md5(`${encodeURIComponent(requestPath)}_${body}${md5(String(unix))}ooui`),
+ yy: md5(`${encodeURIComponent(requestPath)}_${body || '{}'}${md5(String(timestamp))}ooui`),
},
...(method === 'POST' ? { body } : {}),
method,
@@ -152,73 +273,112 @@ const signedAgentRequest = async (
});
};
-const signedAgentExtraInfoRequest = async (accessToken: string) => {
+type AgentIdentityResult = { realUserId: string } | { response: Response };
+
+const resolveAgentIdentity = async (accessToken: string): Promise => {
const accountId = miniMaxTokenIdentity(accessToken);
if (!accountId) {
return null;
}
const userInfoResponse = await signedAgentRequest(accessToken, USER_INFO_PATH, 'GET', accountId);
if (!userInfoResponse?.ok) {
- return userInfoResponse;
+ return userInfoResponse ? { response: userInfoResponse } : null;
}
- const userInfo = (await userInfoResponse.json()) as UserInfoPayload;
- const realUserId = userInfo.data?.userInfo?.realUserID ?? accountId;
+ let value: unknown;
+ try {
+ value = await readBoundedResponseJson(userInfoResponse, 'MiniMax account identity');
+ } catch {
+ return null;
+ }
+ const data = isRecord(value) && isRecord(value.data) ? value.data : {};
+ const userInfo = isRecord(data.userInfo) ? data.userInfo : {};
+ const realUserId = userInfo.realUserID;
+ return typeof realUserId === 'string' && realUserId.trim() ? { realUserId } : null;
+};
+
+const signedAgentExtraInfoRequest = async (accessToken: string, realUserId: string) => {
return signedAgentRequest(accessToken, USER_EXTRA_INFO_PATH, 'POST', realUserId);
};
const workspaceState = (payload: WorkspacePayload): WorkspaceState | LimitResult => {
if (payload.base_resp?.status_code && payload.base_resp.status_code !== 0) {
- return { error: payload.base_resp.status_msg ?? 'MiniMax account state request was rejected', ok: false };
+ return { error: 'MiniMax account state request was rejected', ok: false };
}
const workspace = payload.workspaces?.find((item) => item.selected) ?? payload.workspaces?.[0];
if (!workspace) {
return { error: 'MiniMax account state returned no workspace', ok: false };
}
return {
- creditBalance: Math.max(
- 0,
- finiteNumber(workspace.opcredit_balance) ?? finiteNumber(workspace.total_remains_credit) ?? 0,
- ),
hasTokenPlan: workspace.has_token_plan === true,
};
};
-export const workspaceToLimitResult = (state: WorkspaceState): LimitResult => {
+export const workspaceToLimitResult = (state: WorkspaceCreditState): LimitResult => {
return {
expires: '',
- models: {
- 'minimax-free-daily': {
- detail: 'Token valid · MiniMax does not report a free daily quota',
- displayName: 'Free daily quota',
- percentage: 100,
- resetTime: '',
- },
- },
+ models: { 'minimax-credits': creditModel(state.creditBalance) },
ok: true,
tier: state.hasTokenPlan ? 'MiniMax Code' : 'MiniMax Code · free access',
};
};
+const formatCreditBalance = (value: number) => {
+ return Math.floor(Math.max(0, value)).toLocaleString('en-US');
+};
+
+const creditModel = (creditBalance: number): ModelLimit => ({
+ detail: `Credit: ${formatCreditBalance(creditBalance)}`,
+ displayName: 'Credits',
+ percentage: 100,
+ resetTime: '',
+});
+
+const addCreditModel = (result: LimitResult, creditBalance: number): LimitResult => {
+ if (!result.ok) {
+ return result;
+ }
+ return {
+ ...result,
+ models: {
+ 'minimax-credits': creditModel(creditBalance),
+ ...result.models,
+ },
+ };
+};
+
+const membershipCreditBalance = (payload: MembershipPayload) => {
+ if (payload.base_resp?.status_code && payload.base_resp.status_code !== 0) {
+ return undefined;
+ }
+ const balance = finiteNumber(payload.op_credit_summary?.total_remaining_amount);
+ return balance === undefined ? undefined : Math.max(0, balance);
+};
+
const remainingPercentage = (value: unknown) => {
const percent = finiteNumber(value);
return percent === undefined ? undefined : Math.max(0, Math.min(100, Math.round(percent)));
};
-const resetTime = (value: number | string | undefined) => {
- const timestamp = typeof value === 'number' ? value * 1_000 : typeof value === 'string' ? Date.parse(value) : Number.NaN;
- return Number.isFinite(timestamp) && timestamp > 0 ? new Date(timestamp).toISOString() : '';
+const resetTime = (value: unknown) => {
+ const timestamp =
+ typeof value === 'number' ? value * 1_000 : typeof value === 'string' ? Date.parse(value) : Number.NaN;
+ if (!Number.isFinite(timestamp) || timestamp <= 0) {
+ return '';
+ }
+ const date = new Date(timestamp);
+ return Number.isNaN(date.valueOf()) ? '' : date.toISOString();
};
const totalFromBoost = (value: unknown) => {
const boost = finiteNumber(value);
- return boost === undefined ? 100 : boost / 10;
+ return boost === undefined || boost <= 0 ? undefined : Math.min(Number.MAX_SAFE_INTEGER, boost) / 10;
};
const quota = (
displayName: string,
remaining: unknown,
boost: unknown,
- endTime: number | string | undefined,
+ endTime: unknown,
status: unknown,
): ModelLimit | undefined => {
if (status === 3) {
@@ -231,15 +391,18 @@ const quota = (
const limit = totalFromBoost(boost);
return {
displayName,
- limit,
+ ...(limit === undefined ? {} : { limit }),
percentage,
resetTime: resetTime(endTime),
- used: Math.round(((100 - percentage) * limit) / 100),
+ ...(limit === undefined ? {} : { used: Math.min(limit, Math.round(((100 - percentage) * limit) / 100)) }),
};
};
-export const usageToLimitResult = (payload: UsagePayload): LimitResult => {
- if (payload.base_resp?.status_code === NO_TOKEN_PLAN_STATUS) {
+export const usageToLimitResult = (payload: unknown): LimitResult => {
+ const record = isRecord(payload) ? payload : {};
+ const baseResponse = isRecord(record.base_resp) ? record.base_resp : {};
+ const statusCode = finiteNumber(baseResponse.status_code);
+ if (statusCode === NO_TOKEN_PLAN_STATUS) {
return {
expires: '',
models: {
@@ -254,11 +417,11 @@ export const usageToLimitResult = (payload: UsagePayload): LimitResult => {
tier: 'MiniMax Code · no token plan',
};
}
- if (payload.base_resp?.status_code && payload.base_resp.status_code !== 0) {
- return { error: payload.base_resp.status_msg ?? 'MiniMax usage request was rejected', ok: false };
+ if (statusCode !== undefined && statusCode !== 0) {
+ return { error: 'MiniMax usage request was rejected', ok: false };
}
- const current = payload.model_remains?.[0];
- if (!current) {
+ const current = Array.isArray(record.model_remains) ? record.model_remains[0] : undefined;
+ if (!isRecord(current)) {
return { error: 'MiniMax usage returned no quota fields', ok: false };
}
@@ -289,42 +452,328 @@ export const usageToLimitResult = (payload: UsagePayload): LimitResult => {
return { expires: weekly?.resetTime ?? interval?.resetTime ?? '', models, ok: true, tier: 'MiniMax Code' };
};
-export const fetchMiniMaxLimits = async (config: MiniMaxConfig): Promise => {
- const accessToken = config.tokens?.accessToken;
- if (!accessToken) {
- return { error: 'Saved MiniMax config has no access token', ok: false };
+const checkInPanel = (value: unknown): MiniMaxCheckInPanel | null => {
+ if (!isRecord(value)) {
+ return null;
+ }
+ const scene = finiteNumber(value.scene);
+ const rawDays = Array.isArray(value.days) ? value.days : [];
+ const days = rawDays.flatMap((rawDay) => {
+ if (!isRecord(rawDay)) {
+ return [];
+ }
+ const dayNo = finiteNumber(rawDay.day_no);
+ const points = finiteNumber(rawDay.points);
+ const status = finiteNumber(rawDay.status);
+ if (
+ dayNo === undefined ||
+ points === undefined ||
+ status === undefined ||
+ !Number.isInteger(dayNo) ||
+ !Number.isInteger(status) ||
+ dayNo < 1 ||
+ points < 0 ||
+ status < 1 ||
+ status > 4
+ ) {
+ return [];
+ }
+ return [
+ {
+ dayNo,
+ isToday: rawDay.is_today === true,
+ points,
+ status,
+ },
+ ];
+ });
+ return scene === undefined || !Number.isInteger(scene) || scene < 0 || days.length === 0 ? null : { days, scene };
+};
+
+const checkInResponsePayload = async (response: Response, label: string) => {
+ if (response.status === 401 || response.status === 403) {
+ await discardResponse(response);
+ throw new Error('Saved MiniMax access token is expired or rejected');
+ }
+ if (!response.ok) {
+ await discardResponse(response);
+ throw new Error(`MiniMax ${label} request failed with HTTP ${response.status}`);
}
- let stateResponse: Response | null;
+ let payload: unknown;
try {
- stateResponse = await signedAgentExtraInfoRequest(accessToken);
+ payload = await readBoundedResponseJson(response, `MiniMax ${label}`);
+ } catch {
+ throw new Error(`MiniMax ${label} response was not valid JSON`);
+ }
+ const baseResponse = isRecord(payload) && isRecord(payload.base_resp) ? payload.base_resp : {};
+ const statusCode = finiteNumber(baseResponse.status_code);
+ if (statusCode !== undefined && statusCode !== 0) {
+ throw new Error(`MiniMax ${label} request was rejected`);
+ }
+ return payload;
+};
+
+const checkInStatus = (status: number): MiniMaxCheckInResult['status'] => {
+ if (status === 2) {
+ return 'claimable';
+ }
+ if (status === 3) {
+ return 'claimed';
+ }
+ if (status === 4) {
+ return 'disabled';
+ }
+ return 'upcoming';
+};
+
+const resolvedIdentity = async (accessToken: string) => {
+ const identity = await resolveAgentIdentity(accessToken);
+ if (!identity) {
+ throw new Error('Saved MiniMax access token has no readable user identity');
+ }
+ if (!('response' in identity)) {
+ return identity.realUserId;
+ }
+ await discardResponse(identity.response);
+ if (identity.response.status === 401 || identity.response.status === 403) {
+ throw new Error('Saved MiniMax access token is expired or rejected');
+ }
+ throw new Error(`MiniMax account identity request failed with HTTP ${identity.response.status}`);
+};
+
+const currentCheckIn = async (accessToken: string, realUserId: string) => {
+ const response = await signedAgentRequest(accessToken, SIGN_IN_STATUS_PATH, 'GET', realUserId);
+ if (!response) {
+ throw new Error('MiniMax account state request failed');
+ }
+ const payload = await checkInResponsePayload(response, 'check-in status');
+ const panel = checkInPanel(isRecord(payload) ? payload.data : undefined);
+ if (!panel) {
+ throw new Error('MiniMax check-in status returned no valid schedule');
+ }
+ const today = panel.days.find((day) => day.isToday);
+ if (!today) {
+ throw new Error('MiniMax check-in status returned no current day');
+ }
+ return { panel, today };
+};
+
+const unclaimedResult = (today: MiniMaxCheckInDay, panel: MiniMaxCheckInPanel): MiniMaxCheckInResult => ({
+ alreadyClaimed: today.status === 3,
+ claimed: false,
+ dayNo: today.dayNo,
+ panel,
+ points: today.points,
+ status: checkInStatus(today.status),
+});
+
+const claimedResult = async (
+ accessToken: string,
+ realUserId: string,
+ today: MiniMaxCheckInDay,
+ statusPanel: MiniMaxCheckInPanel,
+): Promise => {
+ const response = await signedAgentRequest(accessToken, SIGN_IN_CLAIM_PATH, 'POST', realUserId);
+ if (!response) {
+ throw new Error('MiniMax check-in request failed');
+ }
+ const payload = await checkInResponsePayload(response, 'check-in claim');
+ const data = isRecord(payload) && isRecord(payload.data) ? payload.data : {};
+ const claimResult = finiteNumber(data.claim_result);
+ if (claimResult !== 1 && claimResult !== 2) {
+ throw new Error('MiniMax check-in response did not include a valid claim result');
+ }
+ const dayNo = finiteNumber(data.day_no);
+ const points = finiteNumber(data.points);
+ return {
+ alreadyClaimed: claimResult === 2,
+ claimed: claimResult === 1,
+ dayNo: dayNo !== undefined && Number.isInteger(dayNo) && dayNo >= 1 ? dayNo : today.dayNo,
+ panel: checkInPanel(data.panel) ?? statusPanel,
+ points: points !== undefined && points >= 0 ? points : today.points,
+ status: 'claimed',
+ };
+};
+
+const performMiniMaxCheckIn = async (accessToken: string): Promise => {
+ const realUserId = await resolvedIdentity(accessToken);
+ const { panel, today } = await currentCheckIn(accessToken, realUserId);
+ if (today.status !== 2) {
+ return unclaimedResult(today, panel);
+ }
+ return claimedResult(accessToken, realUserId, today, panel);
+};
+
+export const checkInMiniMax = async (config: MiniMaxConfig): Promise => {
+ const accessToken = config.tokens.accessToken;
+ const tokenIdentity = miniMaxTokenIdentity(accessToken);
+ if (!tokenIdentity) {
+ throw new Error('Saved MiniMax access token has no readable user identity');
+ }
+ const existing = checkInPromises.get(tokenIdentity);
+ if (existing) {
+ return existing;
+ }
+ const pending = performMiniMaxCheckIn(accessToken);
+ checkInPromises.set(tokenIdentity, pending);
+ try {
+ return await pending;
+ } finally {
+ if (checkInPromises.get(tokenIdentity) === pending) {
+ checkInPromises.delete(tokenIdentity);
+ }
+ }
+};
+
+const savedTokenRejected = (): LimitResult => ({
+ error: 'Saved MiniMax access token is expired or rejected',
+ ok: false,
+});
+
+const agentResponseError = async (response: Response, label: string): Promise => {
+ if (response.status === 401 || response.status === 403) {
+ await discardResponse(response);
+ return savedTokenRejected();
+ }
+ if (response.ok) {
+ return null;
+ }
+ await discardResponse(response);
+ return { error: `MiniMax ${label} request failed with HTTP ${response.status}`, ok: false };
+};
+
+const fetchWorkspaceState = async (accessToken: string, realUserId: string): Promise => {
+ let response: Response | null;
+ try {
+ response = await signedAgentExtraInfoRequest(accessToken, realUserId);
} catch {
return { error: 'MiniMax account state request failed', ok: false };
}
- if (!stateResponse) {
- return { error: 'Saved MiniMax access token has no readable user identity', ok: false };
+ if (!response) {
+ return { error: 'MiniMax account state request failed', ok: false };
}
- if (stateResponse.status === 401 || stateResponse.status === 403) {
- return { error: 'Saved MiniMax access token is expired or rejected', ok: false };
+ const responseError = await agentResponseError(response, 'account state');
+ if (responseError) {
+ return responseError;
}
- if (!stateResponse.ok) {
- return { error: `MiniMax account state request failed with HTTP ${stateResponse.status}`, ok: false };
+ try {
+ return workspaceState(await readBoundedResponseJson(response, 'MiniMax account state'));
+ } catch {
+ return { error: 'MiniMax account state response was not valid JSON', ok: false };
}
- const stateResult = workspaceState((await stateResponse.json()) as WorkspacePayload);
- if ('ok' in stateResult) {
- return stateResult;
+};
+
+type MembershipResult = {
+ creditBalance?: number;
+ fatal?: LimitResult;
+};
+
+const fetchMembership = async (accessToken: string, realUserId: string): Promise => {
+ let response: Response | null;
+ try {
+ response = await signedAgentRequest(accessToken, MEMBERSHIP_PATH, 'POST', realUserId);
+ } catch {
+ return {};
}
- if (!stateResult.hasTokenPlan) {
- return workspaceToLimitResult(stateResult);
+ if (!response) {
+ return {};
}
- const response = await fetch(new URL(REMAINS_PATH, MINIMAX_PLATFORM_URL), {
- headers: { token: accessToken },
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- });
if (response.status === 401 || response.status === 403) {
- return { error: 'Saved MiniMax access token is expired or rejected', ok: false };
+ await discardResponse(response);
+ return { fatal: savedTokenRejected() };
}
if (!response.ok) {
- return { error: `MiniMax usage request failed with HTTP ${response.status}`, ok: false };
+ await discardResponse(response);
+ return {};
+ }
+ try {
+ const creditBalance = membershipCreditBalance(
+ await readBoundedResponseJson(response, 'MiniMax membership'),
+ );
+ return creditBalance === undefined ? {} : { creditBalance };
+ } catch {
+ return {};
+ }
+};
+
+const freeAccessResult = (): LimitResult => ({
+ expires: '',
+ models: {
+ 'minimax-free-access': {
+ detail: 'MiniMax does not report a numeric allowance for non-plan access',
+ displayName: 'Free / non-plan access',
+ percentage: 100,
+ resetTime: '',
+ },
+ },
+ ok: true,
+ tier: 'MiniMax Code · no token plan',
+});
+
+const fetchPlanUsage = async (accessToken: string): Promise => {
+ let response: Response;
+ try {
+ response = await fetch(new URL(REMAINS_PATH, MINIMAX_PLATFORM_URL), {
+ headers: { token: accessToken },
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ });
+ } catch {
+ return { error: 'MiniMax usage request failed', ok: false } as LimitResult;
+ }
+ const responseError = await agentResponseError(response, 'usage');
+ if (responseError) {
+ return responseError;
+ }
+ try {
+ return usageToLimitResult(await readBoundedResponseJson(response, 'MiniMax usage'));
+ } catch {
+ return { error: 'MiniMax usage response was not valid JSON', ok: false };
+ }
+};
+
+const fetchAgentIdentity = async (accessToken: string): Promise => {
+ try {
+ const identity = await resolveAgentIdentity(accessToken);
+ if (!identity || !('response' in identity)) {
+ return identity;
+ }
+ return (await agentResponseError(identity.response, 'account identity')) ?? null;
+ } catch {
+ return { error: 'MiniMax account state request failed', ok: false };
+ }
+};
+
+export const fetchMiniMaxLimits = async (config: MiniMaxConfig): Promise => {
+ const accessToken = config.tokens.accessToken;
+ const identity = await fetchAgentIdentity(accessToken);
+ if (!identity) {
+ return { error: 'Saved MiniMax access token has no readable user identity', ok: false };
+ }
+ if ('ok' in identity) {
+ return identity;
+ }
+ if ('response' in identity) {
+ return { error: 'MiniMax account identity request failed', ok: false };
+ }
+ const state = await fetchWorkspaceState(accessToken, identity.realUserId);
+ if ('ok' in state) {
+ return state;
+ }
+ if (!state.hasTokenPlan) {
+ const membership = await fetchMembership(accessToken, identity.realUserId);
+ if (membership.fatal) {
+ return membership.fatal;
+ }
+ return membership.creditBalance === undefined
+ ? freeAccessResult()
+ : workspaceToLimitResult({ creditBalance: membership.creditBalance, hasTokenPlan: false });
+ }
+ const [membership, usage] = await Promise.all([
+ fetchMembership(accessToken, identity.realUserId),
+ fetchPlanUsage(accessToken),
+ ]);
+ if (membership.fatal) {
+ return membership.fatal;
}
- return usageToLimitResult((await response.json()) as UsagePayload);
+ return membership.creditBalance === undefined ? usage : addCreditModel(usage, membership.creditBalance);
};
diff --git a/src/package-ui-smoke.test.ts b/src/package-ui-smoke.test.ts
index b4360ee..0ca9d1e 100644
--- a/src/package-ui-smoke.test.ts
+++ b/src/package-ui-smoke.test.ts
@@ -47,6 +47,7 @@ const runCommand = async (argv: string[], cwd: string) => {
if (exitCode !== 0) {
throw new Error(`${argv.join(' ')} failed\n${stdoutText}\n${stderrText}`.trim());
}
+ return { stderrText, stdoutText };
};
const waitForHealthyUi = async (url: string) => {
@@ -90,9 +91,14 @@ describe('packaged UI smoke', () => {
try {
await runCommand(['bun', 'pm', 'pack', '--destination', tempDir], process.cwd());
+ const tarball = packageTarballPath(tempDir, manifest);
+ const { stdoutText: packedEntries } = await runCommand(['tar', '-tzf', tarball], tempDir);
+ expect(packedEntries).toContain('package/scripts/build.ts');
+ expect(packedEntries).toContain('package/scripts/dev.ts');
+ expect(packedEntries).not.toContain('.test.ts');
await Bun.write(join(tempDir, 'package.json'), '{"name":"dondo-smoke","private":true}\n');
- const proc = Bun.spawn(['bunx', '--package', packageTarballPath(tempDir, manifest), manifest.name], {
+ const proc = Bun.spawn(['bunx', '--package', tarball, manifest.name], {
cwd: tempDir,
env: {
...process.env,
diff --git a/src/process.test.ts b/src/process.test.ts
new file mode 100644
index 0000000..8e5320c
--- /dev/null
+++ b/src/process.test.ts
@@ -0,0 +1,52 @@
+import { expect, it } from 'bun:test';
+import { isProcessRunning } from './process.ts';
+
+it('terminates pgrep options before a configured process name', async () => {
+ const originalSpawn = Bun.spawn;
+ let command: string[] = [];
+ let spawnOptions: Record = {};
+ try {
+ for (const [exitCode, expected] of [
+ [0, true],
+ [1, false],
+ ] as const) {
+ Bun.spawn = ((args: string[], options: Record) => {
+ command = args;
+ spawnOptions = options;
+ return { exited: Promise.resolve(exitCode) };
+ }) as unknown as typeof Bun.spawn;
+ expect(await isProcessRunning('-hostile-name')).toBe(expected);
+ expect(command).toEqual(['/usr/bin/pgrep', '-x', '--', '-hostile-name']);
+ expect(spawnOptions.timeout).toBe(5_000);
+ }
+ } finally {
+ Bun.spawn = originalSpawn;
+ }
+});
+
+it('escapes configured process names as literal pgrep patterns', async () => {
+ const originalSpawn = Bun.spawn;
+ let command: string[] = [];
+ Bun.spawn = ((args: string[]) => {
+ command = args;
+ return { exited: Promise.resolve(1) };
+ }) as unknown as typeof Bun.spawn;
+ try {
+ expect(await isProcessRunning('Kiro.*[test]')).toBe(false);
+ expect(command).toEqual(['/usr/bin/pgrep', '-x', '--', 'Kiro\\.\\*\\[test\\]']);
+ } finally {
+ Bun.spawn = originalSpawn;
+ }
+});
+
+it('fails closed when pgrep cannot evaluate the process state', async () => {
+ const originalSpawn = Bun.spawn;
+ Bun.spawn = (() => ({ exited: Promise.resolve(2) })) as unknown as typeof Bun.spawn;
+ try {
+ const error = await isProcessRunning('Kiro').catch((value: unknown) => value);
+ expect((error as { status?: number }).status).toBe(500);
+ expect(String(error)).toContain('could not verify whether the configured application process is running');
+ } finally {
+ Bun.spawn = originalSpawn;
+ }
+});
diff --git a/src/process.ts b/src/process.ts
new file mode 100644
index 0000000..8902148
--- /dev/null
+++ b/src/process.ts
@@ -0,0 +1,18 @@
+import { publicError } from './errors.ts';
+
+export const isProcessRunning = async (name: string) => {
+ const literalPattern = name.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
+ const proc = Bun.spawn(['/usr/bin/pgrep', '-x', '--', literalPattern], {
+ stderr: 'ignore',
+ stdout: 'ignore',
+ timeout: 5_000,
+ });
+ const exitCode = await proc.exited;
+ if (exitCode === 0) {
+ return true;
+ }
+ if (exitCode === 1) {
+ return false;
+ }
+ throw publicError(500, 'Dondo could not verify whether the configured application process is running');
+};
diff --git a/src/server.test.ts b/src/server.test.ts
index 021975e..daeca05 100644
--- a/src/server.test.ts
+++ b/src/server.test.ts
@@ -1,7 +1,13 @@
import { expect, it } from 'bun:test';
import { createServer } from 'node:net';
import { publicError } from './errors.ts';
-import { createFetch, serveOnAvailablePort } from './server.ts';
+import {
+ API_RATE_LIMIT_MAX,
+ createFetch,
+ MAX_EXPORT_PAYLOAD_BYTES,
+ MAX_JSON_BODY_BYTES,
+ serveOnAvailablePort,
+} from './server.ts';
const assets = {
appJs: 'console.log("ok");',
@@ -52,6 +58,8 @@ it('should apply security headers to the UI shell', async () => {
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
expect(response.headers.get('x-frame-options')).toBe('DENY');
expect(response.headers.get('content-security-policy')).toContain("default-src 'self'");
+ expect(response.headers.get('content-security-policy')).toContain("object-src 'none'");
+ expect(response.headers.get('content-security-policy')).toContain("worker-src 'none'");
});
it('should serve the UI shell for direct platform routes', async () => {
@@ -74,6 +82,28 @@ it('should reject non-local API origins', async () => {
expect(response.status).toBe(403);
});
+it('should reject a local origin on a different port', async () => {
+ const response = await app(
+ new Request('http://127.0.0.1:3000/api/codex/state', {
+ headers: { Origin: 'http://127.0.0.1:3001' },
+ }),
+ );
+
+ expect(response.status).toBe(403);
+ expect(await json(response)).toEqual({ error: 'Request origin must exactly match the local server origin' });
+});
+
+it('should reject a mismatched local Host header', async () => {
+ const response = await app(
+ new Request('http://127.0.0.1:3000/api/codex/state', {
+ headers: { Host: '127.0.0.1:3001' },
+ }),
+ );
+
+ expect(response.status).toBe(403);
+ expect(await json(response)).toEqual({ error: 'Only localhost requests are allowed' });
+});
+
it('should export plaintext credentials only through confirmed local POST requests', async () => {
const exportApp = createFetch(assets, {
exportWallet: async (platform) => ({
@@ -91,6 +121,7 @@ it('should export plaintext credentials only through confirmed local POST reques
expect(response.status).toBe(200);
expect(response.headers.get('cache-control')).toBe('no-store');
+ expect(response.headers.get('content-length')).toBe(String(Buffer.byteLength(await response.clone().text())));
expect(response.headers.get('content-type')).toBe('application/json');
expect(response.headers.get('content-disposition')).toMatch(
/^attachment; filename="dondo-antigravity-wallet-[A-Za-z0-9-]+\.json"$/,
@@ -130,7 +161,7 @@ it('should reject foreign origins before exporting credentials', async () => {
);
expect(response.status).toBe(403);
- expect(await json(response)).toEqual({ error: 'Only localhost origins are allowed' });
+ expect(await json(response)).toEqual({ error: 'Request origin must exactly match the local server origin' });
});
it('should preserve empty-export status without exposing credential-shaped errors', async () => {
@@ -152,6 +183,23 @@ it('should preserve empty-export status without exposing credential-shaped error
expect(responseText).not.toContain('ya29.secret');
});
+it('should replace unexpected internal errors instead of exposing their contents', async () => {
+ const exportApp = createFetch(assets, {
+ exportWallet: async () => {
+ throw new Error('Could not read /private/path containing Bearer ya29.secret');
+ },
+ });
+ const response = await exportApp(
+ new Request('http://127.0.0.1:3000/api/codex/export', {
+ headers: { 'X-Dondo-Export': '1' },
+ method: 'POST',
+ }),
+ );
+
+ expect(response.status).toBe(500);
+ expect(await json(response)).toEqual({ error: 'Internal server error' });
+});
+
it('should reject unsupported API methods before reading a body', async () => {
const response = await app(new Request('http://127.0.0.1:3000/api/antigravity/save'));
@@ -163,6 +211,7 @@ it('should reject malformed JSON bodies before service calls', async () => {
const response = await app(
new Request('http://127.0.0.1:3000/api/antigravity/save', {
body: '{',
+ headers: { 'Content-Type': 'application/json' },
method: 'POST',
}),
);
@@ -171,10 +220,112 @@ it('should reject malformed JSON bodies before service calls', async () => {
expect(await json(response)).toEqual({ error: 'Invalid JSON body' });
});
+it('should reject invalid UTF-8 request bodies', async () => {
+ const response = await app(
+ new Request('http://127.0.0.1:3000/api/antigravity/save', {
+ body: new Uint8Array([0x7b, 0x22, 0x6b, 0x65, 0x79, 0x22, 0x3a, 0x22, 0xc3, 0x28, 0x22, 0x7d]),
+ headers: { 'Content-Type': 'application/json' },
+ method: 'POST',
+ }),
+ );
+
+ expect(response.status).toBe(400);
+ expect(await json(response)).toEqual({ error: 'JSON request body must be valid UTF-8' });
+});
+
+it('should require a JSON content type before parsing request bodies', async () => {
+ const response = await app(
+ new Request('http://127.0.0.1:3000/api/antigravity/save', {
+ body: '{}',
+ method: 'POST',
+ }),
+ );
+
+ expect(response.status).toBe(415);
+ expect(await json(response)).toEqual({ error: 'JSON requests require Content-Type: application/json' });
+});
+
+it('should reject unexpected JSON fields and require JSON for empty mutations', async () => {
+ const unexpected = await app(
+ new Request('http://127.0.0.1:3000/api/antigravity/save', {
+ body: JSON.stringify({ key: 'work', token: 'not-accepted' }),
+ headers: { 'Content-Type': 'application/json' },
+ method: 'POST',
+ }),
+ );
+ const clearWithoutJson = await app(
+ new Request('http://127.0.0.1:3000/api/antigravity/clear', {
+ method: 'POST',
+ }),
+ );
+ const clearWithoutBody = await app(
+ new Request('http://127.0.0.1:3000/api/antigravity/clear', {
+ headers: { 'Content-Type': 'application/json' },
+ method: 'POST',
+ }),
+ );
+ const clearWithFields = await app(
+ new Request('http://127.0.0.1:3000/api/antigravity/clear', {
+ body: JSON.stringify({ key: 'work' }),
+ headers: { 'Content-Type': 'application/json' },
+ method: 'POST',
+ }),
+ );
+
+ expect(unexpected.status).toBe(400);
+ expect(await json(unexpected)).toEqual({ error: 'JSON body has unexpected fields' });
+ expect(clearWithoutJson.status).toBe(415);
+ expect(await json(clearWithoutJson)).toEqual({ error: 'JSON requests require Content-Type: application/json' });
+ expect(clearWithoutBody.status).toBe(400);
+ expect(await json(clearWithoutBody)).toEqual({ error: 'JSON body must be an object' });
+ expect(clearWithFields.status).toBe(400);
+ expect(await json(clearWithFields)).toEqual({ error: 'JSON body must be empty' });
+});
+
+it('should reject oversized JSON bodies without echoing their contents', async () => {
+ const secret = `Bearer ${'x'.repeat(MAX_JSON_BODY_BYTES)}`;
+ const response = await app(
+ new Request('http://127.0.0.1:3000/api/antigravity/save', {
+ body: JSON.stringify({ key: secret }),
+ headers: { 'Content-Type': 'application/json' },
+ method: 'POST',
+ }),
+ );
+ const text = await response.text();
+
+ expect(response.status).toBe(413);
+ expect(response.headers.get('cache-control')).toBe('no-store');
+ expect(text).not.toContain(secret);
+});
+
+it('should cancel oversized streamed JSON bodies and release their reader lock', async () => {
+ let cancelled = false;
+ const stream = new ReadableStream({
+ cancel: () => {
+ cancelled = true;
+ },
+ start: (controller) => {
+ controller.enqueue(new Uint8Array(MAX_JSON_BODY_BYTES + 1));
+ },
+ });
+ const request = new Request('http://127.0.0.1:3000/api/antigravity/save', {
+ body: stream,
+ ...({ duplex: 'half' } as { duplex: 'half' }),
+ headers: { 'Content-Type': 'application/json' },
+ method: 'POST',
+ });
+ const response = await app(request);
+
+ expect(response.status).toBe(413);
+ expect(cancelled).toBe(true);
+ expect(request.body?.locked).toBe(false);
+});
+
it('should not expose token-shaped fields in API error responses', async () => {
const response = await app(
new Request('http://127.0.0.1:3000/api/antigravity/load', {
body: JSON.stringify({ key: 'Bearer ya29.secret' }),
+ headers: { 'Content-Type': 'application/json' },
method: 'POST',
}),
);
@@ -185,6 +336,163 @@ it('should not expose token-shaped fields in API error responses', async () => {
expect(text).not.toContain('refresh_token');
});
+it('should reject oversized export payloads with a redacted error', async () => {
+ const secret = `Bearer ${'x'.repeat(MAX_EXPORT_PAYLOAD_BYTES)}`;
+ const exportApp = createFetch(assets, {
+ exportWallet: async () => ({
+ accounts: [{ config: { access_token: secret }, key: 'oversized' }],
+ exportedAt: '',
+ platform: 'codex',
+ }),
+ });
+ const response = await exportApp(
+ new Request('http://127.0.0.1:3000/api/codex/export', {
+ headers: { 'X-Dondo-Export': '1' },
+ method: 'POST',
+ }),
+ );
+ const text = await response.text();
+
+ expect(response.status).toBe(413);
+ expect(response.headers.get('cache-control')).toBe('no-store');
+ expect(text).not.toContain(secret);
+ expect(text).toContain('Export payload exceeds the 8 MiB size limit');
+});
+
+it('should stream large multi-account exports in bounded chunks', async () => {
+ let iteratorCalls = 0;
+ let generatedAccounts = 0;
+ const accounts = {
+ [Symbol.iterator]: () => {
+ iteratorCalls += 1;
+ let index = 0;
+ return {
+ next: () => {
+ if (index >= 2_000) {
+ return { done: true as const, value: undefined };
+ }
+ const account = {
+ config: { access_token: `token-${index}-${'x'.repeat(256)}` },
+ key: `account-${index}`,
+ };
+ index += 1;
+ generatedAccounts += 1;
+ return { done: false as const, value: account };
+ },
+ };
+ },
+ };
+ const exportApp = createFetch(assets, {
+ exportWallet: async (platform) => ({
+ accounts,
+ exportedAt: '2026-01-01T00:00:00.000Z',
+ platform,
+ }),
+ });
+ const response = await exportApp(
+ new Request('http://127.0.0.1:3000/api/codex/export', {
+ headers: { 'X-Dondo-Export': '1' },
+ method: 'POST',
+ }),
+ );
+ const reader = response.body?.getReader();
+ let chunks = 0;
+ let bytes = 0;
+ for (;;) {
+ const next = await reader?.read();
+ if (!next || next.done) {
+ break;
+ }
+ chunks += 1;
+ bytes += next.value.byteLength;
+ expect(next.value.byteLength).toBeLessThanOrEqual(64 * 1024);
+ }
+
+ expect(chunks).toBeGreaterThan(1);
+ expect(response.headers.get('content-length')).toBe(String(bytes));
+ expect(iteratorCalls).toBe(1);
+ expect(generatedAccounts).toBe(2_000);
+});
+
+it('should preflight the exact export ceiling before returning a body', async () => {
+ const baseAccount = { config: {}, key: '' };
+ const fixedBytes = Buffer.byteLength(
+ JSON.stringify({
+ accounts: [baseAccount],
+ exportedAt: '',
+ platform: 'codex',
+ }),
+ );
+ const wallet = (bytes: number) => ({
+ accounts: [{ config: {}, key: 'x'.repeat(bytes - fixedBytes) }],
+ exportedAt: '',
+ platform: 'codex' as const,
+ });
+ const allowed = createFetch(assets, { exportWallet: async () => wallet(MAX_EXPORT_PAYLOAD_BYTES) });
+ const rejected = createFetch(assets, { exportWallet: async () => wallet(MAX_EXPORT_PAYLOAD_BYTES + 1) });
+ const request = () =>
+ new Request('http://127.0.0.1:3000/api/codex/export', {
+ headers: { 'X-Dondo-Export': '1' },
+ method: 'POST',
+ });
+
+ const allowedResponse = await allowed(request());
+ expect(allowedResponse.status).toBe(200);
+ expect(allowedResponse.headers.get('content-length')).toBe(String(MAX_EXPORT_PAYLOAD_BYTES));
+ await allowedResponse.body?.cancel();
+
+ const rejectedResponse = await rejected(request());
+ expect(rejectedResponse.status).toBe(413);
+ expect(rejectedResponse.headers.get('content-disposition')).toBeNull();
+ expect(await rejectedResponse.text()).toContain('Export payload exceeds the 8 MiB size limit');
+});
+
+it('should clean up the export iterator when the response stream is cancelled', async () => {
+ let factoryCalls = 0;
+ let streamedIteratorCleanups = 0;
+ const exportApp = createFetch(assets, {
+ exportByteIterator: () => {
+ factoryCalls += 1;
+ let emitted = false;
+ return {
+ next: () => {
+ if (emitted) {
+ return { done: true as const, value: undefined };
+ }
+ emitted = true;
+ return { done: false as const, value: new TextEncoder().encode('{"ok":true}') };
+ },
+ return: () => {
+ streamedIteratorCleanups += 1;
+ return { done: true as const, value: undefined };
+ },
+ };
+ },
+ exportWallet: async () => ({ accounts: [], exportedAt: '', platform: 'codex' }),
+ });
+ const response = await exportApp(
+ new Request('http://127.0.0.1:3000/api/codex/export', {
+ headers: { 'X-Dondo-Export': '1' },
+ method: 'POST',
+ }),
+ );
+
+ await response.body?.cancel();
+ expect(factoryCalls).toBe(1);
+ expect(streamedIteratorCleanups).toBe(1);
+});
+
+it('should isolate rate-limit state between fetch instances', async () => {
+ const saturated = createFetch(assets);
+ const isolated = createFetch(assets);
+ for (let hit = 0; hit < API_RATE_LIMIT_MAX; hit += 1) {
+ expect((await saturated(new Request('http://127.0.0.1:3000/api/unknown'))).status).toBe(404);
+ }
+
+ expect((await saturated(new Request('http://127.0.0.1:3000/api/unknown'))).status).toBe(429);
+ expect((await isolated(new Request('http://127.0.0.1:3000/api/unknown'))).status).toBe(404);
+});
+
it('should bind the next available port when the preferred port is occupied', async () => {
const blocker = await occupyAvailablePortBelowMax();
const preferredPort = serverPort(blocker);
diff --git a/src/server.ts b/src/server.ts
index 3e838a7..4839f20 100755
--- a/src/server.ts
+++ b/src/server.ts
@@ -1,5 +1,6 @@
#!/usr/bin/env bun
+import { fileURLToPath } from 'node:url';
import {
antigravityState,
clearAntigravity,
@@ -10,12 +11,14 @@ import {
import { clineState, deleteCline, loadCline, saveCline } from './cline/service.ts';
import { codexState, deleteCodex, loadCodex, saveCodex } from './codex/service.ts';
import { HOST, PORT } from './config.ts';
-import { errorMessage, errorStatus, publicError } from './errors.ts';
+import { errorMessage, errorStatus, isPublicError, publicError } from './errors.ts';
import { clearKiro, deleteKiro, kiroState, loadKiro, saveKiro } from './kiro/service.ts';
-import { deleteMinimax, loadMinimax, minimaxState, saveMinimax } from './minimax/service.ts';
-import { type ExportPlatform, exportPlatformWallet } from './storage/export.ts';
+import { checkInMinimax, deleteMinimax, loadMinimax, minimaxState, saveMinimax } from './minimax/service.ts';
+import { type ExportPlatform, type ExportWalletResult, exportPlatformWallet } from './storage/export.ts';
import { renderHtml } from './ui/html.ts';
+declare const DONDO_BUNDLED_ASSETS: boolean;
+
type Assets = {
appJs: string;
css: string;
@@ -27,12 +30,29 @@ type Route = {
handler: (req: Request, dependencies: ServerDependencies) => Promise;
};
-type ExportWallet = (platform: ExportPlatform) => Promise;
+type EmptyMutation = () => Promise;
+
+type ExportWallet = (platform: ExportPlatform) => Promise;
+
+type ExportByteIterator = Iterator;
+
+type ExportByteIteratorFactory = (wallet: ExportWalletResult) => ExportByteIterator;
+
+type KeyedMutation = (key: string) => Promise;
+
+type LimitState = (options: { refreshLimitKey?: string; refreshLimits: true }) => Promise;
+
+type RouteEntry = [string, Route];
+
+type State = () => Promise;
type ServerDependencies = {
+ exportByteIterator: ExportByteIteratorFactory;
exportWallet: ExportWallet;
};
+type RateLimit = () => void;
+
type ServerFactoryOptions = {
fetch: ReturnType;
hostname: string;
@@ -43,30 +63,127 @@ type ServerFactory = (options: ServerFactoryOptions) => ReturnType {
+ try {
+ const serialized = JSON.stringify(value);
+ if (typeof serialized !== 'string') {
+ throw new Error('Value is not JSON serializable');
+ }
+ return serialized;
+ } catch {
+ throw publicError(500, 'Export payload could not be serialized');
+ }
+};
+
+const exportFragments = (wallet: ExportWalletResult): Iterator => {
+ const accounts = wallet.accounts[Symbol.iterator]();
+ let stage = 0;
+ let firstAccount = true;
+ let closed = false;
+ return {
+ next: () => {
+ if (closed) {
+ return { done: true, value: undefined };
+ }
+ if (stage === 0) {
+ stage = 1;
+ return { done: false, value: '{"accounts":[' };
+ }
+ if (stage === 1) {
+ const account = accounts.next();
+ if (!account.done) {
+ const separator = firstAccount ? '' : ',';
+ firstAccount = false;
+ return { done: false, value: `${separator}${serializeExportValue(account.value)}` };
+ }
+ stage = 2;
+ }
+ if (stage === 2) {
+ stage = 3;
+ return {
+ done: false,
+ value: `],"exportedAt":${serializeExportValue(wallet.exportedAt)},"platform":${serializeExportValue(wallet.platform)}}`,
+ };
+ }
+ closed = true;
+ return { done: true, value: undefined };
+ },
+ return: () => {
+ closed = true;
+ accounts.return?.();
+ return { done: true, value: undefined };
+ },
+ };
+};
+
+const createExportByteIterator: ExportByteIteratorFactory = (wallet) => {
+ const fragments = exportFragments(wallet);
+ const encoder = new TextEncoder();
+ let current = new Uint8Array();
+ let offset = 0;
+ let closed = false;
+ const close = () => {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ current = new Uint8Array();
+ fragments.return?.();
+ };
+ return {
+ next: () => {
+ if (closed) {
+ return { done: true, value: undefined };
+ }
+ while (offset >= current.byteLength) {
+ const next = fragments.next();
+ if (next.done) {
+ close();
+ return { done: true, value: undefined };
+ }
+ current = encoder.encode(next.value);
+ offset = 0;
+ }
+ const end = Math.min(offset + EXPORT_STREAM_CHUNK_BYTES, current.byteLength);
+ const chunk = current.slice(offset, end);
+ offset = end;
+ return { done: false, value: chunk };
+ },
+ return: () => {
+ close();
+ return { done: true, value: undefined };
+ },
+ };
+};
+
const defaultDependencies: ServerDependencies = {
+ exportByteIterator: createExportByteIterator,
exportWallet: exportPlatformWallet,
};
const bunServerFactory: ServerFactory = (options) => Bun.serve(options);
-const buildAssets = async (): Promise => {
+const modulePath = (relative: string) => fileURLToPath(new URL(relative, import.meta.url));
+
+const buildSourceAssets = async (): Promise => {
const result = await Bun.build({
- entrypoints: [new URL('./ui/client.tsx', import.meta.url).pathname],
+ entrypoints: [modulePath('./ui/client.tsx')],
jsx: {
importSource: 'preact',
runtime: 'automatic',
@@ -84,12 +201,27 @@ const buildAssets = async (): Promise => {
return {
appJs: await js.text(),
- css: await Bun.file(new URL('./ui/styles.css', import.meta.url).pathname).text(),
- iconPng: Bun.file(new URL('../icon.png', import.meta.url).pathname),
- iconSvg: await Bun.file(new URL('../icon.svg', import.meta.url).pathname).text(),
+ css: await Bun.file(modulePath('./ui/styles.css')).text(),
+ iconPng: Bun.file(modulePath('../icon.png')),
+ iconSvg: await Bun.file(modulePath('../icon.svg')).text(),
};
};
+const loadBundledAssets = async (): Promise => {
+ return {
+ appJs: await Bun.file(modulePath('./assets/app.js')).text(),
+ css: await Bun.file(modulePath('./assets/styles.css')).text(),
+ iconPng: Bun.file(modulePath('./icon.png')),
+ iconSvg: await Bun.file(modulePath('./icon.svg')).text(),
+ };
+};
+
+const buildAssets = () => {
+ return typeof DONDO_BUNDLED_ASSETS !== 'undefined' && DONDO_BUNDLED_ASSETS
+ ? loadBundledAssets()
+ : buildSourceAssets();
+};
+
const withHeaders = (response: Response, headers: Record) => {
const merged = new Headers(response.headers);
for (const [key, value] of Object.entries({ ...SECURITY_HEADERS, ...headers })) {
@@ -103,11 +235,11 @@ const withHeaders = (response: Response, headers: Record) => {
};
const json = (value: unknown, status = 200) => {
- return withHeaders(Response.json(value, { status }), {});
+ return withHeaders(Response.json(value, { status }), { 'Cache-Control': 'no-store' });
};
const jsonError = (error: unknown) => {
- return json({ error: errorMessage(error) }, errorStatus(error));
+ return json({ error: isPublicError(error) ? errorMessage(error) : 'Internal server error' }, errorStatus(error));
};
const isPortInUse = (error: unknown) => {
@@ -115,11 +247,50 @@ const isPortInUse = (error: unknown) => {
return value?.code === 'EADDRINUSE';
};
-const exportJson = async (platform: ExportPlatform, exportWallet: ExportWallet) => {
+const collectExportChunks = (wallet: ExportWalletResult, iteratorFactory: ExportByteIteratorFactory) => {
+ const iterator = iteratorFactory(wallet);
+ const chunks: Uint8Array[] = [];
+ let total = 0;
+ try {
+ for (;;) {
+ const next = iterator.next();
+ if (next.done) {
+ return { chunks, contentLength: total };
+ }
+ total += next.value.byteLength;
+ if (total > MAX_EXPORT_PAYLOAD_BYTES) {
+ throw publicError(413, 'Export payload exceeds the 8 MiB size limit');
+ }
+ chunks.push(next.value.slice());
+ }
+ } finally {
+ iterator.return?.();
+ }
+};
+
+const exportStream = (chunks: readonly Uint8Array[]) => {
+ let index = 0;
+ return new ReadableStream({
+ pull: (controller) => {
+ const chunk = chunks[index];
+ if (!chunk) {
+ controller.close();
+ return;
+ }
+ index += 1;
+ controller.enqueue(chunk);
+ },
+ });
+};
+
+const exportJson = async (platform: ExportPlatform, dependencies: ServerDependencies) => {
const filename = `dondo-${platform}-wallet-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
- return withHeaders(new Response(JSON.stringify(await exportWallet(platform), null, 2)), {
+ const wallet = await dependencies.exportWallet(platform);
+ const { chunks, contentLength } = collectExportChunks(wallet, dependencies.exportByteIterator);
+ return withHeaders(new Response(exportStream(chunks)), {
'Cache-Control': 'no-store',
'Content-Disposition': `attachment; filename="${filename}"`,
+ 'Content-Length': String(contentLength),
'Content-Type': 'application/json',
});
};
@@ -131,30 +302,34 @@ const localName = (host: string | null) => {
};
const assertLocalRequest = (req: Request) => {
- const host = req.headers.get('host') ?? new URL(req.url).host;
- if (!localName(host)) {
+ const requestUrl = new URL(req.url);
+ const host = req.headers.get('host') ?? requestUrl.host;
+ if (!localName(host) || !localName(requestUrl.host) || host.toLowerCase() !== requestUrl.host.toLowerCase()) {
throw publicError(403, 'Only localhost requests are allowed');
}
const origin = req.headers.get('origin');
if (origin) {
try {
- if (!localName(new URL(origin).host)) {
- throw publicError(403, 'Only localhost origins are allowed');
+ if (new URL(origin).origin !== requestUrl.origin) {
+ throw publicError(403, 'Request origin must exactly match the local server origin');
}
} catch {
- throw publicError(403, 'Only localhost origins are allowed');
+ throw publicError(403, 'Request origin must exactly match the local server origin');
}
}
};
-const assertRateLimit = () => {
- const now = Date.now();
- API_RATE_LIMIT.hits = API_RATE_LIMIT.hits.filter((hit) => hit > now - API_RATE_LIMIT.windowMs);
- if (API_RATE_LIMIT.hits.length >= API_RATE_LIMIT.max) {
- throw publicError(429, 'Too many local API requests; wait a moment and try again');
- }
- API_RATE_LIMIT.hits.push(now);
+const createRateLimit = (): RateLimit => {
+ let hits: number[] = [];
+ return () => {
+ const now = Date.now();
+ hits = hits.filter((hit) => hit > now - API_RATE_LIMIT_WINDOW_MS);
+ if (hits.length >= API_RATE_LIMIT_MAX) {
+ throw publicError(429, 'Too many local API requests; wait a moment and try again');
+ }
+ hits.push(now);
+ };
};
const assertExportConfirmation = (req: Request) => {
@@ -163,16 +338,62 @@ const assertExportConfirmation = (req: Request) => {
}
};
+const assertJsonContentType = (req: Request) => {
+ const mediaType = req.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
+ if (mediaType !== 'application/json') {
+ throw publicError(415, 'JSON requests require Content-Type: application/json');
+ }
+};
+
+const readBoundedBody = async (req: Request) => {
+ const contentLength = Number(req.headers.get('content-length'));
+ if (Number.isFinite(contentLength) && contentLength > MAX_JSON_BODY_BYTES) {
+ throw publicError(413, 'JSON request body exceeds the 16 KiB size limit');
+ }
+ if (!req.body) {
+ return '';
+ }
+
+ const reader = req.body.getReader();
+ const chunks: Uint8Array[] = [];
+ let total = 0;
+ try {
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) {
+ break;
+ }
+ total += value.byteLength;
+ if (total > MAX_JSON_BODY_BYTES) {
+ await reader.cancel().catch(() => undefined);
+ throw publicError(413, 'JSON request body exceeds the 16 KiB size limit');
+ }
+ chunks.push(value);
+ }
+ } finally {
+ reader.releaseLock();
+ }
+ try {
+ return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, total));
+ } catch {
+ throw publicError(400, 'JSON request body must be valid UTF-8');
+ }
+};
+
const body = async (req: Request) => {
- const text = await req.text();
+ assertJsonContentType(req);
+ const text = await readBoundedBody(req);
if (!text.trim()) {
- return {};
+ throw publicError(400, 'JSON body must be an object');
}
try {
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw publicError(400, 'JSON body must be an object');
}
+ if (Object.keys(parsed).some((key) => key !== 'key')) {
+ throw publicError(400, 'JSON body has unexpected fields');
+ }
return parsed as { key?: unknown };
} catch (error) {
if (errorStatus(error) !== 500) {
@@ -201,246 +422,109 @@ const requiredKey = async (req: Request) => {
return key;
};
-const routes = new Map([
- ['GET /api/antigravity/state', { handler: async () => json(await antigravityState()) }],
- [
- 'POST /api/antigravity/export',
- {
- handler: async (req, dependencies) => {
- assertExportConfirmation(req);
- return exportJson('antigravity', dependencies.exportWallet);
- },
- },
- ],
- [
- 'POST /api/antigravity/limits/refresh',
- {
- handler: async (req) =>
- json(await antigravityState({ refreshLimitKey: await optionalKey(req), refreshLimits: true })),
- },
- ],
- [
- 'POST /api/antigravity/save',
- {
- handler: async (req) => {
- await saveAntigravity(await requiredKey(req));
- return json({ ok: true });
- },
- },
- ],
- [
- 'POST /api/antigravity/load',
- {
- handler: async (req) => {
- await loadAntigravity(await requiredKey(req));
- return json({ ok: true });
- },
- },
- ],
- [
- 'POST /api/antigravity/delete',
- {
- handler: async (req) => {
- await deleteAntigravity(await requiredKey(req));
- return json({ ok: true });
- },
- },
- ],
- [
- 'POST /api/antigravity/clear',
- {
- handler: async () => {
- await clearAntigravity();
- return json({ ok: true });
- },
- },
- ],
- ['GET /api/codex/state', { handler: async () => json(await codexState()) }],
- ['GET /api/cline/state', { handler: async () => json(await clineState()) }],
- [
- 'POST /api/cline/export',
- {
- handler: async (req, dependencies) => {
- assertExportConfirmation(req);
- return exportJson('cline', dependencies.exportWallet);
- },
- },
- ],
- [
- 'POST /api/cline/save',
- {
- handler: async (req) => {
- await saveCline(await requiredKey(req));
- return json({ ok: true });
- },
- },
- ],
- [
- 'POST /api/cline/load',
- {
- handler: async (req) => {
- await loadCline(await requiredKey(req));
- return json({ ok: true });
- },
- },
- ],
- [
- 'POST /api/cline/delete',
- {
- handler: async (req) => {
- await deleteCline(await requiredKey(req));
- return json({ ok: true });
- },
- },
- ],
- [
- 'POST /api/codex/export',
- {
- handler: async (req, dependencies) => {
- assertExportConfirmation(req);
- return exportJson('codex', dependencies.exportWallet);
- },
- },
- ],
- [
- 'POST /api/codex/limits/refresh',
- {
- handler: async (req) =>
- json(await codexState({ refreshLimitKey: await optionalKey(req), refreshLimits: true })),
- },
- ],
- [
- 'POST /api/codex/save',
- {
- handler: async (req) => {
- await saveCodex(await requiredKey(req));
- return json({ ok: true });
- },
- },
- ],
- [
- 'POST /api/codex/load',
- {
- handler: async (req) => {
- await loadCodex(await requiredKey(req));
- return json({ ok: true });
- },
- },
- ],
- [
- 'POST /api/codex/delete',
- {
- handler: async (req) => {
- await deleteCodex(await requiredKey(req));
- return json({ ok: true });
- },
- },
- ],
- ['GET /api/kiro/state', { handler: async () => json(await kiroState()) }],
- [
- 'POST /api/kiro/limits/refresh',
- {
- handler: async (req) =>
- json(await kiroState({ refreshLimitKey: await optionalKey(req), refreshLimits: true })),
- },
- ],
- [
- 'POST /api/kiro/export',
- {
- handler: async (req, dependencies) => {
- assertExportConfirmation(req);
- return exportJson('kiro', dependencies.exportWallet);
- },
- },
- ],
- [
- 'POST /api/kiro/save',
- {
- handler: async (req) => {
- await saveKiro(await requiredKey(req));
- return json({ ok: true });
- },
- },
- ],
- [
- 'POST /api/kiro/load',
- {
- handler: async (req) => {
- await loadKiro(await requiredKey(req));
- return json({ ok: true });
- },
- },
- ],
- [
- 'POST /api/kiro/delete',
- {
- handler: async (req) => {
- await deleteKiro(await requiredKey(req));
- return json({ ok: true });
- },
- },
- ],
- [
- 'POST /api/kiro/clear',
- {
- handler: async () => {
- await clearKiro();
- return json({ ok: true });
- },
+const stateRoute = (platform: ExportPlatform, state: State): RouteEntry => [
+ `GET /api/${platform}/state`,
+ { handler: async () => json(await state()) },
+];
+
+const exportRoute = (platform: ExportPlatform): RouteEntry => [
+ `POST /api/${platform}/export`,
+ {
+ handler: async (req, dependencies) => {
+ assertExportConfirmation(req);
+ return exportJson(platform, dependencies);
},
- ],
- ['GET /api/minimax/state', { handler: async () => json(await minimaxState()) }],
- [
- 'POST /api/minimax/export',
- {
- handler: async (req, dependencies) => {
- assertExportConfirmation(req);
- return exportJson('minimax', dependencies.exportWallet);
- },
- },
- ],
- [
- 'POST /api/minimax/limits/refresh',
- {
- handler: async (req) =>
- json(await minimaxState({ refreshLimitKey: await optionalKey(req), refreshLimits: true })),
+ },
+];
+
+const keyedMutationRoute = (
+ platform: ExportPlatform,
+ action: 'delete' | 'load' | 'save',
+ mutation: KeyedMutation,
+): RouteEntry => [
+ `POST /api/${platform}/${action}`,
+ {
+ handler: async (req) => {
+ await mutation(await requiredKey(req));
+ return json({ ok: true });
},
- ],
- [
- 'POST /api/minimax/save',
- {
- handler: async (req) => {
- await saveMinimax(await requiredKey(req));
- return json({ ok: true });
- },
+ },
+];
+
+const limitRefreshRoute = (platform: ExportPlatform, state: LimitState): RouteEntry => [
+ `POST /api/${platform}/limits/refresh`,
+ {
+ handler: async (req) => {
+ const refreshLimitKey = await optionalKey(req);
+ return json(
+ await state({
+ ...(refreshLimitKey === undefined ? {} : { refreshLimitKey }),
+ refreshLimits: true,
+ }),
+ );
},
- ],
- [
- 'POST /api/minimax/load',
- {
- handler: async (req) => {
- await loadMinimax(await requiredKey(req));
- return json({ ok: true });
- },
+ },
+];
+
+const emptyMutationRoute = (platform: ExportPlatform, action: 'clear', mutation: EmptyMutation): RouteEntry => [
+ `POST /api/${platform}/${action}`,
+ {
+ handler: async (req) => {
+ if (Object.keys(await body(req)).length > 0) {
+ throw publicError(400, 'JSON body must be empty');
+ }
+ await mutation();
+ return json({ ok: true });
},
- ],
+ },
+];
+
+const routes = new Map([
+ stateRoute('antigravity', antigravityState),
+ exportRoute('antigravity'),
+ limitRefreshRoute('antigravity', antigravityState),
+ keyedMutationRoute('antigravity', 'save', saveAntigravity),
+ keyedMutationRoute('antigravity', 'load', loadAntigravity),
+ keyedMutationRoute('antigravity', 'delete', deleteAntigravity),
+ emptyMutationRoute('antigravity', 'clear', clearAntigravity),
+ stateRoute('cline', clineState),
+ exportRoute('cline'),
+ keyedMutationRoute('cline', 'save', saveCline),
+ keyedMutationRoute('cline', 'load', loadCline),
+ keyedMutationRoute('cline', 'delete', deleteCline),
+ stateRoute('codex', codexState),
+ exportRoute('codex'),
+ limitRefreshRoute('codex', codexState),
+ keyedMutationRoute('codex', 'save', saveCodex),
+ keyedMutationRoute('codex', 'load', loadCodex),
+ keyedMutationRoute('codex', 'delete', deleteCodex),
+ stateRoute('kiro', kiroState),
+ exportRoute('kiro'),
+ limitRefreshRoute('kiro', kiroState),
+ keyedMutationRoute('kiro', 'save', saveKiro),
+ keyedMutationRoute('kiro', 'load', loadKiro),
+ keyedMutationRoute('kiro', 'delete', deleteKiro),
+ emptyMutationRoute('kiro', 'clear', clearKiro),
+ stateRoute('minimax', minimaxState),
+ exportRoute('minimax'),
+ limitRefreshRoute('minimax', minimaxState),
[
- 'POST /api/minimax/delete',
+ 'POST /api/minimax/check-in',
{
- handler: async (req) => {
- await deleteMinimax(await requiredKey(req));
- return json({ ok: true });
- },
+ handler: async (req) => json(await checkInMinimax(await optionalKey(req))),
},
],
+ keyedMutationRoute('minimax', 'save', saveMinimax),
+ keyedMutationRoute('minimax', 'load', loadMinimax),
+ keyedMutationRoute('minimax', 'delete', deleteMinimax),
]);
-const handleApi = async (url: URL, req: Request, dependencies: ServerDependencies) => {
+const handleApi = async (url: URL, req: Request, dependencies: ServerDependencies, rateLimit: RateLimit) => {
if (!url.pathname.startsWith('/api/')) {
return null;
}
assertLocalRequest(req);
- assertRateLimit();
+ rateLimit();
const route = routes.get(`${req.method} ${url.pathname}`);
if (!route) {
const hasPath = [...routes.keys()].some((key) => key.endsWith(` ${url.pathname}`));
@@ -479,8 +563,10 @@ const handleAsset = (url: URL, assets: Assets) => {
export const createFetch = (assets: Assets, dependencyOverrides: Partial = {}) => {
const dependencies: ServerDependencies = {
+ exportByteIterator: dependencyOverrides.exportByteIterator ?? defaultDependencies.exportByteIterator,
exportWallet: dependencyOverrides.exportWallet ?? defaultDependencies.exportWallet,
};
+ const rateLimit = createRateLimit();
return async (req: Request) => {
const url = new URL(req.url);
try {
@@ -488,7 +574,7 @@ export const createFetch = (assets: Assets, dependencyOverrides: Partial {
await expect(run('bun', ['-e', 'console.error(`password: "secret"`); process.exit(2)'])).rejects.toThrow(
'password: "[redacted]"',
);
});
+
+it('should pass private input over stdin and redact it from subprocess errors', async () => {
+ const secret = 'vault-key-material-not-shaped-like-a-token';
+ const error = await run(
+ 'bun',
+ ['-e', 'const input = await Bun.stdin.text(); console.error(input.split(/\\r?\\n/)[0]); process.exit(7)'],
+ { stdin: `${secret}\n${secret}\n` },
+ ).catch((value: unknown) => value);
+
+ expect(isRunError(error)).toBe(true);
+ expect(String(error)).not.toContain(secret);
+ if (isRunError(error)) {
+ expect(error.stderr).not.toContain(secret);
+ expect(error.stdout).not.toContain(secret);
+ }
+});
+
+it('should handle a subprocess closing stdin before input is written', async () => {
+ await expect(run('bun', ['-e', 'process.exit(0)'], { stdin: 'input-that-may-race-with-exit' })).resolves.toEqual({
+ stderr: '',
+ stdout: '',
+ });
+});
+
+it('should enforce its timeout even when a subprocess ignores SIGTERM', async () => {
+ const startedAt = Date.now();
+
+ await expect(
+ run('bun', ['-e', 'process.on("SIGTERM", () => {}); setInterval(() => {}, 1_000)'], { timeoutMs: 25 }),
+ ).rejects.toThrow('timed out');
+ expect(Date.now() - startedAt).toBeLessThan(2_000);
+});
+
+it('should reject subprocess output that exceeds the capture limit', async () => {
+ for (const stream of ['stdout', 'stderr'] as const) {
+ await expect(run('bun', ['-e', `process.${stream}.write('x'.repeat(1024 * 1024 + 1))`])).rejects.toThrow(
+ `Subprocess ${stream} exceeded the 1 MiB capture limit`,
+ );
+ }
+});
+
+it('should reject subprocess output that is not valid UTF-8', async () => {
+ await expect(run('bun', ['-e', 'process.stdout.write(Buffer.from([255]))'])).rejects.toThrow(
+ 'Subprocess stdout was not valid UTF-8',
+ );
+});
diff --git a/src/shell.ts b/src/shell.ts
index e11911d..528bd59 100644
--- a/src/shell.ts
+++ b/src/shell.ts
@@ -1,4 +1,4 @@
-import { errorMessage, redactSecrets } from './errors.ts';
+import { redactSecrets } from './errors.ts';
export type RunError = Error & {
code: number;
@@ -6,42 +6,102 @@ export type RunError = Error & {
stdout: string;
};
-type RunOptions = {
+export type RunOptions = {
+ stdin?: string;
timeoutMs?: number;
};
const DEFAULT_TIMEOUT_MS = 15_000;
+const MAX_CAPTURE_BYTES = 1024 * 1024;
+
+const captureOutput = async (stream: ReadableStream, label: string, terminate: () => void) => {
+ const reader = stream.getReader();
+ const chunks: Uint8Array[] = [];
+ let total = 0;
+ try {
+ for (;;) {
+ const next = await reader.read();
+ if (next.done) {
+ try {
+ return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, total));
+ } catch {
+ throw new Error(`${label} was not valid UTF-8`);
+ }
+ }
+ total += next.value.byteLength;
+ if (total > MAX_CAPTURE_BYTES) {
+ terminate();
+ await reader.cancel().catch(() => undefined);
+ throw new Error(`${label} exceeded the 1 MiB capture limit`);
+ }
+ chunks.push(next.value);
+ }
+ } finally {
+ reader.releaseLock();
+ }
+};
const safeArgs = (args: string[]) => {
return args.map((arg, index) => (args[index - 1] === '-w' ? '[redacted]' : arg));
};
+const redactPrivateInput = (value: unknown, stdin?: string) => {
+ let redacted = redactSecrets(value);
+ const privateValues = new Set([stdin, stdin?.trim(), ...(stdin?.split(/\r?\n/) ?? [])]);
+ for (const secret of privateValues) {
+ if (secret) {
+ redacted = redacted.replaceAll(secret, '[redacted]');
+ }
+ }
+ return redacted;
+};
+
export const isRunError = (error: unknown): error is RunError => {
return error instanceof Error && 'code' in error && 'stderr' in error && 'stdout' in error;
};
export const run = async (cmd: string, args: string[], options: RunOptions = {}) => {
- const proc = Bun.spawn([cmd, ...args], { stderr: 'pipe', stdout: 'pipe' });
+ const proc = Bun.spawn([cmd, ...args], {
+ stderr: 'pipe',
+ stdin: options.stdin === undefined ? 'ignore' : 'pipe',
+ stdout: 'pipe',
+ });
+ const terminate = () => {
+ proc.kill('SIGKILL');
+ };
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
- proc.kill();
+ terminate();
}, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
+ const stdin = proc.stdin;
+ const stdinWrite =
+ options.stdin !== undefined && stdin
+ ? (async () => {
+ try {
+ await stdin.write(options.stdin as string);
+ await stdin.end();
+ } catch {
+ terminate();
+ }
+ })()
+ : Promise.resolve();
- const [stdout, stderr, code] = await Promise.all([
- new Response(proc.stdout).text(),
- new Response(proc.stderr).text(),
+ const [stdout, stderr, , code] = await Promise.all([
+ captureOutput(proc.stdout, 'Subprocess stdout', terminate),
+ captureOutput(proc.stderr, 'Subprocess stderr', terminate),
+ stdinWrite,
proc.exited,
]).finally(() => clearTimeout(timer));
if (code !== 0) {
const message = timedOut
? `${cmd} ${safeArgs(args).join(' ')} timed out`
- : `${cmd} ${safeArgs(args).join(' ')} failed (${code}): ${errorMessage(stderr || stdout)}`;
+ : `${cmd} ${safeArgs(args).join(' ')} failed (${code}): ${redactPrivateInput(stderr || stdout, options.stdin)}`;
throw Object.assign(new Error(message), {
code,
- stderr: redactSecrets(stderr),
- stdout: redactSecrets(stdout),
+ stderr: redactPrivateInput(stderr, options.stdin),
+ stdout: redactPrivateInput(stdout, options.stdin),
});
}
diff --git a/src/storage/crypto.test.ts b/src/storage/crypto.test.ts
new file mode 100644
index 0000000..43f3e84
--- /dev/null
+++ b/src/storage/crypto.test.ts
@@ -0,0 +1,33 @@
+import { expect, it } from 'bun:test';
+import { createCipheriv, randomBytes } from 'node:crypto';
+import { open } from './crypto.ts';
+
+const TEST_KEY = Buffer.alloc(32, 5);
+
+const sealBytes = (plaintext: Uint8Array, associatedData: string) => {
+ const iv = randomBytes(12);
+ const cipher = createCipheriv('aes-256-gcm', TEST_KEY, iv);
+ cipher.setAAD(Buffer.from(associatedData, 'utf8'));
+ const body = Buffer.concat([cipher.update(plaintext), cipher.final()]);
+ return `enc:v2:${Buffer.concat([iv, cipher.getAuthTag(), body]).toString('base64')}`;
+};
+
+it('should reject authenticated ciphertext whose plaintext is not valid UTF-8', () => {
+ const associatedData = '["test"]';
+ const ciphertext = sealBytes(new Uint8Array([0xc3, 0x28]), associatedData);
+
+ expect(() => open(ciphertext, TEST_KEY, associatedData)).toThrow();
+});
+
+it('should reject noncanonical base64 ciphertext encodings', () => {
+ const associatedData = '["test"]';
+ const ciphertext = sealBytes(new Uint8Array([0x7b]), associatedData);
+ const encoded = ciphertext.slice('enc:v2:'.length);
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
+ const tailIndex = alphabet.indexOf(encoded.at(-2) ?? '');
+ const replacement = alphabet[(tailIndex & 0b11_1100) | ((tailIndex + 1) & 0b11)];
+ const noncanonical = `enc:v2:${encoded.slice(0, -2)}${replacement}=`;
+
+ expect(Buffer.from(noncanonical.slice('enc:v2:'.length), 'base64')).toEqual(Buffer.from(encoded, 'base64'));
+ expect(() => open(noncanonical, TEST_KEY, associatedData)).toThrow('Encrypted vault value is malformed');
+});
diff --git a/src/storage/crypto.ts b/src/storage/crypto.ts
index f446c7f..33996b7 100644
--- a/src/storage/crypto.ts
+++ b/src/storage/crypto.ts
@@ -1,35 +1,55 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
-import { vaultKey } from './secret.ts';
+import { type VaultKeyMode, vaultKey } from './secret.ts';
-const VERSION_PREFIX = 'enc:v1:';
+export type VaultKeyProvider = (mode: VaultKeyMode) => Promise;
+
+const VERSION_PREFIX = 'enc:v2:';
const IV_BYTES = 12;
const AUTH_TAG_BYTES = 16;
-const resolveKey = async (key?: Buffer) => {
- const resolved = key ?? (await vaultKey());
+const assertKey = (key: Buffer) => {
+ const resolved = key;
if (resolved.byteLength !== 32) {
throw new Error(`Vault encryption key must be 32 bytes; received ${resolved.byteLength}`);
}
return resolved;
};
-export const seal = async (text: string, key?: Buffer) => {
+export const resolveVaultKey = async (mode: VaultKeyMode, key?: Buffer, keyProvider: VaultKeyProvider = vaultKey) => {
+ return assertKey(key ?? (await keyProvider(mode)));
+};
+
+export const isCurrentVaultCiphertext = (value: unknown): value is string => {
+ return typeof value === 'string' && value.startsWith(VERSION_PREFIX);
+};
+
+export const seal = (text: string, key: Buffer, associatedData: string) => {
const iv = randomBytes(IV_BYTES);
- const cipher = createCipheriv('aes-256-gcm', await resolveKey(key), iv);
+ const cipher = createCipheriv('aes-256-gcm', assertKey(key), iv);
+ cipher.setAAD(Buffer.from(associatedData, 'utf8'));
const body = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
return `${VERSION_PREFIX}${Buffer.concat([iv, cipher.getAuthTag(), body]).toString('base64')}`;
};
-export const open = async (text: string, key?: Buffer) => {
- if (!text.startsWith(VERSION_PREFIX)) {
- return text;
+export const open = (text: string, key: Buffer, associatedData: string) => {
+ if (!isCurrentVaultCiphertext(text)) {
+ throw new Error('Stored vault value is not enc:v2 ciphertext');
}
- const raw = Buffer.from(text.slice(VERSION_PREFIX.length), 'base64');
+ const encoded = text.slice(VERSION_PREFIX.length);
+ if (!encoded || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length % 4 !== 0) {
+ throw new Error('Encrypted vault value is malformed');
+ }
+ const raw = Buffer.from(encoded, 'base64');
+ if (raw.toString('base64') !== encoded) {
+ throw new Error('Encrypted vault value is malformed');
+ }
if (raw.byteLength < IV_BYTES + AUTH_TAG_BYTES) {
throw new Error('Encrypted vault value is malformed');
}
- const decipher = createDecipheriv('aes-256-gcm', await resolveKey(key), raw.subarray(0, IV_BYTES));
+ const decipher = createDecipheriv('aes-256-gcm', assertKey(key), raw.subarray(0, IV_BYTES));
+ decipher.setAAD(Buffer.from(associatedData, 'utf8'));
decipher.setAuthTag(raw.subarray(IV_BYTES, IV_BYTES + AUTH_TAG_BYTES));
- return Buffer.concat([decipher.update(raw.subarray(IV_BYTES + AUTH_TAG_BYTES)), decipher.final()]).toString('utf8');
+ const plaintext = Buffer.concat([decipher.update(raw.subarray(IV_BYTES + AUTH_TAG_BYTES)), decipher.final()]);
+ return new TextDecoder('utf-8', { fatal: true }).decode(plaintext);
};
diff --git a/src/storage/export.test.ts b/src/storage/export.test.ts
index 66cb1af..9b4f903 100644
--- a/src/storage/export.test.ts
+++ b/src/storage/export.test.ts
@@ -2,9 +2,8 @@ import { expect, it } from 'bun:test';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
-import { seal } from './crypto.ts';
-import { type ExportPlatform, exportPlatformWallet } from './export.ts';
-import { updateVault } from './vault.ts';
+import { type ExportPlatform, type ExportWalletResult, exportPlatformWallet } from './export.ts';
+import { updateVaultSection } from './vault.ts';
const TEST_KEY = Buffer.alloc(32, 7);
@@ -17,29 +16,32 @@ const writeVaultFixture = async (path: string, value: unknown) => {
await Bun.write(path, JSON.stringify(value));
};
+const materializeWallet = (wallet: ExportWalletResult) => ({ ...wallet, accounts: [...wallet.accounts] });
+
it('should export Codex accounts with decrypted parsed configs', async () => {
const { dir, path } = await tempVaultPath();
const auth = {
+ auth_mode: 'apikey',
OPENAI_API_KEY: 'sk-test',
- tokens: { account_id: 'acct_123', refresh_token: 'refresh-test' },
};
try {
- await writeVaultFixture(path, {
- codex: {
- data: {
- work: {
- auth: await seal(JSON.stringify(auth), TEST_KEY),
- createdAt: '2026-01-01T00:00:00.000Z',
- updatedAt: '2026-01-02T00:00:00.000Z',
- },
- },
- limits: {},
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.work = {
+ auth: JSON.stringify(auth),
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
},
- });
+ path,
+ TEST_KEY,
+ );
const exported = await exportPlatformWallet('codex', path, TEST_KEY);
- expect(exported).toMatchObject({
+ expect(materializeWallet(exported)).toMatchObject({
accounts: [
{
config: auth,
@@ -58,27 +60,31 @@ it('should export Codex accounts with decrypted parsed configs', async () => {
it('should export MiniMax accounts with decrypted parsed configs', async () => {
const { dir, path } = await tempVaultPath();
+ const accessToken = `${Buffer.from('{}').toString('base64url')}.${Buffer.from(
+ JSON.stringify({ user: { id: 'user-123' } }),
+ ).toString('base64url')}.signature`;
const config = {
- tokens: { accessToken: 'minimax-token' },
+ tokens: { accessToken },
user: { userID: 'user-123' },
};
try {
- await writeVaultFixture(path, {
- minimax: {
- data: {
- personal: {
- config: await seal(JSON.stringify(config), TEST_KEY),
- createdAt: '2026-01-01T00:00:00.000Z',
- updatedAt: '2026-01-02T00:00:00.000Z',
- },
- },
- limits: {},
+ await updateVaultSection(
+ 'minimax',
+ (section) => {
+ section.data.personal = {
+ config: JSON.stringify(config),
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
},
- });
+ path,
+ TEST_KEY,
+ );
const exported = await exportPlatformWallet('minimax', path, TEST_KEY);
- expect(exported).toMatchObject({
+ expect(materializeWallet(exported)).toMatchObject({
accounts: [
{
config,
@@ -97,30 +103,38 @@ it('should export MiniMax accounts with decrypted parsed configs', async () => {
it('should export Cline accounts with decrypted parsed secrets', async () => {
const { dir, path } = await tempVaultPath();
const secrets = {
- 'cline:clineAccountId': JSON.stringify({
- idToken: 'cline-id-token',
- refreshToken: 'cline-refresh-token',
- userInfo: { email: 'cline@example.com', id: 'cline-user' },
- }),
- unrelated: 'setting',
- };
- try {
- await writeVaultFixture(path, {
+ providers: {
cline: {
- data: {
- personal: {
- createdAt: '2026-01-01T00:00:00.000Z',
- secrets: await seal(JSON.stringify(secrets), TEST_KEY),
- updatedAt: '2026-01-02T00:00:00.000Z',
+ settings: {
+ auth: {
+ accessToken: 'cline-access-token',
+ metadata: { userInfo: { email: 'cline@example.com', id: 'cline-user' } },
+ refreshToken: 'cline-refresh-token',
},
+ provider: 'cline',
},
- limits: {},
},
- });
+ },
+ unrelated: 'setting',
+ };
+ try {
+ await updateVaultSection(
+ 'cline',
+ (section) => {
+ section.data.personal = {
+ createdAt: '2026-01-01T00:00:00.000Z',
+ secrets: JSON.stringify(secrets),
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ );
const exported = await exportPlatformWallet('cline', path, TEST_KEY);
- expect(exported).toMatchObject({
+ expect(materializeWallet(exported)).toMatchObject({
accounts: [
{
config: secrets,
@@ -146,27 +160,25 @@ it('should export Kiro accounts with decrypted parsed auth', async () => {
refreshToken: 'kiro-refresh',
};
try {
- await writeVaultFixture(path, {
- kiro: {
- data: {
- personal: {
- auth: await seal(JSON.stringify(auth), TEST_KEY),
- clientRegistration: await seal(
- JSON.stringify({ clientId: 'kiro-client', clientSecret: 'kiro-secret' }),
- TEST_KEY,
- ),
- createdAt: '2026-01-01T00:00:00.000Z',
- profile: await seal(JSON.stringify({ email: 'kiro@example.com' }), TEST_KEY),
- updatedAt: '2026-01-02T00:00:00.000Z',
- },
- },
- limits: {},
+ await updateVaultSection(
+ 'kiro',
+ (section) => {
+ section.data.personal = {
+ auth: JSON.stringify(auth),
+ clientRegistration: JSON.stringify({ clientId: 'kiro-client', clientSecret: 'kiro-secret' }),
+ createdAt: '2026-01-01T00:00:00.000Z',
+ profile: JSON.stringify({ email: 'kiro@example.com' }),
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
},
- });
+ path,
+ TEST_KEY,
+ );
const exported = await exportPlatformWallet('kiro', path, TEST_KEY);
- expect(exported).toMatchObject({
+ expect(materializeWallet(exported)).toMatchObject({
accounts: [
{
clientRegistration: { clientId: 'kiro-client', clientSecret: 'kiro-secret' },
@@ -195,26 +207,28 @@ it('should export Antigravity accounts with one decoded credential payload', asy
const password = `go-keyring-base64:${Buffer.from(JSON.stringify(tokenPayload)).toString('base64')}`;
try {
- await writeVaultFixture(path, {
- antigravity: {
- data: {
- work: {
- account: 'antigravity',
- createdAt: '2026-01-01T00:00:00.000Z',
- kind: 'Generic Password',
- label: 'gemini',
- password: await seal(password, TEST_KEY),
- service: 'gemini',
- updatedAt: '2026-01-02T00:00:00.000Z',
- },
- },
- limits: {},
+ await updateVaultSection(
+ 'antigravity',
+ (section) => {
+ section.data.work = {
+ account: 'antigravity',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ identity: 'google-user',
+ kind: 'Generic Password',
+ label: 'gemini',
+ password,
+ service: 'gemini',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
},
- });
+ path,
+ TEST_KEY,
+ );
const exported = await exportPlatformWallet('antigravity', path, TEST_KEY);
- expect(exported).toMatchObject({
+ expect(materializeWallet(exported)).toMatchObject({
accounts: [
{
config: {
@@ -251,24 +265,27 @@ it('should reject an empty platform export', async () => {
it('should reject malformed Antigravity token data', async () => {
const { dir, path } = await tempVaultPath();
try {
- await writeVaultFixture(path, {
- antigravity: {
- data: {
- broken: {
- account: 'antigravity',
- createdAt: '2026-01-01T00:00:00.000Z',
- kind: 'Generic Password',
- label: 'gemini',
- password: 'not-a-keyring-token',
- service: 'gemini',
- updatedAt: '2026-01-02T00:00:00.000Z',
- },
- },
- limits: {},
+ await updateVaultSection(
+ 'antigravity',
+ (section) => {
+ section.data.broken = {
+ account: 'antigravity',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ identity: 'google-user',
+ kind: 'Generic Password',
+ label: 'gemini',
+ password: 'not-a-keyring-token',
+ service: 'gemini',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
},
- });
+ path,
+ TEST_KEY,
+ );
- await expect(exportPlatformWallet('antigravity', path, TEST_KEY)).rejects.toThrow(
+ const exported = await exportPlatformWallet('antigravity', path, TEST_KEY);
+ expect(() => materializeWallet(exported)).toThrow(
'Saved Antigravity credentials for "broken" could not be decoded',
);
} finally {
@@ -280,20 +297,23 @@ it('should reject malformed JSON configs without returning their contents', asyn
const { dir, path } = await tempVaultPath();
const malformed = 'Bearer private-test-value';
try {
- await writeVaultFixture(path, {
- codex: {
- data: {
- broken: {
- auth: malformed,
- createdAt: '2026-01-01T00:00:00.000Z',
- updatedAt: '2026-01-02T00:00:00.000Z',
- },
- },
- limits: {},
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.broken = {
+ auth: malformed,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
},
- });
+ path,
+ TEST_KEY,
+ );
- const error = await exportPlatformWallet('codex', path, TEST_KEY).catch((value: unknown) => value);
+ const error = await exportPlatformWallet('codex', path, TEST_KEY)
+ .then(materializeWallet)
+ .catch((value: unknown) => value);
expect(error).toBeInstanceOf(Error);
expect(String(error)).toContain('Saved Codex config for "broken" is not valid JSON');
expect(String(error)).not.toContain(malformed);
@@ -302,6 +322,43 @@ it('should reject malformed JSON configs without returning their contents', asyn
}
});
+it('should reject decrypted configs that are valid JSON but invalid for their platform', async () => {
+ const cases = [
+ ['cline', 'Cline', { secrets: '{}' }],
+ ['codex', 'Codex', { auth: JSON.stringify({ auth_mode: 'apikey' }) }],
+ ['kiro', 'Kiro', { auth: JSON.stringify({ refreshToken: '' }) }],
+ ['minimax', 'MiniMax', { config: JSON.stringify({ tokens: { accessToken: 'not-a-jwt' } }) }],
+ ] as const;
+
+ for (const [platform, displayName, secretFields] of cases) {
+ const { dir, path } = await tempVaultPath();
+ try {
+ await updateVaultSection(
+ platform,
+ (section) => {
+ Object.assign(section.data, {
+ broken: {
+ ...secretFields,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ },
+ });
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ );
+
+ const exported = await exportPlatformWallet(platform, path, TEST_KEY);
+ expect(() => materializeWallet(exported)).toThrow(
+ `Saved ${displayName} config for "broken" is invalid or incomplete`,
+ );
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+ }
+});
+
it('should reject corrupted encrypted configs without returning ciphertext', async () => {
const { dir, path } = await tempVaultPath();
const ciphertext = 'enc:v1:AAAA';
@@ -321,7 +378,7 @@ it('should reject corrupted encrypted configs without returning ciphertext', asy
const error = await exportPlatformWallet('codex', path, TEST_KEY).catch((value: unknown) => value);
expect(error).toBeInstanceOf(Error);
- expect(String(error)).toContain('Encrypted vault value is malformed');
+ expect(String(error)).toContain('Saved Codex accounts include damaged credentials and cannot be exported');
expect(String(error)).not.toContain(ciphertext);
} finally {
await rm(dir, { force: true, recursive: true });
@@ -330,37 +387,40 @@ it('should reject corrupted encrypted configs without returning ciphertext', asy
it('should decrypt only the requested platform section', async () => {
const { dir, path } = await tempVaultPath();
- const auth = { OPENAI_API_KEY: 'sk-focused-test' };
+ const auth = { auth_mode: 'apikey', OPENAI_API_KEY: 'sk-focused-test' };
try {
- await writeVaultFixture(path, {
- antigravity: {
- data: {
- unrelated: {
- account: 'antigravity',
- createdAt: '',
- kind: 'Generic Password',
- label: 'gemini',
- password: 'enc:v1:invalid',
- service: 'gemini',
- updatedAt: '',
- },
- },
- limits: {},
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.focused = {
+ auth: JSON.stringify(auth),
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
},
- codex: {
- data: {
- focused: {
- auth: await seal(JSON.stringify(auth), TEST_KEY),
- createdAt: '2026-01-01T00:00:00.000Z',
- updatedAt: '2026-01-02T00:00:00.000Z',
- },
+ path,
+ TEST_KEY,
+ );
+ const fixture = JSON.parse(await Bun.file(path).text()) as Record;
+ fixture.antigravity = {
+ data: {
+ unrelated: {
+ account: 'antigravity',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ kind: 'Generic Password',
+ label: 'gemini',
+ password: 'enc:v1:invalid',
+ service: 'gemini',
+ updatedAt: '2026-01-02T00:00:00.000Z',
},
- limits: {},
},
- });
+ limits: {},
+ };
+ await writeVaultFixture(path, fixture);
const exported = await exportPlatformWallet('codex', path, TEST_KEY);
- expect(exported.accounts[0]?.config).toEqual(auth);
+ expect([...exported.accounts][0]?.config).toEqual(auth);
} finally {
await rm(dir, { force: true, recursive: true });
}
@@ -380,23 +440,24 @@ it('should queue an export behind an in-flight vault update', async () => {
let exported: ReturnType | undefined;
try {
- update = updateVault(async () => {
- markStarted();
- await updateGate;
- await writeVaultFixture(path, {
- codex: {
- data: {
- queued: {
- auth: JSON.stringify({ OPENAI_API_KEY: 'sk-queued-test' }),
- createdAt: '2026-01-01T00:00:00.000Z',
- updatedAt: '2026-01-02T00:00:00.000Z',
- },
- },
- limits: {},
- },
- });
- return { result: undefined, write: false };
- }, path);
+ update = updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.queued = {
+ auth: JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: 'sk-queued-test' }),
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ },
+ path,
+ undefined,
+ async () => {
+ markStarted();
+ await updateGate;
+ return TEST_KEY;
+ },
+ );
await updateStarted;
let exportSettled = false;
@@ -414,7 +475,7 @@ it('should queue an export behind an in-flight vault update', async () => {
releaseUpdate();
await update;
- expect((await exported).accounts[0]?.key).toBe('queued');
+ expect([...(await exported).accounts][0]?.key).toBe('queued');
} finally {
releaseUpdate();
await update?.catch(() => undefined);
diff --git a/src/storage/export.ts b/src/storage/export.ts
index b87be57..99f9108 100644
--- a/src/storage/export.ts
+++ b/src/storage/export.ts
@@ -1,5 +1,11 @@
import { decodeToken } from '../antigravity/google.ts';
+import { parseClineProviders } from '../cline/providers.ts';
+import { parseCodexAuth } from '../codex/auth.ts';
+import { ANTIGRAVITY_ACCOUNT, ANTIGRAVITY_SERVICE } from '../config.ts';
import { publicError } from '../errors.ts';
+import { isKiroSnapshotConfigValid } from '../kiro/auth.ts';
+import { parseMiniMaxConfig } from '../minimax/usage.ts';
+import type { VaultSection } from '../types.ts';
import { readVaultSection } from './vault.ts';
export type ExportPlatform = 'antigravity' | 'cline' | 'codex' | 'kiro' | 'minimax';
@@ -12,122 +18,222 @@ const platformNames: Record = {
minimax: 'MiniMax',
};
+export type JsonValue = boolean | JsonObject | JsonValue[] | null | number | string;
+
+export type JsonObject = {
+ [key: string]: JsonValue;
+};
+
+const jsonChildren = (value: unknown) => {
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') {
+ return [];
+ }
+ if (typeof value === 'number') {
+ if (!Number.isFinite(value)) {
+ throw new Error('Config contains a non-finite number');
+ }
+ return [];
+ }
+ if (Array.isArray(value)) {
+ return value;
+ }
+ if (typeof value !== 'object') {
+ throw new Error('Config contains a non-JSON value');
+ }
+ const prototype = Object.getPrototypeOf(value);
+ if (prototype !== Object.prototype && prototype !== null) {
+ throw new Error('Config contains a non-plain object');
+ }
+ return Object.values(value);
+};
+
+const assertJsonObject = (value: unknown) => {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ throw new Error('Config must be a JSON object');
+ }
+ const pending: unknown[] = [value];
+ while (pending.length > 0) {
+ pending.push(...jsonChildren(pending.pop()));
+ }
+ return value as JsonObject;
+};
+
const parseJsonConfig = (value: string, platform: ExportPlatform, key: string) => {
try {
- const parsed = JSON.parse(value) as unknown;
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
- throw new Error('Config must be a JSON object');
- }
- return parsed as Record;
+ return assertJsonObject(JSON.parse(value) as unknown);
} catch {
throw publicError(500, `Saved ${platformNames[platform]} config for "${key}" is not valid JSON`);
}
};
+const parseValidatedConfig = (
+ value: string,
+ platform: ExportPlatform,
+ key: string,
+ validate: (text: string) => unknown,
+) => {
+ const parsed = parseJsonConfig(value, platform, key);
+ if (!validate(value)) {
+ throw publicError(500, `Saved ${platformNames[platform]} config for "${key}" is invalid or incomplete`);
+ }
+ return parsed;
+};
+
const assertAccounts = (platform: ExportPlatform, count: number) => {
if (count === 0) {
throw publicError(404, `No ${platformNames[platform]} accounts are saved to export`);
}
};
-export const exportPlatformWallet = async (platform: ExportPlatform, path?: string, key?: Buffer) => {
- const exportedAt = new Date().toISOString();
+const assertExportable = (platform: ExportPlatform, section: VaultSection) => {
+ if (section.corruptions && Object.keys(section.corruptions).length > 0) {
+ throw publicError(
+ 500,
+ `Saved ${platformNames[platform]} accounts include damaged credentials and cannot be exported`,
+ );
+ }
+};
+
+export type ExportedAccount = {
+ config: JsonObject;
+ key: string;
+ [key: string]: JsonValue;
+};
+
+export type ExportWalletResult = {
+ accounts: Iterable;
+ exportedAt: string;
+ platform: ExportPlatform;
+};
+
+type AccountExporter = (path?: string, key?: Buffer) => Promise>;
- if (platform === 'antigravity') {
- const section = await readVaultSection('antigravity', path, key);
- const entries = Object.entries(section.data);
- assertAccounts(platform, entries.length);
+const mappedAccounts = (
+ platform: ExportPlatform,
+ data: Record,
+ mapper: (accountKey: string, snapshot: Snapshot) => ExportedAccount,
+): Iterable => {
+ const accountKeys = Object.keys(data);
+ assertAccounts(platform, accountKeys.length);
+ return {
+ [Symbol.iterator]: () => {
+ let index = 0;
+ return {
+ next: (): IteratorResult => {
+ const accountKey = accountKeys[index];
+ if (accountKey === undefined) {
+ return { done: true, value: undefined };
+ }
+ index += 1;
+ return { done: false, value: mapper(accountKey, data[accountKey] as Snapshot) };
+ },
+ };
+ },
+ };
+};
+
+const exportAntigravityAccounts: AccountExporter = async (path, key) => {
+ const section = await readVaultSection('antigravity', path, key);
+ assertExportable('antigravity', section);
+ return mappedAccounts('antigravity', section.data, (accountKey, snap) => {
+ if (snap.account !== ANTIGRAVITY_ACCOUNT || snap.service !== ANTIGRAVITY_SERVICE) {
+ throw publicError(500, `Saved Antigravity credentials for "${accountKey}" do not match this installation`);
+ }
+ const tokenPayload = decodeToken(snap.password);
+ if (!tokenPayload) {
+ throw publicError(500, `Saved Antigravity credentials for "${accountKey}" could not be decoded`);
+ }
return {
- accounts: entries.map(([accountKey, snap]) => {
- const tokenPayload = decodeToken(snap.password);
- if (!tokenPayload) {
- throw publicError(500, `Saved Antigravity credentials for "${accountKey}" could not be decoded`);
- }
- return {
- config: {
- account: snap.account,
- kind: snap.kind,
- label: snap.label,
- service: snap.service,
- tokenPayload,
- },
- createdAt: snap.createdAt,
- key: accountKey,
- updatedAt: snap.updatedAt,
- };
+ config: assertJsonObject({
+ account: snap.account,
+ kind: snap.kind,
+ label: snap.label,
+ service: snap.service,
+ tokenPayload,
}),
- exportedAt,
- platform,
+ createdAt: snap.createdAt,
+ key: accountKey,
+ updatedAt: snap.updatedAt,
};
- }
+ });
+};
- if (platform === 'cline') {
- const section = await readVaultSection('cline', path, key);
- const entries = Object.entries(section.data);
- assertAccounts(platform, entries.length);
- return {
- accounts: entries.map(([accountKey, snap]) => ({
- config: parseJsonConfig(snap.secrets, platform, accountKey),
- createdAt: snap.createdAt,
- key: accountKey,
- updatedAt: snap.updatedAt,
- })),
- exportedAt,
- platform,
- };
- }
+const exportClineAccounts: AccountExporter = async (path, key) => {
+ const section = await readVaultSection('cline', path, key);
+ assertExportable('cline', section);
+ return mappedAccounts('cline', section.data, (accountKey, snap) => ({
+ config: parseValidatedConfig(snap.secrets, 'cline', accountKey, parseClineProviders),
+ createdAt: snap.createdAt,
+ key: accountKey,
+ updatedAt: snap.updatedAt,
+ }));
+};
- if (platform === 'codex') {
- const section = await readVaultSection('codex', path, key);
- const entries = Object.entries(section.data);
- assertAccounts(platform, entries.length);
- return {
- accounts: entries.map(([accountKey, snap]) => ({
- config: parseJsonConfig(snap.auth, platform, accountKey),
- createdAt: snap.createdAt,
- key: accountKey,
- updatedAt: snap.updatedAt,
- })),
- exportedAt,
- platform,
- };
- }
+const exportCodexAccounts: AccountExporter = async (path, key) => {
+ const section = await readVaultSection('codex', path, key);
+ assertExportable('codex', section);
+ return mappedAccounts('codex', section.data, (accountKey, snap) => ({
+ config: parseValidatedConfig(snap.auth, 'codex', accountKey, parseCodexAuth),
+ createdAt: snap.createdAt,
+ key: accountKey,
+ updatedAt: snap.updatedAt,
+ }));
+};
- if (platform === 'minimax') {
- const section = await readVaultSection('minimax', path, key);
- const entries = Object.entries(section.data);
- assertAccounts(platform, entries.length);
+const exportKiroAccounts: AccountExporter = async (path, key) => {
+ const section = await readVaultSection('kiro', path, key);
+ assertExportable('kiro', section);
+ return mappedAccounts('kiro', section.data, (accountKey, snap) => {
+ if (!isKiroSnapshotConfigValid(snap)) {
+ throw publicError(500, `Saved Kiro config for "${accountKey}" is invalid or incomplete`);
+ }
return {
- accounts: entries.map(([accountKey, snap]) => ({
- config: parseJsonConfig(snap.config, platform, accountKey),
- createdAt: snap.createdAt,
- key: accountKey,
- updatedAt: snap.updatedAt,
- })),
- exportedAt,
- platform,
+ ...(snap.clientRegistration
+ ? { clientRegistration: parseJsonConfig(snap.clientRegistration, 'kiro', accountKey) }
+ : {}),
+ config: parseJsonConfig(snap.auth, 'kiro', accountKey),
+ createdAt: snap.createdAt,
+ key: accountKey,
+ ...(snap.profile ? { profile: parseJsonConfig(snap.profile, 'kiro', accountKey) } : {}),
+ updatedAt: snap.updatedAt,
};
- }
+ });
+};
- if (platform === 'kiro') {
- const section = await readVaultSection('kiro', path, key);
- const entries = Object.entries(section.data);
- assertAccounts(platform, entries.length);
- return {
- accounts: entries.map(([accountKey, snap]) => ({
- clientRegistration: snap.clientRegistration
- ? parseJsonConfig(snap.clientRegistration, platform, accountKey)
- : undefined,
- config: parseJsonConfig(snap.auth, platform, accountKey),
- createdAt: snap.createdAt,
- key: accountKey,
- profile: snap.profile ? parseJsonConfig(snap.profile, platform, accountKey) : undefined,
- updatedAt: snap.updatedAt,
- })),
- exportedAt,
- platform,
- };
- }
+const exportMinimaxAccounts: AccountExporter = async (path, key) => {
+ const section = await readVaultSection('minimax', path, key);
+ assertExportable('minimax', section);
+ return mappedAccounts('minimax', section.data, (accountKey, snap) => ({
+ config: parseValidatedConfig(snap.config, 'minimax', accountKey, parseMiniMaxConfig),
+ createdAt: snap.createdAt,
+ key: accountKey,
+ updatedAt: snap.updatedAt,
+ }));
+};
+
+const accountExporters: Record = {
+ antigravity: exportAntigravityAccounts,
+ cline: exportClineAccounts,
+ codex: exportCodexAccounts,
+ kiro: exportKiroAccounts,
+ minimax: exportMinimaxAccounts,
+};
- throw publicError(400, `Unsupported export platform: ${String(platform)}`);
+const isExportPlatform = (platform: string): platform is ExportPlatform => {
+ return Object.hasOwn(accountExporters, platform);
+};
+
+export const exportPlatformWallet = async (
+ platform: ExportPlatform,
+ path?: string,
+ key?: Buffer,
+): Promise => {
+ if (!isExportPlatform(platform)) {
+ throw publicError(400, `Unsupported export platform: ${String(platform)}`);
+ }
+ return {
+ accounts: await accountExporters[platform](path, key),
+ exportedAt: new Date().toISOString(),
+ platform,
+ };
};
diff --git a/src/storage/file.test.ts b/src/storage/file.test.ts
new file mode 100644
index 0000000..30c83c9
--- /dev/null
+++ b/src/storage/file.test.ts
@@ -0,0 +1,101 @@
+import { expect, it } from 'bun:test';
+import { mkdtemp, readdir, rm, stat } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import {
+ MAX_LOCAL_CREDENTIAL_FILE_BYTES,
+ readBoundedLocalText,
+ readBoundedTextFile,
+ writePrivateFile,
+} from './file.ts';
+
+it('should return null for absent local credential files', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-bounded-file-test-'));
+ try {
+ await expect(readBoundedLocalText(join(dir, 'missing.json'))).resolves.toBeNull();
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should read bounded UTF-8 credential text', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-bounded-file-test-'));
+ const path = join(dir, 'credential.json');
+ try {
+ await Bun.write(path, 'é');
+ await expect(readBoundedLocalText(path, 2)).resolves.toBe('é');
+ await expect(readBoundedLocalText(path, 1)).rejects.toThrow(
+ 'Local credential file exceeds the 1 byte size limit',
+ );
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should reject files larger than the default limit without exposing content', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-bounded-file-test-'));
+ const path = join(dir, 'credential.json');
+ const secret = `Bearer ${'x'.repeat(MAX_LOCAL_CREDENTIAL_FILE_BYTES)}`;
+ try {
+ await Bun.write(path, secret);
+ const error = await readBoundedLocalText(path).catch((value: unknown) => value);
+ expect((error as { status?: number }).status).toBe(413);
+ expect(String(error)).not.toContain(secret);
+ expect(String(error)).toContain('1 MiB size limit');
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should reject malformed UTF-8 without decoding replacement characters', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-bounded-file-test-'));
+ const path = join(dir, 'credential.json');
+ try {
+ await Bun.write(path, new Uint8Array([0xc3, 0x28]));
+ const error = await readBoundedLocalText(path).catch((value: unknown) => value);
+ expect((error as { status?: number }).status).toBe(400);
+ expect(String(error)).toContain('Local credential file is not valid UTF-8');
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should validate bounded-reader limits before reading a file', async () => {
+ for (const maxBytes of [-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
+ await expect(readBoundedLocalText('/path/does/not/matter', maxBytes)).rejects.toThrow(
+ 'maxBytes must be a finite non-negative integer',
+ );
+ }
+});
+
+it('should preserve stream errors that are unrelated to UTF-8 decoding', async () => {
+ const originalFile = Bun.file;
+ const streamError = new TypeError('simulated stream failure');
+ Bun.file = (() => ({
+ exists: async () => true,
+ stream: () =>
+ new ReadableStream({
+ pull: (controller) => controller.error(streamError),
+ }),
+ })) as unknown as typeof Bun.file;
+ try {
+ await expect(readBoundedTextFile('simulated')).rejects.toBe(streamError);
+ } finally {
+ Bun.file = originalFile;
+ }
+});
+
+it('should atomically replace private files with mode 0600 and clean up its temporary file', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-private-file-test-'));
+ const path = join(dir, 'credential.json');
+ try {
+ await Bun.write(path, 'old');
+ await writePrivateFile(path, 'new');
+
+ expect(await Bun.file(path).text()).toBe('new');
+ expect((await stat(path)).mode & 0o777).toBe(0o600);
+ expect((await readdir(dir)).sort()).toEqual(['credential.json']);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
diff --git a/src/storage/file.ts b/src/storage/file.ts
index 145e7ae..d82823e 100644
--- a/src/storage/file.ts
+++ b/src/storage/file.ts
@@ -1,20 +1,103 @@
import { randomUUID } from 'node:crypto';
import { chmod, mkdir, open, rename, rm } from 'node:fs/promises';
import { dirname } from 'node:path';
+import { publicError } from '../errors.ts';
+
+export const MAX_LOCAL_CREDENTIAL_FILE_BYTES = 1024 * 1024;
+
+type BoundedTextOptions = {
+ errorStatus?: number;
+ label?: string;
+ maxBytes?: number;
+};
+
+const sizeLabel = (bytes: number) => {
+ if (bytes > 0 && bytes % (1024 * 1024) === 0) {
+ return `${bytes / (1024 * 1024)} MiB`;
+ }
+ return `${bytes} ${bytes === 1 ? 'byte' : 'bytes'}`;
+};
+
+const decodeUtf8 = (
+ decoder: TextDecoder,
+ value: Uint8Array | undefined,
+ label: string,
+ errorStatus: number,
+ stream = false,
+) => {
+ try {
+ return decoder.decode(value, { stream });
+ } catch (error) {
+ if (error instanceof TypeError) {
+ throw publicError(errorStatus, `${label} is not valid UTF-8`);
+ }
+ throw error;
+ }
+};
+
+export const readBoundedTextFile = async (path: string, options: BoundedTextOptions = {}) => {
+ const label = options.label ?? 'Local credential file';
+ const maxBytes = options.maxBytes ?? MAX_LOCAL_CREDENTIAL_FILE_BYTES;
+ const errorStatus = options.errorStatus ?? 400;
+ if (!Number.isFinite(maxBytes) || !Number.isInteger(maxBytes) || maxBytes < 0) {
+ throw new TypeError('maxBytes must be a finite non-negative integer');
+ }
+ const file = Bun.file(path);
+ if (!(await file.exists())) {
+ return null;
+ }
+
+ const reader = file.stream().getReader();
+ const decoder = new TextDecoder('utf-8', { fatal: true });
+ const chunks: string[] = [];
+ let total = 0;
+ try {
+ for (;;) {
+ const next = await reader.read();
+ if (next.done) {
+ break;
+ }
+ total += next.value.byteLength;
+ if (total > maxBytes) {
+ await reader.cancel().catch(() => undefined);
+ throw publicError(
+ errorStatus === 500 ? 500 : 413,
+ `${label} exceeds the ${sizeLabel(maxBytes)} size limit`,
+ );
+ }
+ chunks.push(decodeUtf8(decoder, next.value, label, errorStatus, true));
+ }
+ chunks.push(decodeUtf8(decoder, undefined, label, errorStatus));
+ } finally {
+ reader.releaseLock();
+ }
+ return chunks.join('');
+};
+
+export const readBoundedLocalText = async (path: string, maxBytes = MAX_LOCAL_CREDENTIAL_FILE_BYTES) => {
+ return readBoundedTextFile(path, { maxBytes });
+};
export const writePrivateFile = async (path: string, text: string) => {
- await mkdir(dirname(path), { recursive: true });
+ const parent = dirname(path);
+ await mkdir(parent, { recursive: true });
const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
let handle: Awaited> | undefined;
try {
- handle = await open(tempPath, 'w', 0o600);
+ handle = await open(tempPath, 'wx', 0o600);
await handle.writeFile(text, 'utf8');
await handle.sync();
await handle.close();
handle = undefined;
await rename(tempPath, path);
await chmod(path, 0o600);
+ const directory = await open(parent, 'r');
+ try {
+ await directory.sync();
+ } finally {
+ await directory.close();
+ }
} catch (error) {
await handle?.close().catch(() => {});
await rm(tempPath, { force: true }).catch(() => {});
diff --git a/src/storage/secret.test.ts b/src/storage/secret.test.ts
new file mode 100644
index 0000000..93584cf
--- /dev/null
+++ b/src/storage/secret.test.ts
@@ -0,0 +1,129 @@
+import { expect, it } from 'bun:test';
+import type { run } from '../shell.ts';
+import { createVaultKeyProvider, storeVaultSecret } from './secret.ts';
+
+it('should store vault-key material through prompted stdin without putting it in argv', async () => {
+ const secret = 'private-vault-key-material';
+ let invocation: { args: string[]; stdin: string | undefined } | undefined;
+ let command = '';
+ const runCommand = (async (cmd, args, options) => {
+ command = cmd;
+ invocation = { args, stdin: options?.stdin };
+ return { stderr: '', stdout: '' };
+ }) as typeof run;
+
+ await storeVaultSecret(secret, runCommand);
+
+ expect(invocation?.args.at(-1)).toBe('-w');
+ expect(invocation?.args).not.toContain(secret);
+ expect(invocation?.stdin).toBe(`${secret}\n${secret}\n`);
+ expect(command).toBe('/usr/bin/security');
+});
+
+it('should deduplicate concurrent cold vault-key lookups', async () => {
+ let calls = 0;
+ const runCommand = (async (_cmd, args) => {
+ calls += 1;
+ expect(args[0]).toBe('find-generic-password');
+ await Bun.sleep(10);
+ return { stderr: '', stdout: 'shared-key\n' };
+ }) as typeof run;
+
+ const vaultKey = createVaultKeyProvider(runCommand);
+ const keys = await Promise.all([vaultKey('existing'), vaultKey('existing'), vaultKey('existing')]);
+
+ expect(calls).toBe(1);
+ expect(keys[0]).toEqual(keys[1]);
+ expect(keys[1]).toEqual(keys[2]);
+});
+
+it('should retry a cold vault-key lookup after rejection', async () => {
+ let calls = 0;
+ const runCommand = (async () => {
+ calls += 1;
+ if (calls === 1) {
+ throw new Error('transient lookup failure');
+ }
+ return { stderr: '', stdout: 'recovered-key\n' };
+ }) as typeof run;
+
+ const vaultKey = createVaultKeyProvider(runCommand);
+ await expect(vaultKey('existing')).rejects.toThrow('Dondo could not access the vault key in macOS Keychain');
+ await expect(vaultKey('existing')).resolves.toBeInstanceOf(Buffer);
+ expect(calls).toBe(2);
+});
+
+it('should reject a successful Keychain write when the persisted vault key is empty', async () => {
+ let calls = 0;
+ const runCommand = (async (_cmd, args) => {
+ calls += 1;
+ if (args[0] === 'add-generic-password') {
+ return { stderr: '', stdout: '' };
+ }
+ return { stderr: '', stdout: '' };
+ }) as typeof run;
+
+ const vaultKey = createVaultKeyProvider(runCommand);
+ await expect(vaultKey('create')).rejects.toThrow('Dondo could not verify the vault key in macOS Keychain');
+ expect(calls).toBe(3);
+});
+
+it('should never create a vault key when an existing key is required', async () => {
+ const commands: string[] = [];
+ const runCommand = (async (_cmd, args) => {
+ commands.push(args[0] ?? '');
+ return { stderr: '', stdout: '' };
+ }) as typeof run;
+
+ const vaultKey = createVaultKeyProvider(runCommand);
+ await expect(vaultKey('existing')).rejects.toThrow(
+ 'The Dondo vault key is missing from macOS Keychain; the encrypted vault cannot be opened',
+ );
+ expect(commands).toEqual(['find-generic-password']);
+});
+
+it('should create and verify a vault key only when creation is explicitly allowed', async () => {
+ const commands: string[] = [];
+ let stored = '';
+ const runCommand = (async (_cmd, args, options) => {
+ commands.push(args[0] ?? '');
+ if (args[0] === 'add-generic-password') {
+ stored = options?.stdin?.split('\n')[0] ?? '';
+ return { stderr: '', stdout: '' };
+ }
+ return { stderr: '', stdout: stored ? `${stored}\n` : '' };
+ }) as typeof run;
+
+ const vaultKey = createVaultKeyProvider(runCommand);
+ await expect(vaultKey('create')).resolves.toBeInstanceOf(Buffer);
+ expect(commands).toEqual(['find-generic-password', 'add-generic-password', 'find-generic-password']);
+});
+
+it('should not escalate an in-flight existing-only lookup when creation is requested concurrently', async () => {
+ const commands: string[] = [];
+ let releaseLookup: (() => void) | undefined;
+ const lookupStarted = Promise.withResolvers();
+ const lookupGate = new Promise((resolve) => {
+ releaseLookup = resolve;
+ });
+ const runCommand = (async (_cmd, args) => {
+ commands.push(args[0] ?? '');
+ lookupStarted.resolve();
+ await lookupGate;
+ return { stderr: '', stdout: '' };
+ }) as typeof run;
+
+ const vaultKey = createVaultKeyProvider(runCommand);
+ const existing = vaultKey('existing');
+ await lookupStarted.promise;
+ const create = vaultKey('create');
+ releaseLookup?.();
+
+ const outcomes = await Promise.allSettled([existing, create]);
+ expect(outcomes.every((outcome) => outcome.status === 'rejected')).toBe(true);
+ expect(outcomes.map((outcome) => String(outcome.status === 'rejected' ? outcome.reason : ''))).toEqual([
+ expect.stringContaining('vault key is missing'),
+ expect.stringContaining('vault key is missing'),
+ ]);
+ expect(commands).toEqual(['find-generic-password']);
+});
diff --git a/src/storage/secret.ts b/src/storage/secret.ts
index 47daf84..f96bc59 100644
--- a/src/storage/secret.ts
+++ b/src/storage/secret.ts
@@ -1,13 +1,23 @@
import { createHash, randomBytes } from 'node:crypto';
import { VAULT_KEY_ACCOUNT, VAULT_KEY_SERVICE } from '../config.ts';
+import { publicError } from '../errors.ts';
import { isRunError, run } from '../shell.ts';
-let cachedKey: Buffer | undefined;
+export type VaultKeyMode = 'create' | 'existing';
+
+const SECURITY_PATH = '/usr/bin/security';
const digestSecret = (secret: string) => createHash('sha256').update(secret).digest();
-const readVaultSecret = async () => {
- return await run('security', ['find-generic-password', '-s', VAULT_KEY_SERVICE, '-a', VAULT_KEY_ACCOUNT, '-w'])
+const readVaultSecret = async (runCommand: typeof run) => {
+ return await runCommand(SECURITY_PATH, [
+ 'find-generic-password',
+ '-s',
+ VAULT_KEY_SERVICE,
+ '-a',
+ VAULT_KEY_ACCOUNT,
+ '-w',
+ ])
.then(({ stdout }) => stdout.trim())
.catch((error) => {
if (isRunError(error) && error.code === 44) {
@@ -17,34 +27,69 @@ const readVaultSecret = async () => {
});
};
-export const vaultKey = async () => {
- if (cachedKey) {
- return cachedKey;
+export const storeVaultSecret = async (secret: string, runCommand: typeof run = run) => {
+ await runCommand(SECURITY_PATH, ['add-generic-password', '-s', VAULT_KEY_SERVICE, '-a', VAULT_KEY_ACCOUNT, '-w'], {
+ stdin: `${secret}\n${secret}\n`,
+ });
+};
+
+const loadExistingVaultKey = async (runCommand: typeof run) => {
+ const found = await readVaultSecret(runCommand).catch(() => {
+ throw publicError(500, 'Dondo could not access the vault key in macOS Keychain');
+ });
+ if (found) {
+ return digestSecret(found);
}
+ throw publicError(500, 'The Dondo vault key is missing from macOS Keychain; the encrypted vault cannot be opened');
+};
- const found = await readVaultSecret();
+const loadOrCreateVaultKey = async (runCommand: typeof run) => {
+ const found = await readVaultSecret(runCommand).catch(() => {
+ throw publicError(500, 'Dondo could not access the vault key in macOS Keychain');
+ });
if (found) {
- cachedKey = digestSecret(found);
- return cachedKey;
+ return digestSecret(found);
}
const secret = randomBytes(32).toString('base64');
- await run('security', [
- 'add-generic-password',
- '-s',
- VAULT_KEY_SERVICE,
- '-a',
- VAULT_KEY_ACCOUNT,
- '-w',
- secret,
- ]).catch(async (error) => {
- const racedSecret = await readVaultSecret();
+ await storeVaultSecret(secret, runCommand).catch(async () => {
+ const racedSecret = await readVaultSecret(runCommand).catch(() => '');
if (racedSecret) {
return;
}
- throw error;
+ throw publicError(500, 'Dondo could not persist the vault key in macOS Keychain');
+ });
+
+ const stored = await readVaultSecret(runCommand).catch(() => {
+ throw publicError(500, 'Dondo could not verify the vault key in macOS Keychain');
});
+ if (!stored) {
+ throw publicError(500, 'Dondo could not verify the vault key in macOS Keychain');
+ }
+ return digestSecret(stored);
+};
- cachedKey = digestSecret((await readVaultSecret()) || secret);
- return cachedKey;
+export const createVaultKeyProvider = (runCommand: typeof run = run) => {
+ let cachedKey: Buffer | undefined;
+ let pendingKey: Promise | undefined;
+ return async (mode: VaultKeyMode) => {
+ if (cachedKey) {
+ return cachedKey;
+ }
+ if (pendingKey) {
+ return pendingKey;
+ }
+ const load = mode === 'existing' ? loadExistingVaultKey : loadOrCreateVaultKey;
+ pendingKey = load(runCommand)
+ .then((key) => {
+ cachedKey = key;
+ return key;
+ })
+ .finally(() => {
+ pendingKey = undefined;
+ });
+ return pendingKey;
+ };
};
+
+export const vaultKey = createVaultKeyProvider();
diff --git a/src/storage/vault.test.ts b/src/storage/vault.test.ts
index bb3c6e0..5751bb4 100644
--- a/src/storage/vault.test.ts
+++ b/src/storage/vault.test.ts
@@ -1,41 +1,72 @@
import { expect, it } from 'bun:test';
-import { mkdtemp, rm, stat } from 'node:fs/promises';
+import { readFileSync, writeFileSync } from 'node:fs';
+import { mkdtemp, rm, stat, truncate, utimes } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
-import { readVault, updateVault, writeVault } from './vault.ts';
+import {
+ MAX_VAULT_ACCOUNTS_PER_PLATFORM,
+ MAX_VAULT_FILE_BYTES,
+ MAX_VAULT_MODELS_PER_ACCOUNT,
+ readVaultSection,
+ updateVaultSection,
+} from './vault.ts';
+
+const TEST_KEY = Buffer.alloc(32, 3);
const tempVault = async () => {
const dir = await mkdtemp(join(tmpdir(), 'dondo-vault-test-'));
return { dir, path: join(dir, 'vault.json') };
};
-it('should read a missing vault as nested empty platform sections', async () => {
+const runChild = async (code: string) => {
+ const child = Bun.spawn(['bun', '-e', code], { stderr: 'pipe', stdout: 'pipe' });
+ const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
+ if (exitCode !== 0) {
+ throw new Error(`Child process failed (${exitCode}): ${stderr}`);
+ }
+};
+
+it('should read missing platform sections as empty', async () => {
const { dir, path } = await tempVault();
try {
- await expect(readVault(path)).resolves.toEqual({
- antigravity: { data: {}, limits: {} },
- codex: { data: {}, limits: {} },
- cline: { data: {}, limits: {} },
- kiro: { data: {}, limits: {} },
- minimax: { data: {}, limits: {} },
- });
+ const sections = await Promise.all([
+ readVaultSection('antigravity', path, TEST_KEY),
+ readVaultSection('cline', path, TEST_KEY),
+ readVaultSection('codex', path, TEST_KEY),
+ readVaultSection('kiro', path, TEST_KEY),
+ readVaultSection('minimax', path, TEST_KEY),
+ ]);
+ expect(sections).toEqual([
+ { data: {}, limits: {} },
+ { data: {}, limits: {} },
+ { data: {}, limits: {} },
+ { data: {}, limits: {} },
+ { data: {}, limits: {} },
+ ]);
} finally {
await rm(dir, { force: true, recursive: true });
}
});
-it('should write the vault with private file permissions', async () => {
+it('should write a platform section with private file permissions', async () => {
const { dir, path } = await tempVault();
try {
- await writeVault(
- {
- antigravity: { data: {}, limits: {} },
- codex: { data: {}, limits: {} },
- cline: { data: {}, limits: {} },
- kiro: { data: {}, limits: {} },
- minimax: { data: {}, limits: {} },
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.private = {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ section.limits.private = {
+ fetchedAt: '2026-01-03T00:00:00.000Z',
+ quota: { error: 'private', ok: false },
+ };
+ return { result: undefined };
},
path,
+ TEST_KEY,
);
expect((await stat(path)).mode & 0o777).toBe(0o600);
@@ -49,7 +80,9 @@ it('should include the vault path in corrupt JSON errors', async () => {
try {
await Bun.write(path, '{');
- await expect(readVault(path)).rejects.toThrow(`Vault file is not valid JSON: ${path}`);
+ await expect(readVaultSection('codex', path, TEST_KEY)).rejects.toThrow(
+ `Vault file is not valid JSON: ${path}`,
+ );
} finally {
await rm(dir, { force: true, recursive: true });
}
@@ -59,49 +92,1442 @@ it('should serialize queued vault updates', async () => {
const { dir, path } = await tempVault();
try {
await Promise.all([
- updateVault(async (vault) => {
- vault.antigravity.limits.a = {
- fetchedAt: 'a',
- quota: { error: 'a', ok: false },
+ updateVaultSection(
+ 'antigravity',
+ (section) => {
+ section.data.a = {
+ account: 'antigravity',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ identity: 'google-user',
+ kind: 'Generic Password',
+ label: 'a',
+ password: 'a',
+ service: 'a',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ section.limits.a = {
+ fetchedAt: '2026-01-03T00:00:00.000Z',
+ quota: { error: 'a', ok: false },
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ ),
+ updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.b = {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ section.limits.b = {
+ fetchedAt: '2026-01-03T00:00:00.000Z',
+ quota: { error: 'b', ok: false },
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ ),
+ updateVaultSection(
+ 'minimax',
+ (section) => {
+ section.data.c = {
+ config: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ section.limits.c = {
+ fetchedAt: '2026-01-03T00:00:00.000Z',
+ quota: { error: 'c', ok: false },
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ ),
+ updateVaultSection(
+ 'cline',
+ (section) => {
+ section.data.e = {
+ createdAt: '2026-01-01T00:00:00.000Z',
+ secrets: '{}',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ section.limits.e = {
+ fetchedAt: '2026-01-03T00:00:00.000Z',
+ quota: { error: 'e', ok: false },
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ ),
+ updateVaultSection(
+ 'kiro',
+ (section) => {
+ section.data.d = {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ section.limits.d = {
+ fetchedAt: '2026-01-03T00:00:00.000Z',
+ quota: { error: 'd', ok: false },
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ ),
+ ]);
+
+ const [antigravity, codex, minimax, kiro, cline] = await Promise.all([
+ readVaultSection('antigravity', path, TEST_KEY),
+ readVaultSection('codex', path, TEST_KEY),
+ readVaultSection('minimax', path, TEST_KEY),
+ readVaultSection('kiro', path, TEST_KEY),
+ readVaultSection('cline', path, TEST_KEY),
+ ]);
+ expect(antigravity.limits.a).toBeDefined();
+ expect(codex.limits.b).toBeDefined();
+ expect(minimax.limits.c).toBeDefined();
+ expect(kiro.limits.d).toBeDefined();
+ expect(cline.limits.e).toBeDefined();
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should serialize operations per vault path without blocking a different path', async () => {
+ const first = await tempVault();
+ const second = await tempVault();
+ let releaseFirst = () => {};
+ let markFirstStarted = () => {};
+ const firstGate = new Promise((resolve) => {
+ releaseFirst = resolve;
+ });
+ const firstStarted = new Promise((resolve) => {
+ markFirstStarted = resolve;
+ });
+ let firstUpdate: Promise | undefined;
+
+ try {
+ firstUpdate = updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.first = {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
};
+ section.limits.first = { fetchedAt: '2026-01-03T00:00:00.000Z', quota: { error: 'first', ok: false } };
return { result: undefined };
- }, path),
- updateVault(async (vault) => {
- vault.codex.limits.b = {
- fetchedAt: 'b',
- quota: { error: 'b', ok: false },
+ },
+ first.path,
+ undefined,
+ async () => {
+ markFirstStarted();
+ await firstGate;
+ return TEST_KEY;
+ },
+ );
+ await firstStarted;
+
+ await expect(
+ updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.second = {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ section.limits.second = {
+ fetchedAt: '2026-01-03T00:00:00.000Z',
+ quota: { error: 'second', ok: false },
+ };
+ return { result: undefined };
+ },
+ second.path,
+ TEST_KEY,
+ ),
+ ).resolves.toBeUndefined();
+ } finally {
+ releaseFirst();
+ await firstUpdate?.catch(() => undefined);
+ await rm(first.dir, { force: true, recursive: true });
+ await rm(second.dir, { force: true, recursive: true });
+ }
+});
+
+it('should queue reads behind writes on the same vault path', async () => {
+ const { dir, path } = await tempVault();
+ let releaseUpdate = () => {};
+ let markStarted = () => {};
+ const gate = new Promise((resolve) => {
+ releaseUpdate = resolve;
+ });
+ const started = new Promise((resolve) => {
+ markStarted = resolve;
+ });
+ let update: Promise | undefined;
+ let read: ReturnType> | undefined;
+
+ try {
+ update = updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.queued = {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ section.limits.queued = {
+ fetchedAt: '2026-01-03T00:00:00.000Z',
+ quota: { error: 'queued', ok: false },
+ };
+ return { result: undefined };
+ },
+ path,
+ undefined,
+ async () => {
+ markStarted();
+ await gate;
+ return TEST_KEY;
+ },
+ );
+ await started;
+ read = readVaultSection('codex', path, TEST_KEY);
+ let settled = false;
+ void read.then(() => {
+ settled = true;
+ });
+ await Bun.sleep(10);
+ expect(settled).toBe(false);
+
+ releaseUpdate();
+ expect((await read).limits.queued).toBeDefined();
+ } finally {
+ releaseUpdate();
+ await update?.catch(() => undefined);
+ await read?.catch(() => undefined);
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should isolate corrupt entries and preserve their raw values during a section update', async () => {
+ const { dir, path } = await tempVault();
+ const damagedAuth = 'enc:v1:AAAA';
+ try {
+ await updateVaultSection(
+ 'antigravity',
+ (section) => {
+ section.data.untouched = {
+ account: 'antigravity',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ identity: 'google-user',
+ kind: 'Generic Password',
+ label: 'gemini',
+ password: 'go-keyring-base64:healthy',
+ service: 'gemini',
+ updatedAt: '2026-01-02T00:00:00.000Z',
};
return { result: undefined };
- }, path),
- updateVault(async (vault) => {
- vault.minimax.limits.c = {
- fetchedAt: 'c',
- quota: { error: 'c', ok: false },
+ },
+ path,
+ TEST_KEY,
+ );
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.healthy = {
+ auth: JSON.stringify({ OPENAI_API_KEY: 'healthy' }),
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
};
return { result: undefined };
- }, path),
- updateVault(async (vault) => {
- vault.cline.limits.e = {
- fetchedAt: 'e',
- quota: { error: 'e', ok: false },
+ },
+ path,
+ TEST_KEY,
+ );
+ const fixture = JSON.parse(await Bun.file(path).text()) as {
+ antigravity: { data: Record; limits: Record };
+ cline?: unknown;
+ codex: { data: Record; limits: Record };
+ };
+ fixture.cline = {
+ data: {
+ untouched: {
+ createdAt: '2026-01-01T00:00:00.000Z',
+ secrets: 'enc:v1:invalid',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ },
+ },
+ limits: {},
+ };
+ fixture.codex.data.damaged = {
+ auth: damagedAuth,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ await Bun.write(path, JSON.stringify(fixture, null, 4));
+
+ const section = await readVaultSection('codex', path, TEST_KEY);
+ expect(JSON.parse(section.data.healthy?.auth ?? '{}')).toEqual({ OPENAI_API_KEY: 'healthy' });
+ expect(section.data.damaged).toBeUndefined();
+ expect(section.corruptions?.damaged).toEqual({
+ corrupted: true,
+ error: 'Stored encrypted credentials could not be opened',
+ });
+
+ const before = JSON.parse(await Bun.file(path).text()) as typeof fixture;
+ await updateVaultSection(
+ 'codex',
+ (current) => {
+ current.limits.healthy = {
+ fetchedAt: '2026-01-03T00:00:00.000Z',
+ quota: { error: 'redacted-safe', ok: false },
};
return { result: undefined };
- }, path),
- updateVault(async (vault) => {
- vault.kiro.limits.d = {
- fetchedAt: 'd',
- quota: { error: 'd', ok: false },
+ },
+ path,
+ TEST_KEY,
+ );
+ const after = JSON.parse(await Bun.file(path).text()) as typeof fixture;
+
+ expect((after.codex.data.damaged as { auth?: unknown } | undefined)?.auth).toBe(damagedAuth);
+ expect(JSON.stringify(after.antigravity)).toBe(JSON.stringify(before.antigravity));
+ expect(after.cline).toEqual(before.cline);
+
+ await updateVaultSection(
+ 'codex',
+ (current) => {
+ if (current.corruptions) {
+ delete current.corruptions.damaged;
+ }
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ );
+ const afterDelete = JSON.parse(await Bun.file(path).text()) as typeof fixture;
+ expect(afterDelete.codex.data.damaged).toBeUndefined();
+ expect(JSON.stringify(afterDelete.antigravity)).toBe(JSON.stringify(before.antigravity));
+ expect(afterDelete.cline).toEqual(before.cline);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should hard-reject plaintext stored secret fields as corruption', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ await Bun.write(
+ path,
+ JSON.stringify({
+ codex: {
+ data: {
+ plaintext: {
+ auth: '{"OPENAI_API_KEY":"secret"}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ },
+ },
+ limits: {},
+ },
+ }),
+ );
+
+ const section = await readVaultSection('codex', path, TEST_KEY);
+ expect(section.data).toEqual({});
+ expect(section.corruptions?.plaintext?.corrupted).toBe(true);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should propagate a global vault-key failure instead of marking entries corrupt', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ await Bun.write(
+ path,
+ JSON.stringify({
+ codex: {
+ data: {
+ saved: {
+ auth: 'enc:v2:AAAA',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ },
+ },
+ limits: {},
+ },
+ }),
+ );
+ let lookups = 0;
+ const unavailableKey = async () => {
+ lookups += 1;
+ throw new Error('Keychain authorization failed');
+ };
+
+ await expect(readVaultSection('codex', path, undefined, unavailableKey)).rejects.toThrow(
+ 'Keychain authorization failed',
+ );
+ expect(lookups).toBe(1);
+
+ await Bun.write(path, JSON.stringify({ codex: { data: {}, limits: {} } }));
+ await expect(readVaultSection('codex', path, undefined, unavailableKey)).resolves.toEqual({
+ data: {},
+ limits: {},
+ });
+ expect(lookups).toBe(1);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should require an existing key for enc:v2 without creating a replacement', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.saved = {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
};
return { result: undefined };
- }, path),
- ]);
+ },
+ path,
+ TEST_KEY,
+ );
+ const modes: string[] = [];
+ const missingExistingKey = async (mode: 'create' | 'existing') => {
+ modes.push(mode);
+ throw new Error('Vault encryption key is unavailable from macOS Keychain');
+ };
+
+ await expect(readVaultSection('codex', path, undefined, missingExistingKey)).rejects.toThrow(
+ 'Vault encryption key is unavailable from macOS Keychain',
+ );
+ expect(modes).toEqual(['existing']);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should create a key only for the first enc:v2 row, including alongside damaged v1 rows', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ await Bun.write(
+ path,
+ JSON.stringify({
+ codex: {
+ data: {
+ legacy: {
+ auth: 'enc:v1:AAAA',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ },
+ },
+ limits: {},
+ },
+ }),
+ );
+ const modes: string[] = [];
+ const createKey = async (mode: 'create' | 'existing') => {
+ modes.push(mode);
+ return TEST_KEY;
+ };
+
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.current = {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ },
+ path,
+ undefined,
+ createKey,
+ );
+ expect(modes).toEqual(['create']);
+
+ modes.length = 0;
+ const section = await readVaultSection('codex', path, undefined, createKey);
+ expect(section.data.current?.auth).toBe('{}');
+ expect(section.corruptions?.legacy?.corrupted).toBe(true);
+ expect(modes).toEqual(['existing']);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should bind ciphertext to its account key and plaintext metadata', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.first = {
+ auth: '{"OPENAI_API_KEY":"bound"}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ );
+ const original = JSON.parse(await Bun.file(path).text()) as {
+ codex: { data: Record>; limits: Record };
+ };
+
+ original.codex.data.second = original.codex.data.first as Record;
+ await Bun.write(path, JSON.stringify(original));
+ const transplanted = await readVaultSection('codex', path, TEST_KEY);
+ expect(transplanted.data.first).toBeDefined();
+ expect(transplanted.corruptions?.second?.corrupted).toBe(true);
+
+ const tampered = JSON.parse(await Bun.file(path).text()) as typeof original;
+ const first = tampered.codex.data.first as Record;
+ first.updatedAt = '2026-01-03T00:00:00.000Z';
+ delete tampered.codex.data.second;
+ await Bun.write(path, JSON.stringify(tampered));
+ const metadataTamper = await readVaultSection('codex', path, TEST_KEY);
+ expect(metadataTamper.corruptions?.first?.corrupted).toBe(true);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should reuse unchanged healthy ciphertext and reseal only changed accounts', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.a = {
+ auth: '{"account":"a"}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ section.data.b = {
+ auth: '{"account":"b"}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ );
+ const rawData = async () => {
+ const stored = JSON.parse(await Bun.file(path).text()) as {
+ codex: { data: Record> };
+ };
+ return stored.codex.data;
+ };
+ const initial = await rawData();
+
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.limits.a = { fetchedAt: '2026-01-03T00:00:00.000Z', quota: { error: 'cached', ok: false } };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ );
+ const limitsOnly = await rawData();
+ expect(limitsOnly).toEqual(initial);
+
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ const a = section.data.a;
+ if (a) {
+ a.updatedAt = '2026-01-01T00:00:00.000Z';
+ }
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ );
+ const changed = await rawData();
+ expect(changed.a?.auth).not.toBe(initial.a?.auth);
+ expect(changed.b).toEqual(initial.b);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should cap decoded and staged secret strings by exact UTF-8 bytes', async () => {
+ const { dir, path } = await tempVault();
+ const atLimit = 'é'.repeat((1024 * 1024) / 2);
+ try {
+ await expect(
+ updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.allowed = {
+ auth: atLimit,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ ),
+ ).resolves.toBeUndefined();
+
+ const before = await Bun.file(path).text();
+ await expect(
+ updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.tooLarge = {
+ auth: `${atLimit}é`,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ ),
+ ).rejects.toThrow('Stored account secret exceeds the 1 MiB size limit');
+ expect(await Bun.file(path).text()).toBe(before);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should fail closed on malformed vault shapes without rewriting them', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ const original = '["do-not-overwrite"]';
+ await Bun.write(path, original);
+
+ await expect(
+ updateVaultSection(
+ 'codex',
+ (section) => {
+ section.limits.changed = { fetchedAt: '2026-01-03T00:00:00.000Z', quota: { error: '', ok: false } };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ ),
+ ).rejects.toThrow('Vault file has an invalid top-level shape');
+ expect(await Bun.file(path).text()).toBe(original);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should reject explicitly malformed platform sections instead of defaulting them', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ await Bun.write(path, '{"codex":null}');
+
+ await expect(readVaultSection('codex', path, TEST_KEY)).rejects.toThrow('invalid codex section');
+ expect(await Bun.file(path).text()).toBe('{"codex":null}');
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should reject explicitly present platform sections missing data or limits', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ for (const codex of [{}, { data: {} }, { limits: {} }]) {
+ const original = JSON.stringify({ codex });
+ await Bun.write(path, original);
+ await expect(readVaultSection('codex', path, TEST_KEY)).rejects.toThrow('invalid codex section');
+ expect(await Bun.file(path).text()).toBe(original);
+ }
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should reject malformed or residue-bearing untouched platform sections', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ for (const cline of [[], { data: {}, legacy: { password: 'plaintext' }, limits: {} }]) {
+ const original = JSON.stringify({ cline, codex: { data: {}, limits: {} } });
+ await Bun.write(path, original);
+ await expect(readVaultSection('codex', path, TEST_KEY)).rejects.toThrow('invalid cline section');
+ expect(await Bun.file(path).text()).toBe(original);
+ }
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should reject historical flat and unknown top-level vault fields without rewriting them', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ for (const original of ['{"data":{"secret":"plaintext"},"limits":{}}', '{"futurePlatform":{"secret":true}}']) {
+ await Bun.write(path, original);
+ await expect(readVaultSection('codex', path, TEST_KEY)).rejects.toThrow('invalid top-level key');
+ expect(await Bun.file(path).text()).toBe(original);
+ }
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should isolate entries with unexpected or malformed snapshot fields', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.valid = {
+ auth: '{"OPENAI_API_KEY":"safe"}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ );
+ const stored = JSON.parse(await Bun.file(path).text()) as {
+ codex: { data: Record>; limits: Record };
+ };
+ const valid = stored.codex.data.valid as Record;
+ await Bun.write(
+ path,
+ JSON.stringify({
+ codex: {
+ data: {
+ missing: { auth: valid.auth, updatedAt: '2026-01-02T00:00:00.000Z' },
+ unexpected: { ...valid, plaintext: 'secret' },
+ valid,
+ wrongType: { ...valid, createdAt: 1 },
+ },
+ limits: {},
+ },
+ }),
+ );
+
+ const section = await readVaultSection('codex', path, TEST_KEY);
+ expect(Object.keys(section.data)).toEqual(['valid']);
+ expect(Object.keys(section.corruptions ?? {}).sort()).toEqual(['missing', 'unexpected', 'wrongType']);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should reject invalid stored account keys and orphan cached limits', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.valid = {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ );
+ const stored = JSON.parse(await Bun.file(path).text()) as {
+ codex: { data: Record> };
+ };
+ const valid = stored.codex.data.valid;
+ for (const codex of [
+ { data: { constructor: valid }, limits: {} },
+ {
+ data: { valid },
+ limits: { orphan: { fetchedAt: '2026-01-03T00:00:00.000Z', quota: { error: '', ok: false } } },
+ },
+ ]) {
+ await Bun.write(path, JSON.stringify({ codex }));
+ await expect(readVaultSection('codex', path, TEST_KEY)).rejects.toThrow(
+ /invalid (stored account key|orphan cached limits)/,
+ );
+ }
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should refuse callback output that would make the vault invalid', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.valid = {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ );
+ const original = await Bun.file(path).text();
+
+ await expect(
+ updateVaultSection(
+ 'codex',
+ (section) => {
+ section.limits.orphan = { fetchedAt: '2026-01-03T00:00:00.000Z', quota: { error: '', ok: false } };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ ),
+ ).rejects.toThrow('invalid orphan cached limits');
+ expect(await Bun.file(path).text()).toBe(original);
+
+ await expect(
+ updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.invalid = {
+ auth: '{}',
+ createdAt: 'yesterday',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ ),
+ ).rejects.toThrow('Stored account timestamp is invalid');
+ expect(await Bun.file(path).text()).toBe(original);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should reject an asynchronous vault update callback without replacing the vault', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.original = {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ );
+ const original = await Bun.file(path).text();
+ const unsafeUpdate = updateVaultSection as unknown as (
+ platform: 'codex',
+ operation: (section: unknown) => unknown,
+ path: string,
+ key: Buffer,
+ ) => Promise;
+
+ await expect(unsafeUpdate('codex', async () => ({ result: undefined }), path, TEST_KEY)).rejects.toThrow(
+ 'Vault update callback must be synchronous',
+ );
+ await expect(unsafeUpdate('codex', () => null, path, TEST_KEY)).rejects.toThrow(
+ 'Vault update callback returned an invalid result',
+ );
+ await expect(
+ unsafeUpdate('codex', () => ({ result: undefined, write: 'yes' }), path, TEST_KEY),
+ ).rejects.toThrow('Vault update callback returned an invalid write flag');
+ expect(await Bun.file(path).text()).toBe(original);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should isolate malformed snapshot timestamps and reject malformed cached timestamps', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.account = {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ );
+ const stored = JSON.parse(await Bun.file(path).text()) as {
+ codex: { data: Record>; limits: Record };
+ };
+ const account = stored.codex.data.account as Record;
+ account.createdAt = 'not-an-instant';
+ await Bun.write(path, JSON.stringify(stored));
+ expect((await readVaultSection('codex', path, TEST_KEY)).corruptions?.account?.corrupted).toBe(true);
+
+ account.createdAt = '';
+ stored.codex.limits.account = { fetchedAt: 'not-an-instant', quota: { error: '', ok: false } };
+ await Bun.write(path, JSON.stringify(stored));
+ await expect(readVaultSection('codex', path, TEST_KEY)).rejects.toThrow('invalid cached limits');
+
+ stored.codex.limits.account = { fetchedAt: '', quota: { error: '', ok: false } };
+ await Bun.write(path, JSON.stringify(stored));
+ await expect(readVaultSection('codex', path, TEST_KEY)).rejects.toThrow('invalid cached limits');
+
+ delete stored.codex.limits.account;
+ account.createdAt = '2026-02-30T00:00:00.000Z';
+ await Bun.write(path, JSON.stringify(stored));
+ expect((await readVaultSection('codex', path, TEST_KEY)).corruptions?.account?.corrupted).toBe(true);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should report malformed UTF-8 in the server-owned vault as a storage failure', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ await Bun.write(path, new Uint8Array([0xc3, 0x28]));
+
+ const error = await readVaultSection('codex', path, TEST_KEY).catch((value: unknown) => value);
+ expect((error as { status?: number }).status).toBe(500);
+ expect(String(error)).toContain('Vault file is not valid UTF-8');
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should transform every account in a multi-entry section', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data = Object.fromEntries(
+ Array.from({ length: 64 }, (_, index) => [
+ `account-${index}`,
+ {
+ auth: JSON.stringify({ index }),
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ },
+ ]),
+ );
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ );
+
+ const section = await readVaultSection('codex', path, TEST_KEY);
+ expect(Object.keys(section.data)).toHaveLength(64);
+ await updateVaultSection('codex', (current) => ({ result: Object.keys(current.data).length }), path, TEST_KEY);
+ expect(Object.keys((await readVaultSection('codex', path, TEST_KEY)).data)).toHaveLength(64);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should preserve concurrent updates from separate Dondo processes', async () => {
+ const { dir, path } = await tempVault();
+ const moduleUrl = new URL('./vault.ts', import.meta.url).href;
+ const childCode = (accountKey: string, delay: number) => `
+ import { updateVaultSection } from ${JSON.stringify(moduleUrl)};
+ const key = Buffer.alloc(32, 3);
+ await Bun.sleep(${delay});
+ await updateVaultSection('codex', (section) => {
+ section.data[${JSON.stringify(accountKey)}] = {
+ auth: JSON.stringify({ account: ${JSON.stringify(accountKey)} }),
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ }, ${JSON.stringify(path)}, key);
+ `;
+
+ try {
+ await Promise.all([runChild(childCode('first', 150)), runChild(childCode('second', 0))]);
+ const section = await readVaultSection('codex', path, TEST_KEY);
+ expect(Object.keys(section.data).sort()).toEqual(['first', 'second']);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should retry release when owned lock metadata is temporarily unreadable or mismatched', async () => {
+ const { dir, path } = await tempVault();
+ const lockPath = `${path}.lock`;
+ let restoreMetadata = Promise.resolve();
+ try {
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ const metadata = readFileSync(lockPath, 'utf8');
+ writeFileSync(lockPath, '{');
+ restoreMetadata = new Promise((resolve, reject) => {
+ setTimeout(() => {
+ try {
+ const parsed = JSON.parse(metadata) as Record;
+ writeFileSync(lockPath, JSON.stringify({ ...parsed, token: 'temporary-mismatch' }));
+ } catch (error) {
+ reject(error);
+ }
+ }, 10);
+ setTimeout(() => {
+ try {
+ writeFileSync(lockPath, metadata);
+ resolve();
+ } catch (error) {
+ reject(error);
+ }
+ }, 75);
+ });
+ section.data.account = {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ );
+ await restoreMetadata;
+ expect(await Bun.file(lockPath).exists()).toBe(false);
+ } finally {
+ await restoreMetadata.catch(() => undefined);
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should recover a lock left by a crashed Dondo process', async () => {
+ const { dir, path } = await tempVault();
+ const lockPath = `${path}.lock`;
+ const crashCode = `
+ import { open } from 'node:fs/promises';
+ const handle = await open(${JSON.stringify(lockPath)}, 'wx', 0o600);
+ await handle.writeFile(JSON.stringify({ createdAt: Date.now(), pid: process.pid, token: 'crashed' }));
+ await handle.sync();
+ process.exit(0);
+ `;
+
+ try {
+ await runChild(crashCode);
+ expect(await Bun.file(lockPath).exists()).toBe(true);
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.recovered = {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ );
+ expect((await readVaultSection('codex', path, TEST_KEY)).data.recovered).toBeDefined();
+ expect(await Bun.file(lockPath).exists()).toBe(false);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should serialize competing recovery attempts for one crashed vault lock', async () => {
+ const { dir, path } = await tempVault();
+ const lockPath = `${path}.lock`;
+ const moduleUrl = new URL('./vault.ts', import.meta.url).href;
+ const crashCode = `
+ import { open } from 'node:fs/promises';
+ const handle = await open(${JSON.stringify(lockPath)}, 'wx', 0o600);
+ await handle.writeFile(JSON.stringify({ createdAt: Date.now(), pid: process.pid, token: 'crashed' }));
+ await handle.sync();
+ process.exit(0);
+ `;
+ const updateCode = (accountKey: string) => `
+ import { updateVaultSection } from ${JSON.stringify(moduleUrl)};
+ await updateVaultSection('codex', (section) => {
+ section.data[${JSON.stringify(accountKey)}] = {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ }, ${JSON.stringify(path)}, Buffer.alloc(32, 3));
+ `;
+
+ try {
+ await runChild(crashCode);
+ await Promise.all([runChild(updateCode('first')), runChild(updateCode('second'))]);
+
+ const section = await readVaultSection('codex', path, TEST_KEY);
+ expect(Object.keys(section.data).sort()).toEqual(['first', 'second']);
+ expect(await Bun.file(`${lockPath}.recovery`).exists()).toBe(false);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should not steal an old lock owned by a live Dondo process', async () => {
+ const { dir, path } = await tempVault();
+ const lockPath = `${path}.lock`;
+ const old = new Date(Date.now() - 10 * 60 * 1000);
+ try {
+ await Bun.write(lockPath, JSON.stringify({ createdAt: old.getTime(), pid: process.pid, token: 'live' }));
+ await utimes(lockPath, old, old);
+ const error = await readVaultSection('codex', path, TEST_KEY).catch((value: unknown) => value);
+ expect((error as { status?: number }).status).toBe(503);
+ expect(await Bun.file(lockPath).text()).toContain('"token":"live"');
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should recover an ancient lock even when its recorded PID is still live', async () => {
+ const { dir, path } = await tempVault();
+ const lockPath = `${path}.lock`;
+ const old = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
+ try {
+ await Bun.write(lockPath, JSON.stringify({ createdAt: old.getTime(), pid: process.pid, token: 'ancient' }));
+ await utimes(lockPath, old, old);
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.recovered = {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ );
+ expect((await readVaultSection('codex', path, TEST_KEY)).data.recovered).toBeDefined();
+ expect(await Bun.file(lockPath).exists()).toBe(false);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should not create or replace a vault lock while stale recovery is active', async () => {
+ const { dir, path } = await tempVault();
+ const lockPath = `${path}.lock`;
+ const recoveryPath = `${lockPath}.recovery`;
+ try {
+ await Bun.write(recoveryPath, JSON.stringify({ createdAt: Date.now(), pid: process.pid, token: 'live' }));
+ const read = readVaultSection('codex', path, TEST_KEY);
+ await Bun.sleep(50);
+ expect(await Bun.file(lockPath).exists()).toBe(false);
+
+ await rm(recoveryPath);
+ await expect(read).resolves.toEqual({ data: {}, limits: {} });
+ expect(await Bun.file(lockPath).exists()).toBe(false);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should recover a stale recovery gate left by a crashed process', async () => {
+ const { dir, path } = await tempVault();
+ const recoveryPath = `${path}.lock.recovery`;
+ const crashCode = `
+ import { open } from 'node:fs/promises';
+ const handle = await open(${JSON.stringify(recoveryPath)}, 'wx', 0o600);
+ await handle.writeFile(JSON.stringify({ createdAt: Date.now(), pid: process.pid, token: 'crashed' }));
+ await handle.sync();
+ process.exit(0);
+ `;
+ try {
+ await runChild(crashCode);
+ expect(await Bun.file(recoveryPath).exists()).toBe(true);
+ await expect(readVaultSection('codex', path, TEST_KEY)).resolves.toEqual({ data: {}, limits: {} });
+ expect(await Bun.file(recoveryPath).exists()).toBe(false);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should recover an empty recovery gate after its short initialization grace period', async () => {
+ const { dir, path } = await tempVault();
+ const recoveryPath = `${path}.lock.recovery`;
+ try {
+ await Bun.write(recoveryPath, '');
+ const old = new Date(Date.now() - 1_000);
+ await utimes(recoveryPath, old, old);
+
+ await expect(readVaultSection('codex', path, TEST_KEY)).resolves.toEqual({ data: {}, limits: {} });
+ expect(await Bun.file(recoveryPath).exists()).toBe(false);
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should reject oversized vault files before materializing them', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ await Bun.write(path, '');
+ await truncate(path, MAX_VAULT_FILE_BYTES + 1);
+
+ const error = await readVaultSection('codex', path, TEST_KEY).catch((value: unknown) => value);
+ expect(error).toBeInstanceOf(Error);
+ expect((error as { status?: number }).status).toBe(500);
+ expect(String(error)).toContain('Vault file exceeds the 16 MiB size limit');
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should refuse an oversized vault write without replacing the readable file', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.safe = {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ );
+ const before = await Bun.file(path).text();
+ const secret = 'x'.repeat(1024 * 1024);
+
+ const error = await updateVaultSection(
+ 'codex',
+ (section) => {
+ for (let index = 0; index < 16; index += 1) {
+ section.data[`large-${index}`] = {
+ auth: secret,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ }
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ ).catch((value: unknown) => value);
+
+ expect((error as { status?: number }).status).toBe(500);
+ expect(String(error)).toContain('Vault file exceeds the 16 MiB size limit');
+ expect(String(error)).not.toContain(secret);
+ expect(await Bun.file(path).text()).toBe(before);
+ await expect(readVaultSection('codex', path, TEST_KEY)).resolves.toBeDefined();
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should validate and redact cached limits read from the vault', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.safe = {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ );
+ const stored = JSON.parse(await Bun.file(path).text()) as {
+ codex: { data: Record> };
+ };
+ const encrypted = stored.codex.data.safe;
+ await Bun.write(
+ path,
+ JSON.stringify({
+ codex: {
+ data: { safe: encrypted },
+ limits: {
+ safe: {
+ fetchedAt: '2026-01-01T00:00:00.000Z',
+ quota: { error: 'Bearer private-limit-token', ok: false },
+ },
+ },
+ },
+ }),
+ );
+ const section = await readVaultSection('codex', path, TEST_KEY);
+ expect(section.limits.safe?.quota).toEqual({ error: 'Bearer [redacted]', ok: false });
+
+ await Bun.write(
+ path,
+ JSON.stringify({
+ codex: {
+ data: { unsafe: encrypted },
+ limits: {
+ unsafe: {
+ fetchedAt: '2026-01-03T00:00:00.000Z',
+ quota: { models: { bad: 'secret' }, ok: true },
+ },
+ },
+ },
+ }),
+ );
+ await expect(readVaultSection('codex', path, TEST_KEY)).rejects.toThrow('invalid cached limits');
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should enforce bounded percentages and non-negative usage in cached model limits', async () => {
+ const { dir, path } = await tempVault();
+ let encrypted: Record;
+ const fixture = (models: Record) => ({
+ codex: {
+ data: { account: encrypted },
+ limits: {
+ account: {
+ fetchedAt: '2026-01-01T00:00:00.000Z',
+ quota: { expires: '', models, ok: true, tier: 'test' },
+ },
+ },
+ },
+ });
+ const model = {
+ displayName: 'Model',
+ limit: 100,
+ percentage: 50,
+ resetTime: '',
+ used: 50,
+ };
+
+ try {
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.account = {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ );
+ encrypted = (
+ JSON.parse(await Bun.file(path).text()) as {
+ codex: { data: Record> };
+ }
+ ).codex.data.account as Record;
+ await Bun.write(
+ path,
+ JSON.stringify(
+ fixture({
+ empty: { ...model, limit: 0, percentage: 0, used: 0 },
+ full: { ...model, percentage: 100 },
+ }),
+ ),
+ );
+ await expect(readVaultSection('codex', path, TEST_KEY)).resolves.toBeDefined();
+
+ for (const invalid of [
+ { ...model, percentage: -1 },
+ { ...model, percentage: 101 },
+ { ...model, used: -1 },
+ { ...model, limit: -1 },
+ ]) {
+ await Bun.write(path, JSON.stringify(fixture({ invalid })));
+ await expect(readVaultSection('codex', path, TEST_KEY)).rejects.toThrow('invalid cached limits');
+ }
+ } finally {
+ await rm(dir, { force: true, recursive: true });
+ }
+});
+
+it('should bound platform account and per-account model counts', async () => {
+ const { dir, path } = await tempVault();
+ try {
+ await updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data.account = {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ };
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ );
+ const original = await Bun.file(path).text();
+ const encrypted = (JSON.parse(original) as { codex: { data: Record> } }).codex
+ .data.account;
+ const model = {
+ displayName: 'Model',
+ percentage: 0,
+ resetTime: '',
+ };
+ const models = Object.fromEntries(
+ Array.from({ length: MAX_VAULT_MODELS_PER_ACCOUNT + 1 }, (_, index) => [`model-${index}`, model]),
+ );
+ await Bun.write(
+ path,
+ JSON.stringify({
+ codex: {
+ data: { account: encrypted },
+ limits: {
+ account: {
+ fetchedAt: '2026-01-03T00:00:00.000Z',
+ quota: { expires: '', models, ok: true, tier: 'test' },
+ },
+ },
+ },
+ }),
+ );
+ await expect(readVaultSection('codex', path, TEST_KEY)).rejects.toThrow('invalid cached limits');
- const vault = await readVault(path);
- expect(vault.antigravity.limits.a?.fetchedAt).toBe('a');
- expect(vault.codex.limits.b?.fetchedAt).toBe('b');
- expect(vault.minimax.limits.c?.fetchedAt).toBe('c');
- expect(vault.kiro.limits.d?.fetchedAt).toBe('d');
- expect(vault.cline.limits.e?.fetchedAt).toBe('e');
+ await Bun.write(path, original);
+ await expect(
+ updateVaultSection(
+ 'codex',
+ (section) => {
+ section.data = Object.fromEntries(
+ Array.from({ length: MAX_VAULT_ACCOUNTS_PER_PLATFORM + 1 }, (_, index) => [
+ `account-${index}`,
+ {
+ auth: '{}',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-02T00:00:00.000Z',
+ },
+ ]),
+ );
+ return { result: undefined };
+ },
+ path,
+ TEST_KEY,
+ ),
+ ).rejects.toThrow('invalid updated platform section');
+ expect(await Bun.file(path).text()).toBe(original);
} finally {
await rm(dir, { force: true, recursive: true });
}
diff --git a/src/storage/vault.ts b/src/storage/vault.ts
index 9a76d83..e3d6d20 100644
--- a/src/storage/vault.ts
+++ b/src/storage/vault.ts
@@ -1,299 +1,982 @@
+import { createHash, randomUUID } from 'node:crypto';
+import type { Stats } from 'node:fs';
+import { link, mkdir, open as openFile, rename, rm, stat } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
import { VAULT_PATH } from '../config.ts';
-import { publicError } from '../errors.ts';
+import { assertAccountKey, publicError, redactSecrets } from '../errors.ts';
import type {
- AppVault,
ClineVault,
- ClineSnapshot,
- CodexSnapshot,
CodexVault,
- KiroSnapshot,
KiroVault,
- MinimaxSnapshot,
+ LimitCache,
+ LimitResult,
MinimaxVault,
+ ModelLimit,
PlatformVault,
- Snapshot,
+ VaultCorruption,
VaultSection,
} from '../types.ts';
-import { open, seal } from './crypto.ts';
-import { writePrivateFile } from './file.ts';
+import { isCurrentVaultCiphertext, open, resolveVaultKey, seal, type VaultKeyProvider } from './crypto.ts';
+import { MAX_LOCAL_CREDENTIAL_FILE_BYTES, readBoundedTextFile, writePrivateFile } from './file.ts';
+
+type VaultPlatform = 'antigravity' | 'cline' | 'codex' | 'kiro' | 'minimax';
+
+type PlatformSection = Platform extends 'antigravity'
+ ? PlatformVault
+ : Platform extends 'cline'
+ ? ClineVault
+ : Platform extends 'codex'
+ ? CodexVault
+ : Platform extends 'kiro'
+ ? KiroVault
+ : MinimaxVault;
type VaultUpdate = {
result: T;
write?: boolean;
};
-const emptySection = (): VaultSection => ({ data: {}, limits: {} });
+type StoredSection = {
+ data: Record;
+ limits: VaultSection['limits'];
+};
-const emptyVault = (): AppVault => ({
- antigravity: emptySection(),
- cline: emptySection(),
- codex: emptySection(),
- kiro: emptySection(),
- minimax: emptySection(),
-});
+type StoredVault = Record;
+
+type DecodedSection = {
+ damaged: Record;
+ healthy: Record;
+ section: PlatformSection;
+};
+
+type SecretField = {
+ name: string;
+ optional?: boolean;
+ secret?: boolean;
+ timestamp?: boolean;
+};
+
+type PlatformCodec = {
+ fields: readonly SecretField[];
+};
+
+type HealthyEntry = {
+ digest: string;
+ raw: unknown;
+};
+
+const PLATFORM_CODECS: Record = {
+ antigravity: {
+ fields: [
+ { name: 'account' },
+ { name: 'createdAt', timestamp: true },
+ { name: 'identity', secret: true },
+ { name: 'kind' },
+ { name: 'label' },
+ { name: 'password', secret: true },
+ { name: 'service' },
+ { name: 'updatedAt', timestamp: true },
+ ],
+ },
+ cline: {
+ fields: [
+ { name: 'createdAt', timestamp: true },
+ { name: 'secrets', secret: true },
+ { name: 'updatedAt', timestamp: true },
+ ],
+ },
+ codex: {
+ fields: [
+ { name: 'auth', secret: true },
+ { name: 'createdAt', timestamp: true },
+ { name: 'updatedAt', timestamp: true },
+ ],
+ },
+ kiro: {
+ fields: [
+ { name: 'auth', secret: true },
+ { name: 'clientRegistration', optional: true, secret: true },
+ { name: 'createdAt', timestamp: true },
+ { name: 'profile', optional: true, secret: true },
+ { name: 'updatedAt', timestamp: true },
+ ],
+ },
+ minimax: {
+ fields: [
+ { name: 'config', secret: true },
+ { name: 'createdAt', timestamp: true },
+ { name: 'updatedAt', timestamp: true },
+ ],
+ },
+};
+
+const VAULT_PLATFORMS = new Set(['antigravity', 'cline', 'codex', 'kiro', 'minimax']);
+const RESERVED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
+
+// Vaults contain credentials, but remain bounded so a malformed local file cannot force an unbounded read.
+export const MAX_VAULT_FILE_BYTES = 16 * 1024 * 1024;
+export const MAX_VAULT_METADATA_FIELD_BYTES = 4 * 1024;
+export const MAX_VAULT_ACCOUNTS_PER_PLATFORM = 4_096;
+export const MAX_VAULT_MODELS_PER_ACCOUNT = 256;
+export const VAULT_LOCK_TIMEOUT_MS = 2_000;
+
+const VAULT_LOCK_RETRY_MS = 25;
+const VAULT_LOCK_STALE_MS = 2 * 60 * 1000;
+const VAULT_LOCK_MAX_AGE_MS = 24 * 60 * 60 * 1000;
+const VAULT_RECOVERY_INVALID_STALE_MS = 250;
+
+const vaultQueues = new Map>();
const isRecord = (value: unknown): value is Record => {
return typeof value === 'object' && value !== null && !Array.isArray(value);
};
-let vaultQueue = Promise.resolve();
+const isPromiseLike = (value: unknown): value is PromiseLike => {
+ return isRecord(value) && typeof value.then === 'function';
+};
-const queueVaultOperation = (operation: () => Promise) => {
- const queued = vaultQueue.then(operation);
- vaultQueue = queued.then(
- () => undefined,
- () => undefined,
- );
- return queued;
+const assertExactKeys = (value: Record, keys: readonly string[], path: string, detail: string) => {
+ const allowed = new Set(keys);
+ if (Object.keys(value).some((key) => !allowed.has(key))) {
+ return invalidVaultShape(path, detail);
+ }
};
-const normalizeVault = (raw: unknown): AppVault => {
- const input = isRecord(raw) ? raw : {};
- const antigravity = isRecord(input.antigravity) ? input.antigravity : {};
- const cline = isRecord(input.cline) ? input.cline : {};
- const codex = isRecord(input.codex) ? input.codex : {};
- const kiro = isRecord(input.kiro) ? input.kiro : {};
- const minimax = isRecord(input.minimax) ? input.minimax : {};
+const invalidVaultShape = (path: string, detail: string): never => {
+ throw publicError(500, `Vault file has an invalid ${detail}: ${path}`);
+};
- return {
- antigravity: {
- data: isRecord(antigravity.data) ? (antigravity.data as Record) : {},
- limits: isRecord(antigravity.limits) ? (antigravity.limits as PlatformVault['limits']) : {},
- },
- cline: {
- data: isRecord(cline.data) ? (cline.data as Record) : {},
- limits: isRecord(cline.limits) ? (cline.limits as ClineVault['limits']) : {},
- },
- codex: {
- data: isRecord(codex.data) ? (codex.data as Record) : {},
- limits: isRecord(codex.limits) ? (codex.limits as CodexVault['limits']) : {},
- },
- kiro: {
- data: isRecord(kiro.data) ? (kiro.data as Record) : {},
- limits: isRecord(kiro.limits) ? (kiro.limits as KiroVault['limits']) : {},
- },
- minimax: {
- data: isRecord(minimax.data) ? (minimax.data as Record) : {},
- limits: isRecord(minimax.limits) ? (minimax.limits as MinimaxVault['limits']) : {},
- },
- };
+const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
+
+const validTimestamp = (value: string, allowEmpty = false) => {
+ if (value === '') {
+ return allowEmpty;
+ }
+ if (!ISO_INSTANT.test(value)) {
+ return false;
+ }
+ const components = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/);
+ if (!components) {
+ return false;
+ }
+ const [, year, month, day, hour, minute, second] = components.map(Number);
+ if (
+ year === undefined ||
+ month === undefined ||
+ day === undefined ||
+ hour === undefined ||
+ minute === undefined ||
+ second === undefined ||
+ month < 1 ||
+ month > 12 ||
+ hour > 23 ||
+ minute > 59 ||
+ second > 59
+ ) {
+ return false;
+ }
+ const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
+ const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ?? 0;
+ return day >= 1 && day <= daysInMonth && Number.isFinite(Date.parse(value));
};
-const decryptPlatform = async (platform: PlatformVault, encryptionKey?: Buffer): Promise => ({
- data: Object.fromEntries(
- await Promise.all(
- Object.entries(platform.data ?? {}).map(async ([key, snap]) => [
- key,
- { ...snap, password: await open(snap.password, encryptionKey) },
- ]),
- ),
- ),
- limits: platform.limits ?? {},
-});
+const boundedText = (value: unknown, path: string, detail: string, maxBytes = MAX_VAULT_METADATA_FIELD_BYTES) => {
+ if (typeof value !== 'string') {
+ return invalidVaultShape(path, detail);
+ }
+ if (Buffer.byteLength(value, 'utf8') > maxBytes) {
+ return invalidVaultShape(path, detail);
+ }
+ return value;
+};
-const encryptPlatform = async (platform: PlatformVault, encryptionKey?: Buffer): Promise => ({
- data: Object.fromEntries(
- await Promise.all(
- Object.entries(platform.data ?? {}).map(async ([key, snap]) => [
- key,
- { ...snap, password: await seal(snap.password, encryptionKey) },
- ]),
- ),
- ),
- limits: platform.limits ?? {},
-});
+const storedText = (value: unknown, path: string) => {
+ const text = boundedText(value, path, 'cached limits');
+ return redactSecrets(text);
+};
-const decryptCodex = async (codex: CodexVault, encryptionKey?: Buffer): Promise => ({
- data: Object.fromEntries(
- await Promise.all(
- Object.entries(codex.data ?? {}).map(async ([key, snap]) => [
- key,
- { ...snap, auth: await open(snap.auth, encryptionKey) },
- ]),
- ),
- ),
- limits: codex.limits ?? {},
-});
+const timestampText = (value: unknown, path: string, detail: string, allowEmpty = false) => {
+ const text = boundedText(value, path, detail);
+ if (!validTimestamp(text, allowEmpty)) {
+ return invalidVaultShape(path, detail);
+ }
+ return text;
+};
-const decryptCline = async (cline: ClineVault, encryptionKey?: Buffer): Promise => ({
- data: Object.fromEntries(
- await Promise.all(
- Object.entries(cline.data ?? {}).map(async ([key, snap]) => [
- key,
- { ...snap, secrets: await open(snap.secrets, encryptionKey) },
- ]),
- ),
- ),
- limits: cline.limits ?? {},
-});
+const storedTimestamp = (value: unknown, path: string, allowEmpty = false) => {
+ const text = timestampText(value, path, 'cached limits', allowEmpty);
+ return text;
+};
-const encryptCodex = async (codex: CodexVault, encryptionKey?: Buffer): Promise => ({
- data: Object.fromEntries(
- await Promise.all(
- Object.entries(codex.data ?? {}).map(async ([key, snap]) => [
- key,
- { ...snap, auth: await seal(snap.auth, encryptionKey) },
- ]),
- ),
- ),
- limits: codex.limits ?? {},
-});
+const percentage = (value: unknown, path: string) => {
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 100) {
+ return invalidVaultShape(path, 'cached limits');
+ }
+ return value;
+};
-const encryptCline = async (cline: ClineVault, encryptionKey?: Buffer): Promise => ({
- data: Object.fromEntries(
- await Promise.all(
- Object.entries(cline.data ?? {}).map(async ([key, snap]) => [
- key,
- { ...snap, secrets: await seal(snap.secrets, encryptionKey) },
- ]),
- ),
- ),
- limits: cline.limits ?? {},
-});
+const optionalNonNegativeNumber = (value: unknown, path: string) => {
+ if (value === undefined) {
+ return undefined;
+ }
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
+ return invalidVaultShape(path, 'cached limits');
+ }
+ return value;
+};
-const decryptKiro = async (kiro: KiroVault, encryptionKey?: Buffer): Promise => ({
- data: Object.fromEntries(
- await Promise.all(
- Object.entries(kiro.data ?? {}).map(async ([key, snap]) => [
- key,
- {
- ...snap,
- auth: await open(snap.auth, encryptionKey),
- clientRegistration: snap.clientRegistration
- ? await open(snap.clientRegistration, encryptionKey)
- : undefined,
- profile: snap.profile ? await open(snap.profile, encryptionKey) : undefined,
- },
+const normalizeModelLimit = (value: unknown, path: string): ModelLimit => {
+ if (!isRecord(value)) {
+ return invalidVaultShape(path, 'cached limits');
+ }
+ assertExactKeys(
+ value,
+ ['detail', 'displayName', 'limit', 'percentage', 'resetTime', 'used'],
+ path,
+ 'cached limits',
+ );
+ const detail = value.detail === undefined ? undefined : storedText(value.detail, path);
+ const limit = optionalNonNegativeNumber(value.limit, path);
+ const used = optionalNonNegativeNumber(value.used, path);
+ return {
+ displayName: storedText(value.displayName, path),
+ percentage: percentage(value.percentage, path),
+ resetTime: storedTimestamp(value.resetTime, path, true),
+ ...(detail === undefined ? {} : { detail }),
+ ...(limit === undefined ? {} : { limit }),
+ ...(used === undefined ? {} : { used }),
+ };
+};
+
+const normalizeQuota = (value: unknown, path: string): LimitResult => {
+ if (!isRecord(value) || typeof value.ok !== 'boolean') {
+ return invalidVaultShape(path, 'cached limits');
+ }
+ if (!value.ok) {
+ assertExactKeys(value, ['error', 'ok'], path, 'cached limits');
+ return { error: storedText(value.error, path), ok: false };
+ }
+ assertExactKeys(value, ['expires', 'models', 'ok', 'tier'], path, 'cached limits');
+ if (!isRecord(value.models)) {
+ return invalidVaultShape(path, 'cached limits');
+ }
+ if (Object.keys(value.models).length > MAX_VAULT_MODELS_PER_ACCOUNT) {
+ return invalidVaultShape(path, 'cached limits');
+ }
+ return {
+ expires: storedTimestamp(value.expires, path, true),
+ models: Object.fromEntries(
+ Object.entries(value.models).map(([name, model]) => [
+ redactSecrets(boundedText(name, path, 'cached limits')),
+ normalizeModelLimit(model, path),
]),
),
- ),
- limits: kiro.limits ?? {},
-});
+ ok: true,
+ tier: storedText(value.tier, path),
+ };
+};
-const encryptKiro = async (kiro: KiroVault, encryptionKey?: Buffer): Promise => ({
- data: Object.fromEntries(
- await Promise.all(
- Object.entries(kiro.data ?? {}).map(async ([key, snap]) => [
+const normalizeLimits = (value: unknown, path: string): Record