Commit Graph

1028 Commits

Author SHA1 Message Date
SaladDay d2ad7995a3 fix(pi): fail closed on unrepresentable compat spread 2026-08-02 16:16:14 +00:00
SaladDay 2a0a8125eb fix(pi): certify pinned native inspection semantics 2026-08-02 15:24:48 +00:00
SaladDay 15c735f040 fix(pi): mirror pinned native runtime semantics 2026-08-02 13:56:18 +00:00
SaladDay 2f856b0ffb fix(pi): validate deferred headers after resolution 2026-08-02 12:05:25 +00:00
SaladDay 9b5a146323 refactor(pi): certify native inspection boundary 2026-08-02 10:12:00 +00:00
SaladDay 2533db035a docs(database): close restore hardening prerequisite 2026-08-02 10:10:45 +00:00
SaladDay 3fa6b1f158 refactor(database): scope untrusted restore to N/N-1 2026-08-02 05:29:48 +00:00
SaladDay ce6edf68a0 fix(database): reject hidden migration source columns 2026-08-01 19:56:17 +00:00
SaladDay c036c6359e refactor(database): construct untrusted migrations from source specs 2026-08-01 19:45:36 +00:00
SaladDay 29cccd5bd3 fix(database): fail closed across restore migrations
Classify every historical migration boundary, thread the restore context through the full chain, reject repair or synthesis for untrusted inputs, and add SQL/binary public-entry certification for damaged and valid legacy fixtures.
2026-08-01 17:04:54 +00:00
SaladDay 28530ff641 fix(database): enforce canonical restore trust boundaries 2026-08-01 16:01:00 +00:00
SaladDay 89961dff28 fix(database): close canonical restore review gaps 2026-08-01 14:12:08 +00:00
SaladDay 9a87512224 refactor(database): certify canonical restore boundary 2026-08-01 12:55:39 +00:00
SaladDay 2841811700 refactor(database): complete prerequisite B 2026-08-01 11:44:50 +00:00
SaladDay 22c010079a fix(provider): enforce durable identity boundaries 2026-08-01 09:09:14 +00:00
SaladDay 37e78166c1 refactor(provider): complete prerequisite A ownership 2026-08-01 08:37:49 +00:00
SaladDay 2bc92e0f79 refactor(provider): certify typed write ownership
Freeze prerequisite A as a component-level certification unit. Add the immutable v5 certification suite, split create/update row DTOs, preserve immutable creation time, map strict-create races to AppError::Conflict, make aggregate compensation insert-or-restore, and enforce reconcile preconditions through a single-lock transaction primitive.

Old save_provider callsite classification remains exhaustively recorded in 4f78451405575158ff6562c7021c7f31f2860780; this checkpoint does not add or reclassify an omitted legacy callsite. It tightens the remaining reconciliation classifications there: default live import is [create]; OpenCode/OpenClaw/Hermes existing branches are [update] and absent branches are [create]; universal Claude/Codex/Gemini branches are [create/update] selected from an observed fingerprint. The sealed compensation helper remains the only [restore] path. The old reconcile_provider_record symbol is deleted.

Remaining update_provider_settings_config callsites are classified as [update]: codex_history_migration updates an already-read Codex row; proxy token synchronization updates already-read Claude, Codex, Gemini, and GrokBuild rows. Each now uses ProviderKey plus ProviderRowUpdate, explicitly removes hydrated endpoint projections, preserves endpoint authority, and fails on a missing row instead of silently succeeding.
2026-08-01 07:48:57 +00:00
SaladDay 10f2dacbe4 config(pi): execute pinned native schema and composer oracles
Evaluate raw Pi documents with the vendored upstream TypeBox schema and replay composer/transport expectations captured by actually executing Pi ab366ebe94cacd419d986be454f12b1b9913aaca.

Bind all 252 canonical fields to successful raw and own-layer composer evidence (70 provider, 92 model, 90 override), fail closed where pinned runtime context is unavailable, expose structured inspection, and enforce module/write/restore boundaries with negative fixtures.
2026-07-31 18:07:21 +00:00
SaladDay 4f78451405 storage(pi): isolate typed provider writes and canonical restore
Replace the generic Provider save/upsert surface with strict typed
create/update/rename/endpoint operations. Keep aggregate hydration read-only,
preserve nullable endpoint timestamps end to end, and make service create own
the initial endpoint set atomically.

Restore SQL and binary backups only through UntrustedScratch, migrate and copy
fixed data columns into a fresh CanonicalStage, validate the canonical result,
then publish through the Backup API. The imported schema is never eligible to
become the live schema.

Old save_provider callsite classification
==========================================

Inventory authority: abandoned 5a385fc8 tree. The old definition at
src-tauri/src/database/dao/providers.rs:180 is deleted and is not a callsite.

Production callsites:

- src-tauri/src/commands/provider.rs:253 [create] Claude Desktop import creates
  one absent aggregate; it now strict-inserts the row and initial endpoints in
  one transaction.
- src-tauri/src/database/dao/providers.rs:638 [create] official seed first
  proves absence, then strict-creates; a racing insert is a conflict.
- src-tauri/src/database/dao/providers.rs:704 [create] on-demand seed first
  proves absence, then strict-creates; it cannot overwrite an existing row.
- src-tauri/src/services/omo.rs:291 [create] OMO import constructs a new
  aggregate and strict-creates it; OMO is not eligible for rename.
- src-tauri/src/services/provider/endpoints.rs:85 [update] endpoint last-used
  is not a Provider-row save; it now calls the exact touch endpoint operation.
- src-tauri/src/services/provider/live.rs:1567 [create/update] default live
  import is reconciliation: read first, then strict create or strict update.
- src-tauri/src/services/provider/live.rs:1743 [update] an existing OpenCode
  live provider follows the strict row-update branch.
- src-tauri/src/services/provider/live.rs:1770 [create] a new OpenCode live
  provider follows the strict aggregate-create branch.
- src-tauri/src/services/provider/live.rs:1825 [update] an existing OpenClaw
  live provider follows the strict row-update branch.
- src-tauri/src/services/provider/live.rs:1858 [create] a new OpenClaw live
  provider follows the strict aggregate-create branch.
- src-tauri/src/services/provider/live.rs:1900 [update] an existing Hermes live
  provider follows the strict row-update branch.
- src-tauri/src/services/provider/live.rs:1926 [create] a new Hermes live
  provider follows the strict aggregate-create branch.
- src-tauri/src/services/provider/mod.rs:2568 [create] ProviderService::add owns
  strict aggregate creation and all initial endpoints.
- src-tauri/src/services/provider/mod.rs:2680 [rename] an additive DB-only key
  change now uses the dedicated transactional rename after eligibility checks.
- src-tauri/src/services/provider/mod.rs:2711 [update] OMO edit updates exactly
  the existing main row after its live-file coordination.
- src-tauri/src/services/provider/mod.rs:2740 [update] additive-provider edit
  updates exactly the existing main row after resolving live ownership.
- src-tauri/src/services/provider/mod.rs:2750 [update] switch-mode edit updates
  exactly the existing main row and never inserts.
- src-tauri/src/services/provider/mod.rs:2948 [update] remove-from-live changes
  only the existing provider's live-managed marker.
- src-tauri/src/services/provider/mod.rs:3120 [update] switch backfill updates
  only the existing current provider row.
- src-tauri/src/services/provider/mod.rs:3174 [update] successful additive
  switch changes only the existing live-managed marker.
- src-tauri/src/services/provider/mod.rs:3315 [update] common-config migration
  updates only each already-read existing row.
- src-tauri/src/services/provider/mod.rs:3895 [update] Gemini credential scrub
  updates only each already-read existing row.
- src-tauri/src/services/provider/mod.rs:4082 [update] sort ordering is routed
  to the dedicated sort-index state operation, not row replacement.
- src-tauri/src/services/provider/mod.rs:4636 [create/update] universal-to-
  Claude reconciliation reads the target and selects strict create or update.
- src-tauri/src/services/provider/mod.rs:4651 [create/update] universal-to-
  Codex reconciliation reads the target and selects strict create or update.
- src-tauri/src/services/provider/mod.rs:4665 [create/update] universal-to-
  Gemini reconciliation reads the target and selects strict create or update.

Required indirect ownership paths:

- src-tauri/src/deeplink/provider.rs [create] the old indirect flow called
  ProviderService::add and then appended endpoints one by one. It now supplies
  every non-primary endpoint to one strict aggregate create, so hydration is
  complete atomically and a duplicate is zero-side-effect.
- [restore] no old generic-save callsite is reclassified as restore. Exact
  aggregate replacement exists only as the sealed
  restore_provider_aggregate_on_tx compensation primitive.

Test-only callsites:

Every item below is classified [test]. Each is fixture setup, not a production
write authority, and is migrated to a real ProviderService entry where the
behavior is under test or to the cfg(test)-only typed fixture reconciler where
the test merely needs pre-existing rows.

- src-tauri/src/codex_history_migration.rs:1442 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:1452 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2174 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2176 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2199 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2219 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2247 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2267 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2288 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2320 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2393 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2449 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2498 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2555 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2604 [test] migration fixture setup.
- src-tauri/src/codex_history_migration.rs:2625 [test] migration fixture setup.
- src-tauri/src/database/dao/providers.rs:754 [test] DAO fixture setup.
- src-tauri/src/proxy/provider_router.rs:351 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:352 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:377 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:378 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:410 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:411 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:447 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:448 [test] router fixture setup.
- src-tauri/src/proxy/provider_router.rs:488 [test] router fixture setup.
- src-tauri/src/services/provider/mod.rs:485 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:586 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:813 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:825 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:1472 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:1607 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:1737 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:1945 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:1952 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:1978 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2006 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2056 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2130 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2167 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2207 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2235 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2270 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2320 [test] service fixture setup.
- src-tauri/src/services/provider/mod.rs:2362 [test] service fixture setup.
- src-tauri/src/services/proxy.rs:3762 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:3948 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4034 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4095 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4114 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4263 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4341 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4421 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4533 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4651 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:4787 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5264 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5320 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5385 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5387 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5460 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5462 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5611 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5613 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5615 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5698 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5700 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:5998 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6000 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6173 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6175 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6417 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6419 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6553 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6555 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6635 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6637 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:6919 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:7173 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:7175 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:7240 [test] proxy fixture setup.
- src-tauri/src/services/proxy.rs:7242 [test] proxy fixture setup.
- src-tauri/tests/profile_roundtrip.rs:112 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:116 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:126 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:133 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:292 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:501 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:505 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:670 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:677 [test] profile fixture create.
- src-tauri/tests/profile_roundtrip.rs:762 [test] Linux Desktop fixture create.
- src-tauri/tests/profile_roundtrip.rs:769 [test] Linux Desktop fixture create.
- src-tauri/tests/provider_commands.rs:69 [test] command fixture create.
- src-tauri/tests/provider_service.rs:2927 [test] service fixture create.
2026-07-31 18:06:59 +00:00
SaladDay 3dc188313e domain(pi): add managed model and capability contracts 2026-07-31 17:52:59 +00:00
Komi 4bfb3fc30d fix(usage): dedupe Claude Desktop proxy and session logs (#5951) 2026-07-31 15:00:18 +08:00
Thefool c49cf96a16 fix(grokbuild): complete proxy and deep-link integrations (#5677)
* fix(grokbuild): complete proxy integration

* fix(deeplink): preview GrokBuild configs safely

* test(app): stabilize provider integration suite

* fix(grokbuild): address review feedback

* fix(grokbuild): resolve remaining review findings

* fix(grokbuild): use native sessions and harden previews
2026-07-31 14:56:42 +08:00
SaladDay 3c1154bed9 refactor: remove superseded dead code (#5916) 2026-07-30 22:32:10 +08:00
Jason 6b13d01825 chore(release): v3.19.0 2026-07-30 09:29:54 +08:00
ayanamislover 56fb46c093 perf(codex): cache parent rollout timelines across fork cutoffs (#5626)
* perf(codex): cache parent rollout timelines

* fix(codex): harden parent timeline cache

* fix(codex): tighten replay cache invalidation

---------

Co-authored-by: Ayanami <ay@nami.ltd>
Co-authored-by: SaladDay <92240037+SaladDay@users.noreply.github.com>
2026-07-29 23:49:46 +08:00
Jason 87b0e3fb85 fix(test): pin zip extraction temp dir instead of hijacking TMPDIR
The two cleanup-guard tests introduced in ff3bc242 set the process-global
TMPDIR to a scratch dir and asserted it ended up empty. serial_test only
serializes marked tests, so any concurrent test creating a tempdir inside
the hijacked window landed in scratch and randomly failed the emptiness
assertion on Ubuntu/macOS CI (Windows ignores TMPDIR).

Add an extract_local_zip_in(zip_path, base_dir) seam that takes the temp
base explicitly; the public function delegates with std::env::temp_dir().
Tests now pass their private scratch dir directly, dropping the TMPDIR
mutation and the serial markers — the race is impossible by construction.
2026-07-29 10:22:30 +08:00
Jason cfa90f396a fix(deeplink): import usage scripts disabled and show their code
An imported usage script is JavaScript that runs whenever usage is
queried. Two things made it possible to acquire one without seeing it:

  - `usage_enabled.unwrap_or(!code.is_empty())` treated the presence of
    code as a decision to run it, so a link that simply carried a script
    got it enabled
  - the confirmation dialog rendered only an enabled/disabled badge; the
    script body was never displayed

Default to disabled. Enabling now requires `usageEnabled=true` in the
link -- which is the link author's request, not the user's consent. The
consent is the user pressing Import after seeing the full script body
and the badge, which is why both displays are load-bearing rather than
decorative.

The badge predicate moves from `!== false` to `=== true` to match the
new backend default. Left alone it would have started rendering "did not
say" as a green "Enabled" -- more optimistic than what would actually
happen.

Extracts the payload decode into `decodeDeeplinkPayload`, which falls
back to the raw string when decoding fails or yields empty. A dialog
whose job is to show what is about to be written must not let a payload
vanish just because it is malformed; empty reads as "there is no
script", which is exactly the wrong impression.
2026-07-29 10:22:30 +08:00
Jason 134bdc0e65 docs(sessions): record the renderer trust boundary for terminal launch
`launch_session_terminal` takes an arbitrary string from the renderer and
hands it to a shell. External audits report this as arbitrary command
execution over IPC. Document it as a known, accepted risk instead of
leaving it to be re-reported every audit cycle.

The precondition for exploiting it is control over the renderer, which
already implies local code execution as the user -- at which point going
through this command grants nothing extra. The renderer is treated as a
trusted boundary, supported by four facts each verified against the tree:

- the only `dangerouslySetInnerHTML` (ProviderIcon) takes an icon *name*,
  gated by `hasIcon()`, and reads the SVG from a build-time registry;
  neither users nor deep links can supply markup
- no `eval` / `new Function` anywhere in the frontend
- `frontendDist` points at the bundled output, the webview loads no
  remote origin, and there are no `<iframe>` / `<webview>` elements
- CSP is `script-src 'self'` -- no inline and no external scripts

The note lists what invalidates the conclusion, so the exemption is
falsifiable rather than a standing opinion: rendering network- or
config-sourced rich text, embedding a webview or navigating to a remote
origin, relaxing `script-src`, or introducing any way to execute
external code in the renderer. Any of those and this command must be
changed to accept a session identifier and rebuild the command in the
backend.

It also states explicitly that `cwd` is *not* covered. That value comes
from disk scanning and can legitimately contain `$(...)` regardless of
renderer trust, which is why it is escaped rather than exempted. Without
that sentence "the renderer is trusted" invites being read as "nothing
on this path needs handling".
2026-07-29 10:22:30 +08:00
Jason 35486afdda fix(sessions): use POSIX single-quote escaping for terminal cwd
`shell_escape` wrapped the working directory in double quotes and escaped
only `\` and `"`. Inside double quotes a shell still expands `$(...)`,
backticks and `$VAR`, so the quoting stopped spaces but not command
substitution. Verified: `cd "/tmp/$(id -un)"` runs `id`.

The value is `selectedSession.projectDir` -- a real path recorded in the
AI CLI's session history. macOS allows `$`, `(` and `)` in directory
names, so any project whose folder is named that way triggers it on
Resume; no compromised renderer is required.

Three built-in launchers were affected because they route through
`build_shell_command(command, cwd)`: Terminal.app, iTerm and kitty.
Ghostty, WezTerm/Kaku and Alacritty were already correct -- they pass the
directory as its own argv element (`--working-directory` / `--cwd`) and
call `build_shell_command(command, None)`. Terminal and iTerm go through
AppleScript `do script`, which accepts a single shell line and has no
cwd parameter, so correct quoting is the only option there.

Switch to POSIX single quotes, where nothing expands, using the
close-escape-reopen `'\''` sequence for embedded quotes. A test pins the
two-layer interaction with `escape_osascript`, which doubles backslashes
on the way into the AppleScript literal.

Also escape the `{cwd}` substitution in `launch_custom`, and correct that
function's comment: the escaping there is context-dependent and only
holds while the placeholder sits in an unquoted shell word. A template
written as `echo "{cwd}"` puts the inserted quotes inside double quotes
and command substitution runs again. The branch has no UI entry point
today; the note now says it must be redesigned before one is added
rather than implying it is already safe.
2026-07-29 10:22:29 +08:00
Jason c98913df41 fix(database): reject cross-file statements during SQL import
`import_sql_string_inner` validated only that the file starts with the
`-- CC Switch SQLite 导出` comment, then handed the whole text to
`execute_batch`. Anything after that prefix ran unchecked, so a crafted
backup could `ATTACH DATABASE '/path/x.db'` and create a SQLite file
anywhere the user can write. The side effect lands before
`validate_basic_state`, so the file survives even when the import as a
whole fails. `settings` is in neither SYNC_SKIP_TABLES nor
SYNC_PRESERVE_TABLES, so the WebDAV/S3 sync path reaches the same code.

Install a SQLite authorizer for the duration of the external batch only,
then clear it so our own schema maintenance is unaffected.

Deny what can leave the temp database rather than allow-listing what
`dump_sql` emits. The batch runs on a throwaway NamedTempFile whose
entire contents are already decided by that same SQL, so DELETE/DROP/
UPDATE hand an attacker nothing new -- the only meaningful boundary is
the temp file itself. A strict allow-list only adds the risk of refusing
a legitimate backup whose schema has a shape we did not anticipate.

The denied set was measured, not guessed: `ATTACH DATABASE 'x'`,
`VACUUM INTO 'x'` and bare `VACUUM` all surface as `AuthAction::Attach`,
so one rule covers all three -- which keyword scanning would not, since
`VACUUM INTO` contains no "ATTACH". Also deny vtable creation
(file-backed modules such as csvfile can read and write arbitrary paths)
and `Unknown`, so future SQLite statements fail closed.

Tests cover both denied statements (asserting no file is left on disk,
not merely that the call errors) and a real export round-trip, which
guards against the allow-list regressing into false refusals.
2026-07-29 10:22:29 +08:00
Thefool 12b972a66e feat(usage): add automatic models.dev pricing sync (#5734)
* feat(usage): persist model pricing in local config

* feat(usage): sync selected models.dev pricing on startup

* fix(usage): address models.dev sync review feedback

* fix(usage): harden local pricing synchronization
2026-07-28 23:37:55 +08:00
zayoka ff3bc242cc fix(Security): zip-slip on skill install, two credential leaks, and three panic paths (#5811)
* fix(security): harden GrokBuild credential handling and Codex/Anthropic transforms against malformed input

Three robustness/security fixes in the upstream v3.18.0 code, each with a
regression test.

1) grok_config: remove the unconditional XAI_API_KEY fallback in
   extract_credentials(). Credentials now come only from an explicit inline
   api_key or the process env var named by env_key. Silently substituting a
   different account's key (when the declared env_key var is unset) could
   leak that key to whatever base_url the config points at.

2) deeplink/provider: merge_grokbuild_config no longer resolves env vars
   into a plaintext api_key on import. A deeplink is untrusted input;
   resolving+inlining would persist the victim's environment secret into the
   imported provider's config.toml and ship it to the link's declared
   base_url. env_key now stays an indirection (name), not a resolved secret.

3) proxy transforms: stop panicking on malformed upstream data.
   - transform_codex_anthropic::anthropic_sse_to_message_value: only store a
     `message`/`content_block` when it is an object; otherwise treat it as
     empty, so the later `["content"]`/`["text"]`/`["signature"]` index
     assignments can't panic on a scalar/array Value.
   - streaming_codex_anthropic::responses_sse_events_from_anthropic_message:
     bail out with a failed-event when the buffered body is a top-level JSON
     array/scalar (a gateway that ignores stream:true), instead of
     index-assigning into a non-object.
   - mcp/grokbuild sync: normalize a non-table `mcp_servers` before
     inserting, avoiding a toml_edit IndexMut panic on a user-edited
     config.toml.

Panic findings verified with a minimal repro (serde_json index-assignment on
a non-object Value aborts). cargo test --lib: 2183 passed / 0 failed.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(mcp): normalize a non-table `mcp_servers` in Codex config before insert

Same class of bug as the GrokBuild MCP fix in the previous commit, but in the
much more widely used Codex path.

`sync_single_server_to_codex` guarded only with `contains_key("mcp_servers")`,
so a user-edited `~/.codex/config.toml` where the key exists but is *not* a
table (`mcp_servers = "x"` / `[]` / `42`) skipped the rebuild and then hit
`doc["mcp_servers"][id] = …`, which panics in toml_edit's `IndexMut`
(`.expect("index not found")`). The panic happens inside a Tauri command and
unwinds across the FFI boundary; in the provider-switch flow it also fires
after the DB/live write already committed, leaving a half-applied switch.

Extract the normalize-then-insert step into `upsert_mcp_server_table()` so the
previously panic-prone logic is unit-testable without touching the real
`~/.codex/config.toml`, and normalize any non-table value to an empty table
before inserting.

Audited the sibling MCP writers while here: claude.rs / gemini.rs /
hermes.rs / opencode.rs do not have this pattern (their `contains_key` uses are
read-only checks), and the other index-assignments in codex.rs operate on
freshly built `Table::new()` values, which are safe.

Tests: 2 new regression tests (malformed non-table values normalize and insert;
an existing valid table keeps its entries). cargo test --lib 2185 passed / 0
failed; cargo clippy --lib -D warnings clean.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(security): reject path-traversal entries when extracting a skill repo archive

`SkillService::download_and_extract` built the output path from the raw ZIP
entry name (`file.name()`), stripped only the leading `<repo>-<branch>/`
component, and then `dest.join(relative_path)` → `fs::File::create`. A crafted
entry such as `repo-main/../../../evil.sh` therefore escaped the destination
directory (zip-slip): arbitrary file write outside the temp extraction dir.

The archive is third-party controlled: it is downloaded from
`https://github.com/<owner>/<name>/archive/refs/heads/<branch>.zip`, and a
skill repo (`owner`/`name`) can be added through an untrusted `ccswitch://`
deeplink (`deeplink/skill.rs`), so the attacker fully controls the archive
contents.

Fix: resolve each entry through `zip::read::ZipFile::enclosed_name()`, which
rejects `..` components and absolute paths, before stripping the archive's
root directory and joining onto `dest`. Unsafe entries are skipped with a
warning. This matches what the two sibling extractors in this codebase already
did correctly (`extract_local_zip`, `webdav_sync/archive.rs`) — this call site
was the one that had been missed.

The extraction loop is split out into `extract_repo_archive()` so the guard is
testable without network access.

Verified the test actually catches the bug: with the guard reverted to the old
`file.name()` behaviour the new test fails ("zip-slip entry must not escape
dest (temp root)"); with the guard in place it passes.

cargo test --lib: 2186 passed / 0 failed; cargo clippy --lib -D warnings clean.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(security): strip all credentials from the shared Gemini common-config snippet

`extract_gemini_common_config` skipped only two hardcoded keys
(`GOOGLE_GEMINI_BASE_URL`, `GEMINI_API_KEY`) and copied every other `env`
entry into the shared snippet. But `GOOGLE_API_KEY` is a first-class Gemini
credential — `provider.rs` resolves it via
`first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"])` — so it was
never stripped.

That snippet is not inert: `apply_common_config_to_settings` (live.rs)
deep-merges it into the `env` of *every other* Gemini provider that uses the
common config, and the snippet is auto-extracted on startup, on import, and on
switch. Net effect: account A's key gets written into provider B and sent to
B's `GOOGLE_GEMINI_BASE_URL`, which may be a third-party relay. Anything else
the user put in `env` (`GOOGLE_APPLICATION_CREDENTIALS`, a proxy
`*_AUTH_TOKEN`, …) leaked the same way.

Fix: also skip `Self::is_sensitive_config_key(key)`, reusing the pattern
matcher the Claude extractor already relies on for exactly this reason (its
comment notes a fixed enumeration "will always miss the next `*_API_KEY`").
`GOOGLE_API_KEY` matches its `_API_KEY` suffix rule. Shareable non-secret
config (e.g. `GEMINI_TIMEOUT_MS`) is preserved.

Verified the test catches the bug: run against the old two-key filter it fails
with "credential GOOGLE_API_KEY must not leak into the shared Gemini snippet";
with the fix it passes.

cargo test --lib: 2187 passed / 0 failed; cargo clippy --lib -D warnings clean.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(security): validate skill directory from backups and sync-imported DB rows

The skill install pipeline sanitizes the install directory name, but two
entry points bypassed it entirely:

- restore_from_backup joined metadata.skill.directory (raw meta.json
  content) into the SSOT dir with no validation, allowing a crafted
  backup to copy attacker-controlled files outside the skills directory
  and to persist the poisoned value into the database.
- Sync import (WebDAV/S3) loads the remote database dump verbatim, so a
  malicious or compromised sync snapshot could plant a skills row whose
  directory contains path traversal. Every later raw join then operated
  on attacker-controlled paths — uninstall/remove_from_app would
  remove_dir_all outside the managed dirs (arbitrary directory deletion),
  and sync_to_app_dir would write/symlink outside them.

Add require_valid_directory() (built on the existing
sanitize_install_name) and enforce it at restore_from_backup,
sync_to_app_dir, remove_from_app, and uninstall. Regression tests cover
all three sinks plus the metadata path; each was verified against the
unguarded code first (disabling the guard makes the restore/uninstall
tests complete the traversal write/delete, failing the assertions).

Also fixes a pre-existing cargo fmt drift in mcp/grokbuild.rs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): reject path separators in sanitize_install_name on all platforms

The previous components()-based check was platform-dependent: on
Linux/macOS a backslash is not a path separator, so "a\b" parsed as a
single Normal component and was accepted as a valid install name — the
same value becomes a nested path when synced or restored onto Windows.
CI caught this on the Linux runner (the Windows runner passed).

Reject both '/' and '\' explicitly so the validation is
platform-independent; the components() check stays as the second layer
for dot segments, roots, and prefixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): close the remaining skill-install attack chain

The zip-slip guard added earlier only covered half the problem, and the
repo coordinates that decide *which* archive gets downloaded were never
validated at all. Together those two gaps let an attacker choose both the
bytes and where they land.

1) zip-slip, second layer. `enclosed_name()` only guarantees the entry does
   not escape the *archive's own* root, and it does not normalise the path —
   `..` survives verbatim in the returned value. Stripping `root_name`
   afterwards spends one level of that depth budget, so `repo-main/../evil`
   still escaped `dest`. On Windows it is worse: `root_name` comes from
   `split('/')` and may contain backslashes, which Windows treats as
   separators, so one `strip_prefix` can eat N components. Re-check the
   *actual* relative path for `ParentDir` right before `dest.join()`.
   Verified by reverting the guard: the single-`..` case escapes without it.
   (The existing test used two `..`, which `enclosed_name()` rejects on its
   own — it covered the half that was already guarded.)

2) Repo coordinates are now validated. `download_repo` formats owner/name/
   branch straight into
   `https://github.com/{owner}/{name}/archive/refs/heads/{branch}.zip`,
   and `deeplink/skill.rs` applied no validation whatsoever. URL parsing
   resolves dot segments, so a branch of
   `../../../releases/download/v1/evil` retargets the request at a *release
   asset* — arbitrary attacker-uploaded bytes. That is what made (1)
   reachable in practice: git itself will not let a tree contain `..`.
   Note the trigger is below "install": repos are enabled by default, so
   merely opening the Skills panel downloads and extracts.

   `validate_repo_ref` whitelists all three fields. Branch names legally
   contain `/` (`feature/x`), so the check is per segment rather than a
   blanket separator ban; an empty branch keeps its existing "use the
   default branch" sentinel meaning, same as `HEAD`. The main guard sits in
   `download_repo` — the single convergence point for all four call paths —
   because both `skill_repos` and `skills` can be overwritten wholesale by a
   sync snapshot, which no insert-time check can prevent. Entry-point checks
   (deeplink, `add_skill_repo`, agents lock) exist so bad values surface
   immediately instead of silently failing on every later panel open.
   `build_skill_doc_url` is covered too: it feeds `readme_url`, which the
   frontend hands to `openExternal`.

3) Regressions from the previous commits in this branch, both fixed:
   - `uninstall` ran the directory guard *before* `db.delete_skill`. That
     method has exactly one call site and is not exposed as a command, so a
     row with a dirty `directory` (pre-v3.11.0 installs, or anything
     `import_from_apps` still creates today — it has no sanitiser) became
     permanently undeletable from the UI. Now the guard skips the filesystem
     work but still deletes the row.
   - `sync_to_app` propagated with `?`, so one bad row aborted the entire
     app's skill sync — and that runs on provider switch. Now it warns and
     continues per entry.
   - `require_valid_directory` returned `sanitize_install_name`'s normalised
     value, which trims. A DB value of `" foo "` would then join as `"foo"`
     and miss the real directory. It now validates without rewriting.

4) Guard applied to the sinks the earlier commits missed: `migrate_storage`
   (rename + remove_dir_all), `update_skill` (delete + write attacker-named
   paths), `import_from_apps` (both a sink and the source of dirty values —
   `selection.directory` arrives raw over IPC), `resolve_uninstall_backup_source`
   (copies any directory into the backup area, which the UI then lists),
   `check_updates`, and `migrate_skills_to_ssot`.

5) Extraction limits. The archive bytes were fully attacker-controlled via
   (2), and this path had no ceiling of any kind, unlike
   `webdav_sync/archive.rs`. Added entry count, total extracted bytes, a
   symlink-target cap (zip 2.4.2's `make_reader` does not truncate at the
   declared `uncompressed_size`, so a symlink-flagged entry that inflates to
   gigabytes was read straight into memory), per-directory charging (a
   directory-only archive writes no content bytes but still consumes inodes),
   and a download-body cap (`response.bytes()` buffered the whole archive
   before any limit applied). Budget is charged on bytes actually read and
   written, never on sizes declared in the archive header.

   Symlink materialisation is charged to the same budget, and a target that
   contains the link itself (`dir/link -> ..`) is now rejected: the existing
   "must stay inside base" check passes for it, and the recursive copy then
   re-copies its own output every level until PATH_MAX.

6) Temp directories are now RAII. `download_repo` and `extract_local_zip`
   called `TempDir::keep()` immediately and relied on every exit path
   remembering `remove_dir_all`. Several did not — including
   `fetch_repo_skills`, the highest-frequency path of all. Returning the
   guard makes the leak unrepresentable at the call sites.

* fix(config): make removals and edits survive user-authored config shapes

The previous commits hardened the *write* side of the MCP tables against a
non-table `mcp_servers`. The read/delete side kept using `as_table_mut`,
which returns None for an inline table (`mcp_servers = { foo = {...} }` —
valid TOML). Removal then silently did nothing: the UI reported success, the
entry stayed in the file, and Codex loaded it again on next start. That is
worse than the panic it mirrors, because users usually reach for the toggle
precisely when an MCP server is misbehaving. Both Codex and GrokBuild now use
`as_table_like_mut` on both sides.

Normalisation is no longer silent either. Replacing a user's hand-written
`mcp_servers = "x"` with an empty table destroys data, so all four sites that
do it now log a warning first.

`update_codex_toml_field` had the same asymmetry with a different symptom:
when `model_providers` or `[model_providers.<id>]` was an inline table,
`as_table_mut` returned None and execution fell through to the "write a
top-level field" fallback. The user's `base_url` edit landed at the wrong
level, Codex never read it, and nothing reported a problem.

`opencode_config` parses `~/.config/opencode/opencode.json` with json5 into a
bare Value and never checked the root's shape. A user file of `[]` or a
scalar made `set_provider`, `set_mcp_server` and `add_plugin` all panic on
index-assignment, inside a Tauri command, unwinding across the FFI boundary.
(A top-level `null` is fine — serde_json promotes it.) Rejecting a non-object
root at the single read site fixes all three. Rejecting rather than rebuilding
is deliberate: the file also holds the user's own `model` / `theme` settings,
so a silent rebuild would delete them. Same treatment for the `provider` and
`mcp` sections, whose non-object forms made writes silently no-op.

* fix(proxy): recover a malformed Anthropic content_block as text

Sanitising a non-object `content_block` to `{}` stops the panic but creates a
quieter failure: the final Responses conversion matches on the block's `type`
and silently drops anything it does not recognise, so a garbled block header
turned into a `completed` response with empty output. The client saw the model
say nothing, with no signal that data had been discarded.

The replacement now carries `type: "text"`. The deltas that follow a malformed
header are usually well-formed, so this recovers the common case; a tool-use
block still yields nothing, exactly as before. A warning is logged when the
substitution happens.

The regression test now asserts through `anthropic_response_to_responses`
rather than on the intermediate value — asserting on the intermediate alone
passes while the client still receives an empty response.

* fix(grokbuild): decouple base_url from credential resolution, reject env_key-only links

`resolve_usage_credentials` called `extract_credentials(...).unwrap_or_default()`,
so a missing credential blanked the base_url too — even though it is written
right there in the config. Removing the `XAI_API_KEY` fallback in the earlier
commit widened that considerably: a GUI process on macOS does not inherit the
shell environment, so an `env_key` that resolves fine in a terminal resolves to
nothing here. The result was a Base URL shown in the UI that differed from the
one actually used, `{{baseUrl}}` expanding to empty in usage scripts (turning
requests relative), and native balance queries reporting "API key is empty"
while hiding the real cause. The two values are now resolved independently, via
the existing `grok_config::extract_base_url`, matching how the Codex arm of the
same function already works.

A deeplink whose only credential is `env_key` is now rejected by name. It was
already unimportable — `build_grokbuild_settings` has no `env_key` slot — but it
failed with the generic "API key is required", which reads like a malformed link
and invites the obvious "just carry the name over" fix. Carrying it over is
exactly what must not happen: the forwarder and the usage query both resolve
`env_key` at request time, so the victim's environment secret would still reach
the link's `base_url`. Same leak, merely deferred. The message says so, and the
test sets the probe variable so that restoring the resolution turns it red.

* fix(security): scrub credentials already leaked into the Gemini shared snippet

Fixing the extractor only prevents new contamination. A Gemini snippet is never
re-extracted once it exists — startup auto-extract and post-import extraction
both require `snippet.is_none()`, and the on-switch rewrite only covers Claude
and Codex — so existing users keep injecting the leaked key into live config,
the proxy upstream, and the new-provider form, where another account's key is
plainly visible.

Removing it from the snippet alone would make things worse. Merge and strip
cancel out by *value equality*: on switch-away, `remove_common_config_from_settings`
deletes the injected keys using the snippet as its only record of what to look
for. Once the key is gone from the snippet, the residue left in live config is
backfilled verbatim into the victim provider's `settings_config`, turning a
transient leak into a permanent one. So the cleanup covers all four locations at
once, and step order is itself a safety property: every fallible step runs before
the irreversible one, and a failure returns an error so the next start retries
from an intact state.

Deletion is by key *and* value, never by key name alone, so a provider's own
same-named key with a different value survives. The `~/.gemini/.env` edit
preserves layout rather than round-tripping through
`parse_env_file`/`serialize_env_file`: that pair drops comments, blank lines and
unparseable lines, collapses duplicate keys and re-sorts the file. Acceptable
when re-projecting everything, destructive for a targeted removal the user never
asked for — in testing it reduced a six-line fixture to one line, taking the
user's own key with it.

An audit record is written before any provider row changes, listing key names and
affected provider ids but *no values*: `settings` is not in `SYNC_SKIP_TABLES`,
so it is uploaded by WebDAV/S3 sync, and these are precisely the credentials that
must be destroyed. Keeping values would trade one deletion for a plaintext copy
that spreads across devices, has no UI to reach it, and never expires. It is
written only when absent, so a partially-applied earlier run cannot overwrite the
original pre-cleanup state.

Consequence, intentional: some Gemini providers will now report a missing API key
and need one entered. That key was never theirs. (The victim's own value was
already overwritten at merge time and cannot be recovered — worth noting in the
release notes, along with a recommendation to rotate the leaked key.) The snippet
row is deleted rather than set to `{}` when nothing shareable remains, and the
`cleared` flag is deliberately not set — either would disable auto-extract
permanently and prevent the user's legitimate shared config from ever coming back.

The frontend snippet validator is aligned with the backend matcher. It had the
same two hardcoded key names, so a hand-edited snippet could put `GOOGLE_API_KEY`
straight back with no error.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-07-28 16:34:11 +08:00
Jason 2b2f2cfad9 ci: mirror in-app updater to Cloudflare R2 with release-gated sync
- Move the R2 sync out of release.yml into a standalone sync-r2.yml
  triggered on release promotion (release: released) or manual dispatch,
  so the mirror follows the same gate as GitHub's /releases/latest
- Hard-fail on the official repo when R2 secrets are missing (a silently
  stale mirror strands updater users); forks may still skip
- Only the tag that is currently releases/latest may rewrite the root
  manifests or prune old versions; backfills of older tags restore
  versioned files only
- Resolve releases/latest with retries and fail instead of guessing on
  API errors; re-verify right before publishing the root manifests
- Add scripts/rewrite-updater-manifest.mjs to point latest.json download
  URLs at the mirror (minisign signatures cover file contents and stay
  valid); upload the macOS .tar.gz updater payload alongside installers
- Put https://dl.ccswitch.io/latest.json first in the updater endpoints
  with GitHub as connectivity fallback
2026-07-27 18:41:49 +08:00
Jason b972f0a3bd feat(presets): upgrade default models to Opus 5, GPT-5.6 Sol and Gemini 3.6 Flash
Bump the default model IDs across every preset file and the downstream
defaults that mirror them:

- claude-opus-4-8 / anthropic/claude-opus-4.8 / global.anthropic.claude-opus-4-8
  -> claude-opus-5, covering all three naming forms
- gpt-5.5 and the bare gpt-5.6 -> gpt-5.6-sol
- gemini-3.5-flash -> gemini-3.6-flash

Add gemini-3.6-flash to the built-in pricing seed (1.50 in / 7.50 out /
0.15 cache read per million). The seed runs INSERT OR IGNORE on every
startup, so existing databases pick the row up without overriding prices
the user edited by hand.

Advance the Claude Desktop opus route ID in step with the frontend SSOT:
CURRENT_OPUS_ROUTE_ID becomes claude-opus-5 and claude-opus-4-8 takes over
the LEGACY slot, so route IDs stored in existing user configs still resolve
through is_compatible_opus_route_alias.

Also sync omo.ts recommendations, form placeholders, the SudoCode partner
blurb and all four locales, plus the preset assertions that pin these IDs.
2026-07-26 19:08:33 +08:00
Jason 9cf4ae41e6 feat(pricing): add Claude Opus 5 to built-in model pricing table
Seed claude-opus-5 at $5/$25 per MTok with $0.50 cache read and $6.25
cache creation, matching the Opus 4.8 tier. Verified against models.dev
and the official Anthropic models overview. Fast mode ($10/$50) is a
separate billing path and is deliberately left out of the table.

New model id, so seed only; no repair entry and no SCHEMA_VERSION bump.
2026-07-26 17:06:20 +08:00
Jason 878c26f31e feat(proxy): extend tool-result media handling to all conversion bridges
Generalize the Codex Responses-to-Chat tool media mechanism from 6c9d444c
to the remaining protocol bridges, so image-bearing tool results are never
tokenized as base64 text on any conversion path:

- Claude-to-Chat: tool_result images become native image_url parts,
  batched into one synthetic user message after the tool message batch.
- Claude-to-Responses: JSON-string, MCP, and nested variants are restored
  as native input_image parts inside function_call_output.
- Codex/GrokBuild-to-Anthropic: non-standard tool images are restored as
  Anthropic image blocks (case-insensitive data:/http prefix parsing).
- Claude-to-Gemini: Gemini 3 uses multimodal functionResponse.parts;
  older models get inlineData parts in the same user turn. The new
  InlineImagesOnly scope keeps remote URLs and malformed data URLs in the
  legacy text form instead of emitting fileData the API would reject.

Shared changes in tool_media:

- Centralize the plan/queue/flush helpers used by both Chat bridges.
- strip_and_clamp_media_from_tool_value clamps residual base64 inside
  parsed JSON strings at any nesting depth before re-serialization.
- Emitted Chat parts no longer carry cache_control or
  prompt_cache_breakpoint, preserving the strip-all-cache_control
  contract that strict upstreams (GLM/Qwen) depend on.
- Media detection now requires full convertibility, keeping detection
  and extraction symmetric by construction.

The media sanitizer detects and strips the new shapes symmetrically
(Chat string tool results, Anthropic string tool results, Gemini
inlineData/fileData and functionResponse.parts); the legacy typed-block
replacement runs first so replacement stays a superset of detection and
cache_control survives on replaced Anthropic blocks.

No-media tool outputs keep byte-identical legacy representations on all
bridges to protect prompt-cache prefixes.
2026-07-24 12:12:22 +08:00
Jason 6c9d444c8a fix(proxy): move Codex tool-result media out of stringified tool text
The Responses->Chat conversion serialized image-bearing *_output items
into role:"tool" text via canonical_json_string, so view_image results
were tokenized as base64 text (~9000x inflation). Codex replays full
history every turn, so sessions hit context-limit 400s and wedged
(#4465, #5663).

- add proxy/tool_media: shared detection/strip/clamp walker for tool
  output media (typed input_image / image_url / input_file /
  input_audio, Anthropic source and MCP data+mimeType image shapes,
  untyped data: image_url, whole-string bare data URLs)
- transform_codex_chat: replace media blocks in place with marker text
  so tool content stays a plain string, and flush the extracted media
  as one synthetic role:"user" message after each consecutive tool
  batch; media-free traffic stays byte-identical to keep prompt-cache
  prefixes stable
- media_sanitizer: detect and strip tool-output media symmetrically
  (including JSON-string outputs) so reactive image stripping can heal
  upstream modality rejections
- forwarder: regression tests pinning the reactive trigger and the
  context-limit-400 non-trigger

E2E against Kimi K3 through the proxy: the replayed turn stays ~12k
input tokens with 99% cache hit, versus ~85k+ of base64 text per
replay before.
2026-07-24 10:16:57 +08:00
Jason cd161f4401 feat(usage): import Grok Build official-mode usage from session logs
Grok CLI's official OAuth mode cannot be routed through the local proxy
(empty config is the mode switch, so there is no injection point), which
left official-mode usage invisible. Add session_usage_grokbuild to
import usage from ~/.grok/sessions updates.jsonl:

- Only turn_completed events carry usage; each event is the independent
  per-turn total (accumulated across inference loops within one prompt),
  so events are imported at face value. Do not reintroduce differencing
  of adjacent events: counters reset every turn and differencing would
  massively under-record.
- Cost priority: reported costUsdTicks (1 tick = 1e-10 USD) wins when
  complete, because the backfill only repairs rows with total <= 0 and
  can never correct a positive mispriced value; local pricing fills the
  breakdown and raises a drift warning above max(1% of reported, 1e-6).
  costIsPartial marks the reported value a lower bound: prefer a full
  local recompute when the model is priced, else record the lower bound.
- Idempotency key grok_session:{session}:{prompt_id}:{model} anchors on
  the upstream per-turn UUID (index fallback only when prompt_id is
  empty), so rewind truncation cannot shift keys and double count;
  orphan rows from truncated turns are kept since the tokens were spent.
- Anti double-count vs proxy takeover: 10min settle window plus a
  time-window guard over recent grokbuild proxy activity; guarded skips
  never mark files as synced.
- Seed grok-4.5-build pricing 2/6/0.30, back-derived from exact
  costUsdTicks samples (cache read bills at 0.30, not the listed 0.50).
- Map _grok_session to a friendly provider display name and refresh the
  takeover-capability comment in services/proxy.rs.
2026-07-23 17:00:03 +08:00
Jason 3cf84ca362 fix(usage): centralize cache-inclusive app set and cover grokbuild in cost backfill
The cost backfill hardcoded codex|gemini as cache-inclusive apps, so
grokbuild TOTAL-semantics rows were priced on full input tokens with
cache reads double-counted. Converge the writer (proxy logger and
calculator) and the backfill recompute onto a single
sql_helpers::is_cache_inclusive_app predicate backed by the existing
CACHE_INCLUSIVE_APP_TYPES constant, and add a regression test for the
grokbuild backfill path.
2026-07-23 16:59:41 +08:00
Jason 15d5dbe065 feat: add Grok official subscription quota query
Add SuperGrok subscription usage display, following the existing
Claude Code / Codex official-subscription pattern (protocol ported
from steipete/CodexBar):

- New subscription_grok service: reads Grok CLI credentials from
  ~/.grok/auth.json, calls the grok.com GrokBuildBilling gRPC-web
  endpoint, and parses the response via heuristic protobuf scanning
  (used percent, reset time, zero-usage special case)
- Transient failures (network errors, HTTP 408, gRPC deadline/
  cancelled) propagate as Err so the frontend retries and keeps the
  last good value; auth failures map to Expired with a re-login hint
- Tier naming by reset distance: weekly limit, monthly, or a new
  "credits" tier (i18n added for zh/en/ja/zh-TW; tray shows "c")
- New get_xai_oauth_quota command: xai_oauth providers (managed
  SuperGrok OAuth accounts) query the same billing endpoint with
  their bound account token; ProviderCard auto-renders the quota
  footer for them and hides the usage-script entry, and the tray /
  usage-script path routes xai_oauth providers to the managed
  account instead of the host app's CLI credentials
- UsageScriptModal: drop the config-content heuristic for official
  detection; category === "official" is the single source of truth

Claude-Session: https://claude.ai/code/session_01LSNvhEfuoJHaQLZcYQgBU5
2026-07-23 11:13:07 +08:00
Jason df1751a893 chore(release): v3.18.0 2026-07-21 22:23:17 +08:00
Jason f733def452 feat(grokbuild): add Grok Official provider with official-state import
Add a "Grok Official" preset and seed (grokbuild-official) whose empty
config represents the official login state: no custom [model.*] tables
are written, so Grok CLI falls back to its built-in xAI OAuth login and
cc-switch never touches those credentials.

Backend:
- Seed entry in OFFICIAL_SEEDS plus ensure_grokbuild_official_provider
  command for on-demand repair (the one-shot master seeding flag is
  already set for existing databases).
- Split validation into syntax-only (empty allowed) for live reads,
  writes and official snapshots, keeping the full custom-model shape
  check for non-official provider writes and imports. Backup/restore
  can now round-trip an official-state live file.
- Manual import (command layer only) recognizes an official-state live
  config and imports it as the official entry set as current, matching
  the Codex official-login import outcome. Startup auto-import keeps
  rejecting official-state live so a deleted official entry is never
  resurrected on launch: startup import only captures real user data
  as "default" and never manufactures official entries.
- Manual import also ensures the official entry before importing
  (claude-desktop precedent) and after a successful custom import, so
  first-time users end up with default + official like other apps.
- Proxy takeover guards skip or reject official-state live configs in
  all three takeover paths, consistent with the official-provider
  takeover ban.

Frontend:
- Grok Official preset entry in the GrokBuild form: official category
  hides connection fields and passes the raw config through untouched.
- Filter managed-OAuth presets out of the GrokBuild preset list; they
  were never wired for this app and produced keyless broken configs.

Tests cover seed presence, official round-trip, ensure-after-deletion,
and the four import scenarios including startup non-resurrection.
2026-07-21 16:39:34 +08:00
Jason dbb5bd1537 feat(codex): xAI (Grok) OAuth managed provider with native Responses compat
Add a managed "xAI (Grok) OAuth" Codex provider that routes through the
local proxy to api.x.ai via the shared Grok CLI OAuth identity, plus the
native-Responses compatibility layer that makes Codex 0.142+ traffic work
against xAI's strict upstream serde parser.

Provider:
- codex.rs: recognize the xai_oauth placeholder in extract_auth, hard-pin
  the base URL to api.x.ai and the tool profile to native Responses
- forwarder.rs: treat xAI OAuth auth failures as non-retryable
- presets + ProviderForm/CodexFormFields: managed OAuth preset that hides
  the api key/endpoint fields and derives the provider type across apps

Native Responses compatibility (gated on is_xai_oauth, so no other
provider is affected):
- transform_codex_responses_namespace: flatten Codex's private
  namespace/plugin tool declarations into top-level function tools on the
  request; restore the flat function_call names back to {name, namespace}
  on the response (streaming and non-streaming) so the client matches its
  own namespaced tool registry
- transform_codex_responses_xai_sanitize: strip the OpenAI-backend-private
  fields xAI rejects (external_web_access, prompt_cache_retention,
  safety_identifier, the additional_tools carrier, tool_search, ...) with
  deterministic removals that keep the prompt-cache prefix stable
- wire both into the native passthrough after the request transform;
  response restore runs in a dedicated handler so the generic passthrough
  hot path is untouched

Ports the proven approach of sub2api's Grok Responses gateway. Verified
with a 4-round codex -> xAI OAuth workload: all tasks green, zero upstream
errors.
2026-07-21 16:39:34 +08:00
Jason 8dcedbc062 feat(tools): prefer xAI native Grok installer with npm fallback
Treat Grok like Claude/OpenCode: install via the official xAI installer
(POSIX shell / Windows PowerShell), keep npm as fallback, discover
~/.grok/bin, and anchor updates to `grok update` only for native installs.
2026-07-21 16:39:34 +08:00
Jason eff1e0ccfc feat(db): rebuild Codex usage on upgrade and via maintenance action
Schema v16 wipes codex_session detail rows, _codex_session rollups and
Codex rollout cursors inside the migration savepoint (cursor deletion
uses pure shape matching so CODEX_HOME drift cannot orphan cursors);
the next session sync re-imports history from source JSONL under the
corrected importer. Fresh installs traverse the same branch as a no-op.

Add a manual "Rebuild Codex usage" maintenance action (single-flight,
hard-fail backup before reset, unconditional refresh notification even
when reimport is empty or fails after reset) with a destructive confirm
dialog, result toast and four-locale strings. Historical proxy-side
duplicate rows are intentionally left untouched; history whose source
JSONL was already deleted cannot be reconstructed.
2026-07-21 16:39:34 +08:00
Jason c9ac6efd69 fix(proxy): add stable usage keys and idempotent raw-response logging
Derive request ids from upstream envelope ids (Codex/OpenAI top-level
id, Gemini responseId, Claude message id with non-empty filtering)
scoped as session:{app_type}:{provider_id}:{id} for non-Claude sources;
Claude keeps bare session:{id} to preserve session-log convergence.

The logger now queries and conditionally writes under a single
connection guard: identical semantic replays return without writing or
notifying, session_log rows may be upgraded by proxy, and same-id
different-semantic responses land on a deterministic SHA-256 collision
fallback key instead of being overwritten. Fixes the random-UUID
INSERT OR REPLACE duplication behind #5496.
2026-07-21 16:39:34 +08:00
Jason df3e07edbb fix(codex): strip forked history via parent-rollout token-prefix alignment
Rewrite the Codex session importer: identify forks by forked_from_id or
subagent thread_spawn.parent_thread_id (conflicts defer), anchor thread
identity to the rollout filename UUID, and align child token_count
signatures against the parent rollout as a strict prefix so replayed
parent history only establishes the cumulative baseline and is never
imported as child usage. Ambiguous alignments defer without advancing
the cursor and recover once the parent appears; parent signatures are
cached per (path, cutoff).

Fixes the 3x-20x double counting behind #5335/#5433 while keeping
reference-type subagents fully counted (#5381).
2026-07-21 16:39:34 +08:00
Jason a10b569a53 feat(usage): add suspected-duplicate probe for Codex session imports
Detect codex_session rows sharing the same model/token fingerprint
within the dedup window under a different request_id. Predicates reuse
the COALESCE(data_source,'proxy') shape so the existing expression
index serves the lookup. Wired up by the Codex importer rewrite.
2026-07-21 16:39:34 +08:00
Jason eb105eae76 perf(usage): coalesce session-sync notifications and serialize sync execution
Replace per-insert usage notifications with a single notification per
sync pass, guard all sync entry points (startup backfill, 60s loop,
manual sync) behind a process-wide single-flight tokio mutex, and move
blocking file/DB work onto spawn_blocking with MissedTickBehavior::Skip.

Extend SessionSyncResult with deferredFiles/suspectedDuplicates
observability fields (saturating aggregation) and add a test seam that
counts notifications even without an injected AppHandle.
2026-07-21 16:39:34 +08:00
Jason 01fca69641 test(updater): align grok npm anchor expectation with PATH prefix
bf047990 prefixed anchored npm invocations with their sibling bin
directory but missed updating the grok test expectation.
2026-07-21 16:39:34 +08:00