Compare commits

...
89 Commits
Author SHA1 Message Date
雷电芽衣 c56660fe07 chore(legal): regenerate THIRD-PARTY-NOTICES for the 0.1.19 workspace version
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 6s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish GitHub Release (push) Canceled after 0s
2026-09-29 16:54:21 -04:00
雷电芽衣 3bde7fe851 chore(release): bump workspace version to 0.1.19 2026-09-29 16:53:53 -04:00
雷电芽衣 793b93b74d test(subagent): the auth-scheme test joins the existing tests module 2026-09-29 16:53:07 -04:00
雷电芽衣 c9855c4bb6 fix(review): custom provider login comments, doc placement, subagent scheme test
The subagent auth-scheme lookup is now a function with a test. The failed-login
span carries the same method id as the successful one. README order matches the
code: the provider is saved, then the key is checked.
2026-09-29 16:50:03 -04:00
雷电芽衣 c4ef8a3779 docs+test: custom providers in README and AGENTS.md, provider-only startup PTY test
Also records the codex catalog at eight models and the 0.159.0 User-Agent.
2026-09-29 16:37:56 -04:00
雷电芽衣 ae40ba8da2 test(inspect): serialize the version_overrides test against KIGI_TEST_VERSION setters
It read the installed version while serial tests set a broken one, and failed
about one full run in four.
2026-09-29 16:34:19 -04:00
雷电芽衣 de42bb55c9 feat(models): gpt-6.1-sol joins the compiled-in codex catalog; codex User-Agent follows 0.159.0
The Codex backend (client_version 0.159.0) served eight list+supported models on
2026-09-29, gpt-6.1-sol first: context 272000, efforts low..max, default low.
POST /codex/responses with kigi's codex headers answers 200 and completes.
2026-09-29 16:34:19 -04:00
雷电芽衣 791f953c07 feat(login): custom provider rows, URL / name / key steps, and custom:<name> authenticate
The picker lists Custom provider (OpenAI compatible) and (Anthropic compatible).
The TUI asks for a base URL, a name (default from the host), and a key, saves the
definition and the key, and the agent validates the key against GET {base}/models.
PTY e2e covers both wires against the mock inference server.
2026-09-29 16:10:41 -04:00
雷电芽衣 eb86d7ec26 fix(shell): turns read the auth scheme and BYOK status from the live catalog
reconstruct_full_config and the subagent spawn resolved both from a disk reload
that never holds fetched entries, so a fetched Messages model (anthropic,
minimax) went out with Authorization: Bearer instead of x-api-key.
2026-09-29 16:10:41 -04:00
雷电芽衣 2ca1f3baa7 feat(shell): custom providers declared in config.toml join the model catalog
[platforms.<name>] with base_url and api (openai or anthropic) declares a provider.
Its key lives in auth.json under the name or in the table; /models is fetched per
provider, entries are keyed <name>/<model>, and the key is stamped in memory only.
2026-09-29 14:50:24 -04:00
雷电芽衣 4514b66ffe feat(models): custom provider wire kinds, name and base-URL rules 2026-09-29 14:08:29 -04:00
雷电芽衣 45edd7bf33 docs(agents): record the 0.1.19 upstream sync window and what it left behind 2026-09-29 14:03:38 -04:00
雷电芽衣 ceff1d7cc7 fix(tui): a second Ctrl+C cancels a plan comment
The first Ctrl+C clears the draft; the second did nothing and left the user in
comment mode. Both the approval and the casual preview paths now cancel.
2026-09-29 14:02:43 -04:00
雷电芽衣 041c5ca030 fix(tui): a bracketed paste that is not the clipboard never attaches its image
The origin check ran only on Otty. An IME commit or a diverged tmux buffer on any
other terminal attached whatever image sat on the clipboard.
2026-09-29 13:54:45 -04:00
雷电芽衣 0e063e5b02 fix(tui): y copies the whole queued prompt; a finished queue edit refocuses the composer
y copied the truncated display row. Saving or discarding a row edit left focus on
the queue pane, so the next Enter re-opened the edit instead of sending.
2026-09-29 13:43:56 -04:00
雷电芽衣 b840fff80c fix(sampler): Messages streams keep every thinking block and join split signatures
A turn with interleaved thinking lost all but its last signed block on
replay, and a signature split across deltas kept only its last part.
2026-09-29 13:32:03 -04:00
雷电芽衣 cb4c2eaad9 update version
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 6s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish GitHub Release (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
2026-09-22 18:43:42 -04:00
雷电芽衣 cfde4e91a1 chore(legal): regenerate THIRD-PARTY-NOTICES for the 0.1.18 workspace version 2026-09-22 18:42:49 -04:00
雷电芽衣 5082bd47ee chore(release): bump workspace version to 0.1.18 2026-09-22 18:42:49 -04:00
雷电芽衣 5d70140d62 docs(agents): the codex table is seven models 2026-09-22 18:42:49 -04:00
雷电芽衣 299c6bcfcb chore(sampler): codex User-Agent follows the 0.156.0 release 2026-09-22 18:42:49 -04:00
雷电芽衣 6be9ab0aea feat(models): gpt-6-sol and gpt-6-luna join the compiled-in codex catalog
The Codex backend (client_version 0.156.0) served seven list+supported
models on 2026-09-22: gpt-6-astra, gpt-6-sol, gpt-6-luna, then
gpt-5.6-sol/terra/luna and gpt-5.5. Sol and Luna carry context 272000,
efforts low..max, default medium. POST /codex/responses with kigi's codex
headers answers 200 and completes for both.
2026-09-22 18:42:49 -04:00
ZacharyZhang-NY 0e4e446fe4 docs(agents): record where Shift+Enter can and cannot arrive
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 6s
Release / publish GitHub Release (push) Canceled after 0s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-09-21 19:25:20 -04:00
ZacharyZhang-NY 008b6623f6 docs(tui): correct the VTE and WezTerm notes around the Shift+Enter hint 2026-09-21 19:25:04 -04:00
ZacharyZhang-NY f8cd33d19d fix(tui): the Shift+Enter hint follows the protocol, not a brand list
`shift_enter_unavailable` kept its own list of brands and missed every
other reason the Kitty keyboard protocol gets skipped: JetBrains, screen,
tmux below 3.3 and tmux with `extended-keys off` all swallow it, yet the
prompt still advertised Shift+Enter there. It now reads
`kitty_skip_reason` directly. Two skips deliver the modifier another way
and stay available: Apple Terminal, where `route_enter` polls
CoreGraphics, and Windows Terminal, where the console key record carries
SHIFT — a bare ConHost, only optimistically refined to Windows Terminal,
keeps advertising Alt+Enter.

The old list also carried a `VTE_VERSION >= 8200` cutoff, citing VTE
0.82.0 as the first release with the protocol. It is not: at tag 0.82.0
the string "kitty" appears nowhere in `src/parser-seq.py`, `src/vte.cc`
or `src/widget.cc`, and `_vte_keymap_GDK_Return` encodes only Alt
(`ESC CR`), so Shift+Enter leaves a VTE terminal as the same bare CR as
Enter. Every VTE now advertises Alt+Enter, and `/terminal-setup` stops
telling GNOME Terminal users to upgrade to a version that would not
help. It also stops advertising Alt+Enter to WezTerm, which binds that
chord to ToggleFullScreen.
2026-09-21 19:19:21 -04:00
ZacharyZhang-NY 2e40ebb08b docs(agents): the codex table is five models, and how to refresh it 2026-09-21 18:43:46 -04:00
ZacharyZhang-NY fcb7588312 feat(models): gpt-6-astra joins the compiled-in codex catalog
The Codex backend served five `visibility=list` + `supported_in_api`
models on 2026-09-21: `gpt-6-astra` ahead of `gpt-5.6-sol/terra/luna`
and `gpt-5.5`. Astra carries context 272000 and efforts
low/medium/high/xhigh/max, default medium; the `ultra` tier it also
advertises has no kigi level and is left out. `gpt-reserve` and
`codex-auto-review` stay hidden.

Inference checked against the live backend: `POST
/backend-api/codex/responses` with `model: gpt-6-astra` and the codex
`User-Agent` kigi sends answers 200 and streams, so the model works on
the compiled-in path.
2026-09-21 18:42:22 -04:00
ZacharyZhang-NY 9ab038f573 docs(agents): rewrap the reasoning-effort paragraph 2026-09-21 18:39:46 -04:00
ZacharyZhang-NY dd5711f24f docs(agents): the codex backend does advertise an ultra tier 2026-09-21 18:39:40 -04:00
ZacharyZhang-NY a690987ea2 revert: openai-codex goes back to the compiled-in catalog
Reverts c61977c. The live listing needed no codex CLI on the user's
machine — kigi issued the request itself and `client_version` was a
compiled-in string — but the project keeps the catalog in the binary, so
the fetch path, the `OpenaiCodex` listing dialect, its parser and the
pinned client version are gone again, and the codex `User-Agent` returns
to its previous value.

The models.dev provider rename `kimi-for-coding` → `kimi-code-plan-cn`
rode in the same commit and stays: the regenerated snapshot ships under
the new id.
2026-09-21 18:36:46 -04:00
ZacharyZhang-NY 95d4f19650 chore(legal): regenerate THIRD-PARTY-NOTICES for the 0.1.17 workspace version 2026-09-21 15:16:47 -04:00
ZacharyZhang-NY 99e09cfaf5 chore(release): bump workspace version to 0.1.17 2026-09-21 15:16:13 -04:00
ZacharyZhang-NY 0c580afb5f chore(models): regenerate the models.dev enrichment snapshot
`xai` and `openai` carry `restrict_to_enriched`, so a live listing id the
enrichment catalog does not know is dropped. The bundled snapshot knew
neither `grok-4.7` nor `gpt-6-astra`, which left both invisible offline
and until the 24h models.dev refresh landed.

Regeneration also tripped the generator's fail-fast: models.dev renamed
the Kimi coding provider. `kimi-for-coding` is now `kimi-code-plan-cn`
(api.kimi.com — kigi's endpoint), with `kimi-code-plan-global` the
api.kimi.ai twin; the model set is unchanged. Both registry rows, the
generator target and the two snapshot lookups follow the new id, and the
cache `keep_set` guard invalidates any cache filtered under the old one.
2026-09-21 15:13:00 -04:00
ZacharyZhang-NY c61977cb70 feat(models): fetch the openai-codex catalog live
The ChatGPT Codex catalog was compiled in: four models read once from the
Codex CLI cache. The backend moved on — `gpt-6-astra` shipped, and
`gpt-5.3-codex-spark` left — so the picker showed a table that no longer
matched the account.

`GET https://chatgpt.com/backend-api/codex/models?client_version=<v>`
serves that table. Verified against a live ChatGPT account: the OAuth
Bearer alone returns 200, the query is required (400 without it), and the
backend gates the catalog on it — `0.104.0` answers with
`codex-auto-review` alone, `0.150.0` omits `gpt-6-astra`, `0.155.1`
serves the full set. `CODEX_CLIENT_VERSION` pins the current upstream
release (openai/codex rust-v0.155.1) and the codex `User-Agent` now
derives from it, so the two can never drift.

`ListingDialect::OpenaiCodex` + `parse_openai_codex_listing` keep the
`visibility == "list" && supported_in_api` models and carry the wire
context window and per-model reasoning levels; `hardcoded_catalog` and
its short-circuit in the fetch path are gone. The catalog advertises an
`ultra` tier kigi has no level for — `think_efforts_to_options` drops it
with a warning, as it does any unknown token.
2026-09-21 15:12:54 -04:00
ZacharyZhang-NY 781fb44659 chore(legal): regenerate THIRD-PARTY-NOTICES for the 0.1.16 dependency set
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 5s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish GitHub Release (push) Canceled after 0s
`cargo about generate about.hbs -o THIRD-PARTY-NOTICES.md` (cargo-about
0.9.2) on the 0.1.16 lock: kigi crates at 0.1.16, rustls 0.23.45,
rustls-webpki 0.103.15, aws-lc-rs 1.18.1, aws-lc-sys 0.45.0. The set of
listed crates is otherwise unchanged; the large diff is block order.
2026-09-19 04:21:53 -04:00
ZacharyZhang-NY a640816c38 chore(release): bump workspace version to 0.1.16 2026-09-19 04:20:50 -04:00
ZacharyZhang-NY 256ff2528e test(config): two e2e routing tests stop racing KIGI_CODE_BASE_URL
`e2e_default_model_with_session_routes_to_proxy` and
`e2e_duplicate_model_field_both_entries_survive` assert the default
model's base URL. They were not serial and read `KIGI_CODE_BASE_URL`
through `EndpointsConfig`, while two serial tests (auth_method.rs,
models_fetch.rs) point that variable at wiremock. When the runs
overlapped, the base URL came back as the wiremock URI: the Linux suite
failed in 2 of 13 full runs. Presetting the variable fails both tests
every time, on v0.1.15 as well.

Both tests are now `#[serial]` and unset the variable for their scope,
the same cure `e2e_enterprise_endpoints_only_no_model_override` got for
the moonshot variable. With the variable preset they pass.
2026-09-19 04:16:35 -04:00
ZacharyZhang-NY 2820dd5f2b docs(agents): record the 0.1.16 upstream sync window and what it left behind
The sync record now ends at upstream `a28ee2b` (2026-09-17, ≈v1.0.35).
The items evaluated in this window and left for a later cycle are listed
with the reason each one waits, so the next sync does not triage them
again. `pager.toml` joins that list: its writer still ends in a bare
`fs::rename`.

Two stale lines go: `ultra` left the reasoning-effort vocabulary in
649c5f5, yet the file still described it as a codex-only tier.
2026-09-19 03:53:47 -04:00
ZacharyZhang-NY 11b5a47ef0 chore(deps): rustls 0.23.45 closes RUSTSEC-2026-0285
`cargo deny check advisories` failed on rustls 0.23.42: TLS 1.3 handshake
messages were accepted across encryption-level boundaries. `cargo update
-p rustls` moves rustls to 0.23.45 and, with it, rustls-webpki to
0.103.15, aws-lc-rs to 1.18.1 and aws-lc-sys to 0.45.0.

The same update also re-picked six unrelated edges among versions
already in the lock (errno, quinn-udp, rustix, rustls-platform-verifier
and tempfile from windows-sys 0.52.0 to 0.59.0; tempfile from getrandom
0.4.3 to 0.3.4). Those edges are restored, so the diff is the four
packages only: version and checksum, sixteen lines. `cargo metadata
--locked` accepts the lock.

Advisories pass. `cargo clippy --workspace --all-targets --locked` is
clean on Windows.
2026-09-19 03:45:52 -04:00
ZacharyZhang-NY bc93b540cd fix(clipboard): the macOS image probe writes into a private scratch dir
The osascript fallback hands the pasted raster to kigi through a file.
Every kigi process used the same three paths in the shared temp dir
(`kigi-clipboard-probe.{png,tiff,jpg}`). Two sessions pasting at once, or
a stale file from an earlier probe, could swap the file between the
AppleScript's write and kigi's read: the wrong image went to the model.

`ProbeTemps` owns a fresh 0700 directory per probe and removes it on
drop, so the hand-written removals are gone. The script builder, the
raster reader, and the read flow move out of the cfg(macos) module into
`clipboard/osascript_probe.rs`, which compiles under test on every
platform; the flow takes the script runner as a closure. The script text
with a scratch dir is byte-identical to the old builder's (checked once
against the old function, then the copy was dropped).

A file paste needs no scratch file. When the directory cannot be made,
the probe warns, sends the file-URL half of the script only, and answers
file URLs as before; with no file URL either, the scratch error surfaces
instead of an empty board. `get_image` needs the file and fails fast.

Tests (all platforms): probes never share a path, the directory goes
away with the probe, mode 0700 on unix (Linux under WSL), the reported
class is read from this probe's file, file URLs survive a missing
scratch dir, and no dir plus no file URL is an error. The remaining
cfg(macos) callers were type-checked in a stubbed scratch crate; the
warm-cache workflow builds both darwin targets before the tag.

Ported from upstream (grok-build shared/clipboard.rs ProbeTemps and
osascript_attachments, 1.0.33).
2026-09-19 03:33:02 -04:00
ZacharyZhang-NY 0189009822 fix(config): a symlinked ~/.kigi/config.toml survives a save
Every config.toml writer wrote a tmp beside the path and renamed it over
the path. With config.toml symlinked into a dotfiles repo, the rename
replaced the link with a regular file: the repo copy went stale and the
link was gone.

`config_write_dest` resolves the destination before a writer names its
tmp. For the user's own config path a symlink resolves to its referent,
so the tmp sits on the referent's filesystem and the rename stays atomic.
A dangling link is an error, not a clobber. All eight writers call it:
`save_config`, the four MCP writers, and the three import writers.

Only the user's config path is followed, and only while
`user_kigi_home()` resolves. `kigi mcp add --scope project` and the
Claude project import write `.kigi/config.toml` inside the repo; a
hostile repo could ship that file as a symlink to any file the user
owns. With no home, `kigi_home()` falls back to `./.kigi`, which is that
same project tree, so nothing is followed then. Untrusted paths come
back unchanged and the link itself is replaced, as before. AGENTS.md
records the rule.

Tests (unix; run on Linux under WSL): helper cases including the no-home
case, a project-scope save that spares the link's target, and an
end-to-end save under KIGI_SHARE_DIR. The end-to-end test fails with
following disabled. Windows symlinks were not exercised: the host lacks
the privilege.

Ported from upstream (grok-build config/fs_atomic.rs LeafSymlink::Follow,
1.0.35): the rule, not the 1375-line module.
2026-09-19 03:15:01 -04:00
ZacharyZhang-NY 206711bed5 fix(skills): a skill body stops at the 25k-token read cap
`read_file` exempted SKILL.md from every limit, and both injection paths
(`/skill` expansion and the `skill` tool) pasted the whole body. A 220 KB
skill put 55k tokens into the context on each call.

`cap_skill_body` cuts an over-cap body at the last line boundary that
fits (a char boundary when the body is one long line) and appends a note
that points at offset and limit. It runs before `apply_substitutions`, so
the arguments land after the cut. Both injection paths call it and log
the cut.

`read_file` returns a SKILL.md whole only while it fits the cap. Over the
cap it reads like any other file: offset and limit apply, and a window
that is still too large answers FileTooLarge. Without that the note would
send the model to a read that ignores its range.

`skill_file_skips_token_limit` pinned the exemption; it gives way to
`skill_file_over_cap_returns_file_too_large` and
`skill_file_over_cap_honors_offset_and_limit`, as upstream did.

Ported from upstream (grok-build tools skills/skill.rs cap_skill_body and
read_file skill_full, 1.0.33). Upstream's `max_output_bytes` read budget
in the same hunk is a feature and stays out.
2026-09-19 02:31:24 -04:00
ZacharyZhang-NY 443de79e2f fix(textarea): Up and Down land on the soft-wrapped row they target
A soft-wrapped row and the next row share a byte: the row's exclusive end
is the next row's start. Up or Down with a goal column past the target
row's width put the cursor on that byte, so it drew on the wrong row. Up
made no progress and Down skipped a row.

`row_with_end` tells a soft wrap from a hard break (byte-adjacent rows
are soft). `move_to_display_col_on_line` takes the row and its `RowEnd`
and clamps a soft row to its last grapheme, or to the start of an element
that closes the row. A hard-break row still reaches its newline.
`clamp_to_line` now cuts on graphemes, not chars, so a ZWJ cluster stays
whole.

Kigi differs from upstream's premise that elements wrap whole: with no
display override in the buffer, `wrapped_lines` uses plain textwrap and
an element can span rows. Snapping to such an element's start would
leave the target row (Down from byte 0 stuck at 0), so an element that
began on an earlier row keeps the boundary `display_col_to_buffer_pos`
chose, as before this change.

`wrapped_navigation_with_zwj_graphemes` asserted the bug: Up from the
end stayed on the last row (byte 22). It now expects row 1's cluster
start (byte 11), as upstream does.

Tests: upstream's four cases, the row-spanning element, and a seeded
property test (500 cases): each Up or Down moves exactly one row. The
property test fails with the clamp disabled.

Ported from upstream (grok-build ratatui-textarea/textarea.rs, 1.0.28).
2026-09-19 02:17:19 -04:00
ZacharyZhang-NY be0983237e fix(mcp): an abandoned tool call tells the server to stop
`try_call_tool` wrapped `call_tool` in a bare `tokio::time::timeout`.
On a timeout or a cancelled turn the future dropped and the server kept
working: no `notifications/cancelled` ever left the client.

`call_tool_cancel_aware` sends the request through
`send_cancellable_request` and holds a guard armed with the request id.
A settled call disarms it. A dropped future (cancelled turn) spawns the
cancel from the guard's drop. A timeout sends the cancel inline, before
returning: the HTTP timeout arm resets the transport next, and dropping
the last service handle killed a spawned cancel before it left.

The inline send waits 500 ms at most. rmcp 2.2.0 awaits its own
timeout-cancel without a bound, and its streamable-HTTP worker awaits
each POST in turn, so against a JSON-mode server the cancel queues
behind the hung call: with rmcp's timeout path the call returned after
3.0 s against a 1 s budget. That server class cannot receive the cancel
until rmcp 3.x; the budget elapses, a warn is logged, and AGENTS.md
records the limitation. stdio and SSE-mode servers receive it.

Tests: dropped call, timed-out call followed by a service drop, settled
call; the HTTP outer-timeout test now asserts the return time.

Ported from upstream (grok-build mcp/servers.rs call_tool_cancel_aware,
1.0.33); upstream leans on rmcp 3.2's timeout path.
2026-09-19 02:07:30 -04:00
ZacharyZhang-NY 3ccb6e5e91 fix(config): reasoning_effort accepts mixed case
`reasoning_effort = "High"` in config.toml failed the whole parse: the
derived Deserialize matched lowercase variants only, while FromStr
lowercased its input. Deserialize now routes through FromStr, so both
paths share one vocabulary. Unknown tokens and non-strings still error.

Ported from upstream (grok-build sampling-types/types.rs, 1.0.35).
2026-09-19 01:51:55 -04:00
雷电芽衣 0fb971e9aa chore(legal): regenerate THIRD-PARTY-NOTICES for the 0.1.15 dependency set
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 6s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish GitHub Release (push) Canceled after 0s
`cargo about generate about.hbs -o THIRD-PARTY-NOTICES.md` on the 0.1.15
lock: kigi crates at 0.1.15, chacha20 0.10.2.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 13:38:08 -04:00
雷电芽衣 a2ad08fb52 chore(release): bump workspace version to 0.1.15
Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 13:37:16 -04:00
雷电芽衣 b9ca80e801 docs(agents): record the 0.1.15 upstream sync window and its known limitations
Sync record now ends at upstream `3794978` (2026-09-09, ≈v1.0.24). Lists
the two limitations the sync left: the Windows frame writer still prints
through std's stderr lock, and the bwrap builder re-stats /data.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 13:36:58 -04:00
雷电芽衣 5f249ba6ca chore(deps): chacha20 0.10.2 replaces the yanked 0.10.1
`cargo deny check advisories` failed on the yanked crate (via rand 0.10 →
sigstore-tsa → nono). `cargo update -p chacha20`; no other lock change.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 13:36:58 -04:00
雷电芽衣 d331b0b2a3 fix(goal-classifier): git diff headers keep a/ b/ prefixes under any host gitconfig
`extract_changed_files` parses `diff --git a/<old> b/<new>` headers.
A host `diff.mnemonicprefix=true` or `diff.noprefix=true` rewrote them
(`c/ w/`, or none), so every tracked change vanished from `changed_files`
and the two evidence git-diff tests failed on such hosts. Both capture
paths now pass `--src-prefix=a/ --dst-prefix=b/`, which override both
config keys. Regression test sets each key in the temp repo.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 13:36:58 -04:00
雷电芽衣 09867915ef fix(sandbox): the devbox /data write-deny makes bubblewrap mandatory
A devbox-based profile on a host with /data warned and fell back to
Landlock when bubblewrap was missing or failed to exec, and Landlock
grants the workspace under /data. requires_data_write_deny (devbox
inheritance and an existing /data; a stat error other than NotFound
counts as present) now joins requires_read_deny in the fail-closed
startup decision, and the /data ro-bind is added only under that
predicate, so a missing /data no longer makes bwrap fail to exec.

Ported from upstream (grok-build sandbox/lib.rs requires_data_write_deny).

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 13:17:52 -04:00
雷电芽衣 edce333620 fix(subagent): project-agent mcpServers follow folder trust; agent names are one path component
A project-scoped agent definition from an untrusted repository could
declare inline stdio mcpServers and the child session started that
command, while inline hooks on the same definition were already gated on
folder trust. The mcpServers list now uses the same gate.

By-name agent lookup joined the requested name onto each searched
directory, so a name carrying a separator or ".." resolved a file outside
that directory under its trust scope; both lookup entry points now reject
any name that is not a single path component.

Ported from upstream (grok-build subagent/handle_request.rs mcpServers
gate), extended with the name guard.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 13:04:46 -04:00
雷电芽衣 d1c29a576c fix(compaction): failures carry the real error, with endpoint credentials redacted
The pager rendered "Compaction failed." with the error dropped, and the
shell's unsuppressed auto-compaction failure sent an empty error. Both
now carry the decoded first line of the failure (retry guidance plus the
detail), decoded from the ACP data rather than its JSON-escaped Display,
propagated unchanged through the compact extension, capped for a banner.

Endpoint credentials never reach the banner: the sampler redacts the
parsed endpoint (userinfo, sensitive query values, fragment via the
repository sanitizer) in every 401 message and the appended request URL,
and the shell redacts every URL in the error text before line selection,
including URLs whose password holds whitespace or that follow a
multi-byte delimiter.

Ported from upstream 1.0.13 (grok-build dispatch/prompt.rs,
session/compaction.rs), extended with the redaction.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 12:12:00 -04:00
雷电芽衣 17d4ba08f9 fix(exit): diagnostics after the terminal may be gone are best-effort writes
With fd 2 dead (a closed pane, an exited pipe reader) every write fails,
eprintln! panics on that failure, and under panic = "abort" the process
dies by SIGABRT with a crash report on the next launch instead of its
exit code. Exit reports, headless plain-format diagnostics, update-exit,
setup-result and leader-management messages now write through
best_effort_stderr::eprint_line: one attempt, failure reported, never a
panic.

Ported from upstream 1.0.19 (grok-build best_effort_stderr.rs).

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 10:39:20 -04:00
雷电芽衣 901203c2b2 fix(tui): pasted U+2028 and U+2029 become line breaks
str::lines() splits on neither separator, so a paste carrying them counted
as one line and the invisible characters broke the textarea's width model.
Bare \r, U+2028 and U+2029 now normalize to \n on every paste route (the
prompt, the inline paste key and the previous-message editor); dropped
file paths keep their bytes verbatim because a separator in a filename is
payload.

Ported from upstream 1.0.18 (grok-build prompt_widget normalize_line_breaks).

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 10:16:22 -04:00
雷电芽衣 d8609267a1 fix(images): resize over-cap attachments even when the re-encode saves no bytes
An attachment over the side or area caps whose re-encode did not shrink
the byte count was returned unchanged at its original dimensions, so the
provider still received an over-cap image. The byte-saving short-circuit
now applies only within the caps; a resize that would fall under the
vision minimums keeps the original on the existing fallback path.

Ported from upstream 1.0.13 (grok-build session/image_normalize.rs).

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 09:53:11 -04:00
雷电芽衣 a7b8438881 fix(tui): refresh the context bar when auto-compaction starts
AutoCompactStarted carries the count the trigger fired on, but only
AutoCompactCompleted refreshed the context bar, so the "N% full" banner
sat next to a stale pre-turn number until the next stream update. Both
lifecycle updates now refresh the bar, on the root view and on subagent
views.

Ported from upstream 1.0.12 (grok-build acp_handler/session_notification.rs).

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 09:35:27 -04:00
雷电芽衣 584d0fb77e fix(tui): /compact <instructions> reach the compaction request
The queued /compact command drained into Effect::Compact with only the
session id, so the instructions typed after the command never reached the
shell's compaction prompt (which already accepts userContext). The effect
carries the parsed instructions, trimmed so an edited queue row with
leading whitespace still counts.

Ported from upstream 1.0.22 (grok-build dispatch/queue.rs).

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 09:23:39 -04:00
雷电芽衣 4512df79e6 fix(tui): terminal escapes ride the writer queue and teardown is bounded
A terminal that stopped draining the pty parked the writer thread inside
its tty write while it held the stderr lock; the next inline stderr write
from the event loop (mouse toggle, notification, kitty clear) deadlocked
the loop, and /quit hung on the unbounded writer join and the teardown
writes.

EscapeWriter queues out-of-band escapes on the frame writer: the mouse
capture toggle, notifications (title, progress, protocol), the auth
cursor, gboom keyboard flags, the wrap clipboard request, and the kitty
clears (queued per agent view and merged into the frame's post-flush,
nested subagent views included). The $EDITOR/$PAGER handoff aborts when
the writer does not drain. WriterThread::join_within and the teardown
run under a 2 s grace; after a stall fd 2 is silenced, the exit hints,
relaunch and update prints are skipped, and the panic hook keeps only its
log report.

Ported from upstream 1.0.19 (grok-build EscapeWriter, WriterJoin,
run_bounded_teardown), extended to the remaining inline writes.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 09:08:09 -04:00
雷电芽衣 91f14112bf fix(subagent): bound the completion-path awaits so teardown reaches Shutdown
A completed child parked forever behind a parent that never serviced its
command channel, or behind its own frozen chat-state actor, leaking the
child session thread, fs watchers and fds. Every await in the completion
path that rides another actor is now bounded: child chat-state reads
degrade to their fallbacks after 10 s, the usage-fold and not-applied acks
give up after 30 s (an unacked mark falls back to the coordinator's sticky
mark), list_tasks and reparent_notifications are bounded, and the
post-Shutdown worktree snapshot is bounded while removal is awaited in
full so no detached deletion can race a resume.

Ported from upstream 1.0.14 (grok-build subagent/handle_request.rs
PARENT_ACK_TIMEOUT / child_actor_query), reshaped without the drain-budget
const assert.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 07:57:20 -04:00
雷电芽衣 fc7113ba3c fix(chat-state): reasoning estimate and calibration reseed on every rewrite
A Reasoning item counted its text and its encrypted blob as two separate
payloads, inflating the estimate for retained reasoning; the estimate now
takes the larger of the two (base64 runs 4/3 over the raw bytes).

Only compaction carried the provider-confirmed ratio into the reseeded
total; a rewind or a non-compaction replace sprang the context meter back
to the raw estimate. reseed_total_tokens applies the ratio (capped at the
pre-rewrite total) to every rewrite, and the truncate path uses it too.

Ported from upstream 1.0.1x (grok-build xai-chat-state actor/state.rs,
mutations.rs, queries.rs).

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 07:24:07 -04:00
雷电芽衣 00a5e318bb fix(config): an explicit [model.X] api_backend survives same-slug propagation
The slug-match step treated an entry whose api_backend equals the default
as unset and overwrote it with a same-slug sibling's backend, so
`api_backend = "chat_completions"` silently became messages/responses.
Config keys whose override sets api_backend are recorded and skipped by
the donor step; an unset backend still inherits.

Ported from upstream 1.0.22 (grok-build agent/config.rs
explicit_api_backend_keys).

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 07:13:47 -04:00
雷电芽衣 017a570d7d fix(sampler): a max_tokens stop after complete tool calls runs them
The Messages stream failed every max_tokens stop with MaxTokensTruncation,
so a response that had already emitted complete tool calls before the
output budget ran out lost those calls and failed the turn. The stream
now completes with StopReason::ToolCalls when every collected call's
arguments parse (or are empty, the zero-argument convention); a cut call
or a text-only truncation still fails so compact-on-error keeps working.
The ChatCompletions and Responses streams already behaved this way.

Ported from upstream 1.0.1x LengthPolicy::CompleteToolCalls, reshaped
into the stream layer because Kigi has no caller that chooses a policy.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 07:02:05 -04:00
雷电芽衣 63d26d4a3e fix(persistence): FlushAndAck is a durability barrier that reports lost writes
The persistence actor acked FlushAndAck unconditionally after writing its
pending merged notification: nothing was fsynced and every storage failure
was only warn-logged, so persist_ack, the reconnect flush and history
repair treated lost writes as persisted.

The barrier now writes the pending notification, reports the first storage
failure latched since the last barrier (every actor arm latches), and
fsyncs the whole session directory tree (files, directories, deletions).
Session directories are created durably (init and fork copy), and the
shared summary writer syncs its staged bytes before publishing and the
parent afterwards, so a concurrent /rename cannot publish unsynced bytes
over a synced summary.json. FlushComplete and the persist_ack, history
repair and end-of-turn callers propagate the io::Result; CopyFile fails on
a sync error but keeps the latch for the barrier.

Ported from upstream 1.0.1x (grok-build persistence.rs pending_write_error
+ durable storage helpers), reshaped without the dirty-file set.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 06:51:27 -04:00
雷电芽衣 5f7416f3ef fix(permissions): native path deny/ask rules apply to the file the tool opens
Edit/Read/Grep rules matched only the raw argument string, so a workspace
symlink (or a quoted, `~`, `./`, `..`, or absolute spelling) reached a
denied file. The policy now resolves the path exactly as the tools do
(sanitize, expand `~`, display-cwd remap, join to the requesting session's
cwd), follows symlinks parent-first, and re-checks deny/ask rules on the
opened path and its target as absolute, `./`-relative and relative
spellings. Escalation only: no allow is granted through a link. A link
that cannot be followed forces a prompt that survives YOLO, auto mode and
the session edit grant, with event reason `unresolved_link`.

Permission requests now carry the requesting tool's cwd and display cwd
so a child worktree session sharing its parent's handle is checked where
it runs.

Ported from upstream 1.0.14 (grok-build permission/policy.rs symlink
re-check), reshaped for Kigi's lexical rule matcher.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 05:48:06 -04:00
雷电芽衣 fb3fe89d33 fix(permissions): rg exec flags cannot ride an allowlist, and argv is decoded
Port of upstream grok-build 3794978 (snap6 "Reject ripgrep --hostname-bin
on always-safe and Auto routine paths"), widened where review showed the
same hole one layer down.

- `rg --pre` / `--hostname-bin` execute a program per file. The safe
  list refused only `--pre`, and the Auto-mode routine classifier had
  no rg flag check at all. One shared `rg_has_unsafe_flag` now refuses
  both flags (bare or `=VALUE`) on a lowercased-basename head, scanning
  every argument: rg's grammar (`--replace -- --pre`, `-ne --`) makes a
  partial arity model bypassable, so a literal search for the flag name
  costs a prompt instead of the auto-allow.
- The shell splitter handed allowlists undecoded source: a concatenation
  (`--host""name-bin`, `''--hostname-bin`, `--p're'`) kept its quotes,
  and double-quoted escapes (`"--host\<newline>name-bin"`) survived.
  `PlainCommand` words are now the decoded argv (bash double-quote
  rules, whole-node text minus the quotes so literal newlines and the
  whitespace tree-sitter folds into the closing quote survive), with a
  per-word `expandable` flag for unquoted glob/brace/tilde/backslash
  syntax the shell would still expand (`--{pre,pre}`, a glob that can
  match a file named `--pre`); the rg guard treats those as unsafe.
- Every prefix-based decision matches per argv element through one
  matcher (built-in safe list, always-safe list, dangerous list, Auto
  routine prefixes): a decoded word with inner whitespace (`rg    `,
  `rg /../payload`, one word spelled `git status`) can no longer pose
  as a prefix. Saved grants match token for word (a grant saved from a
  spaced argument prompts again). Saved denies keep the pre-0.1.15
  check verbatim on a `legacy_words` spelling (raw content and raw
  concatenations, wrappers peeled independently, space-boundary prefix)
  so no deny narrows on upgrade.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 04:47:21 -04:00
雷电芽衣 aa9a5dbe72 fix(auto-mode): git checkout/switch/stash no longer ride a blanket prefix
Port of upstream grok-build 7581004 (1.0.22 "Auto mode no longer
instantly runs destructive git checkout -- commands"), routine_git.rs.

`ROUTINE_PREFIXES` auto-allowed `git checkout`, `git switch` and
`git stash` wholesale, so Auto mode ran `git checkout -- <file>` (a
working-tree discard) and `git stash drop`/`clear` without review.
Those verbs now go through a fail-closed operand classifier: `switch`
is routine with one ref operand and benign flags; `stash` only for
push/save/pop/apply/list/show/branch; `checkout` additionally needs a
branch-mode flag (`-b`, `-B`, `--orphan`, `--detach`, `-t`) anywhere in
the argument list before an operand counts, because a bare
`git checkout <name>` — `-` included — restores a tracked path when no
such ref resolves (a deliberate deviation from upstream, which allowed
`git checkout main` outright). Path-like operands, pathspec flags and
`--` fail closed. What is not routine goes to the model side-query, not
to a hard block.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 02:36:14 -04:00
雷电芽衣 7a60cd42ac fix(trust): gate directory-typed project hook sources
Port of upstream grok-build 7581004 (1.0.22 window) folder_trust.rs.

`collect_repo_config_kinds` gated `.kigi/hooks` with `is_dir()` and
`.cursor/hooks.json` with `is_file()`, and `.claude/settings*.json` via
the settings-file scan, while the hook loader (`path_to_source`) takes
a directory at any of the four project hook paths as a hook source and
parses its JSON children. A clone carrying `.cursor/hooks.json/evil.json`
or `.claude/settings.json/evil.json` and nothing else resolved as
trusted through the no-config branch and ran its SessionStart hook.

The gate now treats any present path (file, directory, dangling
symlink) or an unreadable one at the four sources
`discover_hook_source_paths` loads from the git root as a hit.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 02:17:25 -04:00
雷电芽衣 97c934a647 fix(sandbox): strict profile no longer lets a child rewrite ~/.kigi
Port of upstream grok-build 1.0.14 ("--sandbox strict now restricts
writes to ~/.grok/sessions only"), shaped for Kigi.

The strict profile granted the whole ~/.kigi read-write, so a sandboxed
command could plant a global SessionStart hook or flip
`[sandbox].profile` in config.toml for the next launch. Landlock cannot
carve a child out of a write grant, so the home is now read-only
except an explicit allowlist of state subtrees (run, sessions,
worktrees, worktree_pool, tasks, memory, logs, debug, crash, memtrace,
trace-exports); config.toml, auth.json, sandbox.toml, hooks/, bin/ and
vendor/ stay read-only. A symlinked state dir under the home is
followed only to the default ~/.kigi/sessions.

What the process itself writes there after enforcement moves or is
minted first:
- kigi-shell `apply_sandbox` mints the device id, extracts the bundled
  rg and the bundled skills BEFORE enforcement for every profile that
  restricts home writes (`restricts_home_writes`, custom profiles
  extending strict included).
- The sandbox audit log lives at ~/.kigi/sessions/sandbox-events.jsonl.
- Leader lock/socket default to ~/.kigi/run/, the leader stderr log to
  ~/.kigi/logs/leader.log; discovery scans run/ and the legacy root,
  and the default target prefers the current socket.
Root-level caches (version.json, agent_id, slash-mru.json, pager.toml,
claude_import_state.json) degrade best-effort under strict; documented.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 02:08:42 -04:00
雷电芽衣 cadc46f5dc fix(sandbox): close io_uring and sendmmsg holes in the child network filter
Port of upstream grok-build 9684fa3 (1.0.11 window) `child_net.rs`.

The per-child seccomp filter matched seven socket syscalls by number and
allowed everything else, so a restricted child could still reach the
network through `sendmmsg` or an io_uring submission queue, and a
syscall issued through another arch table (x32 on x86_64) never matched
the deny list at all.

- Gate on the audit arch before matching `nr` and reject x32 numbers on
  x86_64 (foreign-table syscalls now get EPERM instead of passing).
- Add `sendmmsg`, `io_uring_setup`, `io_uring_enter`, `io_uring_register`.
- Build the program once (`prebuilt_child_network_filter`) and hand the
  slice to the `pre_exec` installer; the old code allocated the Vec after
  `fork`, which is not async-signal-safe. All three callers updated.
- Tests evaluate the BPF program over synthetic `seccomp_data`.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 01:19:36 -04:00
雷电芽衣 b957e12370 fix(tui): repaint the header context window on a /model switch
After `/model`, the header kept the previous model's window ("15K /
1.0M" on a 262K model) until the next turn or a restart. The pager
caches the window inside its context snapshot (streaming `totalTokens`
updates refresh it from the catalog) and nothing refreshed the cache
when the session's model changed. Comparing cached numbers against
catalog windows cannot tell a catalog-derived total from a shell
effective limit (KIGI_DEBUG_CONTEXT_WINDOW, header upgrades), so the
shell, which installs the effective window, now reports it:

- kigi-shell: `handle_set_session_model` returns the installed window;
  `model_switch::apply` stamps it as `totalContextTokens` in the
  `session/set_model` response meta and carries it on the
  `ModelChanged` broadcast (`context_window`, serde-default: old pagers
  ignore it, old leaders omit it). The response meta previously
  serialized the actor's inner `Result` and reported success on an
  actor failure; that error now propagates.
- kigi-tui: `Effect::SwitchModel` reads the meta; `SwitchModelComplete`
  carries `Ok(Option<u64>)`; the completion handler and the follower
  `ModelChanged` arm call `AgentView::apply_context_window`, which
  re-derives the snapshot (pct, free tokens) and reports whether it
  changed so a follower redraws on a window-only change.
- Tests: the bare `/model <name>` dispatch path asserts the snapshot and
  the drawn header; follower with/without the field; same-model new
  window redraw; actor returns the window; serde round trip.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 00:15:14 -04:00
雷电芽衣 ff92a00fea chore(fmt): reflow three link constants rustfmt rejects
f7a4cb6 repointed the GitHub links without running cargo fmt; the
fmt gate has been red since.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-12 23:17:44 -04:00
ZacharyZhang-NY f7a4cb64ab Return the forge to GitHub: releases, self-update, links, CI
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 6s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish GitHub Release (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
The GitHub account is restored, so github.com/ZacharyZhang-NY/Kigi-CLI is
the canonical repo again. Reverse the 2026-08 Gitea migration:

- .github/workflows/{release,warm-cache}.yml: restore the GitHub-hosted
  pipeline dropped in 3e1652c, unchanged — hosted runners, rust-cache,
  softprops release with every asset attached atomically.
- .gitea/workflows/release.yml: retire the Gitea edition (the runners are
  being deregistered; git.zacharyzhang.com keeps the v0.1.12/v0.1.13
  releases for old clients).
- kigi-env: update_base_url now defaults to the GitHub Releases API. The
  updater already speaks GitHub wire format and sends a User-Agent; v0.1.14
  is published to BOTH forges with identical assets so pre-0.1.14 installs
  polling Gitea self-update onto the GitHub channel.
- install.sh / install.ps1: resolve releases from api.github.com.
- feedback, /docs, SECURITY.md, frontend worker + site: repo, issues and
  raw-file links point at github.com / raw.githubusercontent.com.
- docs/RELEASE.md: GitHub edition of the checklist; Gitea runner sections
  retired.
- version: 0.1.13 -> 0.1.14 (the cross-forge transition release).

Claude-Session: https://claude.ai/code/session_01A6RcwHhhsFYYrNdQB4ASuP
2026-09-01 01:05:22 -04:00
ZacharyZhang-NYandClaude Fable 5 39cb1ca1bc chore(release): bump workspace version to 0.1.13
Release / prepare draft release (push) Successful in 6s
Release / build (aarch64-apple-darwin) (push) Failing after 1s
Release / build (x86_64-apple-darwin) (push) Failing after 1s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish release (push) Canceled after 0s
Release / build (x86_64-unknown-linux-gnu) (push) Successful in 16m32s
Release / build (aarch64-unknown-linux-gnu) (push) Successful in 16m13s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 12:43:58 -04:00
ZacharyZhang-NYandClaude Fable 5 e83610db6a chore(legal): regenerate THIRD-PARTY-NOTICES for the 0.1.13 dependency set
cargo-about over the updated lockfile (h2 0.4.19, webbrowser 1.2.4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 12:09:34 -04:00
ZacharyZhang-NYandClaude Fable 5 fd5cff2de6 chore(gates): finish the Windows sweep — zero clippy findings workspace-wide
Second layer of platform gaps the mac/linux-only gate history left:
unix-only test imports/helpers now cfg-gated (mermaid, fast-worktree,
hunk-tracker, pager-render link test, three pty_e2e module decls),
clippy-suggested cleanups in Windows-cfg code (file-utils classifier,
tui console-mode restore, shared clipboard arboard-error flow, tools
terminal closure, workspace capability tail), and `cargo update` of h2
(RUSTSEC-2026-0258) and webbrowser (RUSTSEC-2026-0257) so `cargo deny
check advisories` passes again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 12:04:32 -04:00
ZacharyZhang-NYandClaude Fable 5 427e8b6fea docs(agents): record the upstream sync baseline and the subagent spawn bound
The 0.1.13 sync had to reconstruct the fork point by content
fingerprinting; the sync record (baseline snapshot, last-evaluated
upstream commit, and where upstream hides its per-change lists) now
lives in AGENTS.md so the next sync starts from facts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:55:19 -04:00
ZacharyZhang-NYandClaude Fable 5 50669bf302 chore(gates): zero clippy warnings and a buildable sandbox example on Windows
The full-workspace gates had never run on this platform: collapsible
ifs in the Windows shell probe, a same-type cast in the crash handler,
an unused AsRawHandle import, and the sandbox smoke-test example
building its unix-only body (libc, kernel enforcement) unconditionally.
The example now stubs main off-unix; formatting reflow from cargo fmt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:55:19 -04:00
ZacharyZhang-NYandClaude Fable 5 25d1a4f980 fix(sampler): retry Cloudflare 52x and other 5xx during outages
Port of upstream grok-build 1.0.0/1.0.1-window retry policy.

- is_retryable_api_status: 429 plus any 5xx except Cloudflare 525/526
  (origin TLS handshake / invalid certificate — never clears on its
  own), replacing the 500|502|503|504|520 allowlist so the 521-524,
  529, and 530 edge pages an outage serves actually retry.
- The generic retry path clamps a server Retry-After to
  MAX_RETRY_BACKOFF (30s) and jitters it: Cloudflare 52x pages ask for
  60-120s, which across the budget stalled a turn ~28 minutes. The 429
  path keeps the full (parse-capped) Retry-After under its low attempt
  cap, and jitter is shared via jittered().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:51:36 -04:00
ZacharyZhang-NYandClaude Fable 5 a441b23a6d fix(mcp): kigi mcp add <name> <url> infers an HTTP server
Port of upstream grok-build 1.0.9. A bare positional http(s):// URL
with no transport flag was silently added as a stdio command whose
"command" was the URL. It now infers the HTTP transport with a notice;
extra args, --env, legacy --command/--url, and every explicit flag keep
their prior meaning, and scheme-less URL-looking commands still stay
stdio with the existing warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:42:47 -04:00
ZacharyZhang-NYandClaude Fable 5 574122884a fix(windows): resolve the home directory via the standard lookup
Port of upstream grok-build 1.0.4/1.0.5. Two paths read $HOME only,
which is usually unset on Windows outside Git Bash:

- the global-gitignore fallback (prompt/ignore.rs) now uses
  std::env::home_dir(), the crate-wide idiom;
- fast-worktree's resolve_kigi_home falls back to std::env::home_dir()
  with the same dunce canonicalization, eliminating the documented
  divergence from kigi_config::default_kigi_home. On Unix home_dir()
  reads $HOME first, so behavior is unchanged where HOME is set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:34:45 -04:00
ZacharyZhang-NYandClaude Fable 5 97de661554 fix(startup): thread-exhaustion resilience — cap workers, never panic
Port of upstream grok-build 0.2.113/0.2.114 fixes, extended to Kigi's
startup order.

- kigi-tty-utils runtime module: worker-cap policy (8 max) — tokio's
  per-core default pins 100+ thread slots on many-core shared hosts
  against RLIMIT_NPROC / pids.max, and later spawns die with EAGAIN.
- kigi-bin builds the main runtime with capped workers and reports an
  out-of-threads host with a diagnostic + exit(1) instead of a panic.
- The pre-runtime optimization threads (HTTP warmup, early model
  prefetch) spawn recoverably and degrade — their panic would have
  preempted the runtime's own diagnostic.
- The per-session actor thread and its runtime report spawn/build
  failure through the existing acp::Error / AgentBuildError boundaries
  (new RuntimeBuild variant) instead of panicking the agent host.
- fsnotify's watcher thread spawn maps into FsNotifyError::WatcherStart
  so callers degrade instead of crashing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:28:17 -04:00
ZacharyZhang-NYandClaude Fable 5 bf93091416 fix(agents): bound how many subagents one session runs at once
Port of upstream grok-build 1.0.1 (landed upstream 2026-08-06). The
coordinator fire-and-forgot every spawn, so a wide fan-out (graph
waves, swarm bursts, model-driven Task storms) could exhaust file
descriptors.

- kigi-tools task/admission: SubagentLimits — 32 concurrent per session
  by default (KIGI_MAX_CONCURRENT_SUBAGENTS), queue|fail behavior
  (KIGI_SUBAGENT_LIMIT_BEHAVIOR), zero clamped to 1, read once at the
  composition root; parse_positive_env helper.
- Each parent session gets one spawn semaphore (map entries live for
  the process so a resumed session shares its predecessor's limit);
  handle_subagent_request holds an owned permit for the whole child
  run — queue awaits FIFO with a unified-log notice at saturation,
  fail routes through send_pre_spawn_failure. After a queued wait, a
  closed parent command channel (teardown) fails the spawn instead of
  running against a dead parent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 11:09:24 -04:00
ZacharyZhang-NYandClaude Fable 5 26d26e15c9 fix(exec): bounded post-kill reaps — D-state children can't wedge a turn
Port of upstream grok-build 1.0.1 (landed upstream 2026-08-06).

A child wedged in an uninterruptible kernel syscall (D-state) never
observes SIGKILL, and a background descendant can hold the output pipes
open past the kill; both previously stalled tool futures without bound.

- kigi-tty-utils: KILL_REAP_TIMEOUT (5s) + reap_killed_bounded — wait
  for an already-killed child at most the bound, then abandon the corpse
  to tokio's orphan reaper.
- local_terminal: pipe readers share their buffer with the spawner and
  join under the reap bound on both exit arms (aborted readers are
  awaited so no tail bytes race the take); the whole tree is enrolled in
  a ProcessGroup and group-killed on timeout; the timeout arm returns
  the synthetic result instead of waiting on the corpse. Grandchild
  kill + prompt-return pinned by a ported unix regression test.
- kigi-tools: reap_killed_search_child wrapper; grep's four kill sites
  reap bounded; glob's stderr goes to null (an unread pipe blocks rg on
  warning backpressure) and its truncation kill reaps bounded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 10:45:17 -04:00
ZacharyZhang-NYandClaude Fable 5 1ae803519e fix(compaction): configurable summarizer tool_choice; wider overflow detection
Port of upstream grok-build 0.2.105 + 0.2.118.

- CompactionToolChoice (auto default | none), resolved env
  KIGI_COMPACTION_TOOL_CHOICE > [features].compaction_tool_choice >
  remote settings. Some OpenAI-compatible servers reject
  tool_choice:"none" with tools attached, failing every long-session
  compaction; auto keeps them working (the summarization prompt already
  forbids tool use). Threaded from agent + subagent spawn through
  CompactionConfig into generate_session_compact; the Responses gate
  counts hosted tools too, so a hosted-only request still carries the
  configured choice.
- is_context_length_error recognizes the "current message ... exceeds
  budget" phrasing, so that overflow is Fatal instead of burning the
  retry budget on a deterministic failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 10:22:18 -04:00
ZacharyZhang-NYandClaude Fable 5 6a4bff0d45 fix(agent): a racing cancel can no longer wedge the session's turn slot
Port of upstream grok-build 0.2.103. session/prompt runs an async intake
preamble before enqueueing SessionCommand::Prompt on the actor mailbox; a
concurrent cancel could enqueue Cancel first, land on an idle actor as a
no-op, and the prompt then started a turn nothing would ever cancel.

The per-session prompt-intake lock becomes the dispatch lock: prompt()
holds it across intake as before, and cancel() now holds it around its
Cancel send, so a cancel orders behind the prompt it targets. Regression
test ported verbatim (unix-only harness, like its siblings).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 09:53:09 -04:00
ZacharyZhang-NYandClaude Fable 5 6e0464fa74 fix(auth): unique tmp per auth.json write; tighten loose credential perms
Port of upstream grok-build 0.2.113-window fixes:
- write_auth_json_atomic tmp path gains a monotonic sequence next to the
  pid, so two concurrent in-process writers (background mint + proactive
  refresher) cannot share one tmp path and tear each other's bytes; a tmp
  write that fails partway is reclaimed instead of orphaned.
- New secure_file::ensure_owner_only_permissions (0o600 / Windows ACL)
  tightens pre-existing world-readable credential files on read, backup,
  write, replace, and restore. Best-effort with warns after durable
  content — a chmod-only failure must not masquerade as a failed write —
  and propagating in restore_prior_bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSy9S31pZNWXtRZNVJ1sDf
2026-08-27 09:31:21 -04:00
ZacharyZhang-NYandClaude Fable 5 257caed650 Add kigicli.dev frontend worker with built site assets
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012pFJn6ycA4hPRwM9sqasi6
2026-08-25 23:04:06 -04:00
ZacharyZhang-NYandClaude Opus 5 3e1652ca98 ci: drop the inert GitHub workflows
Gitea ignores `.github/workflows/` entirely once `.gitea/workflows/` is
non-empty, so these two files have had no effect since the release workflow
landed — and the GitHub repo they targeted is gone (404, suspended account),
so they cannot run there either.

They were not merely dead: warm-cache.yml's Monday cron kept firing on the
Gitea side before `.gitea/` existed, queueing jobs against GitHub-hosted
labels (`macos-14`, `windows-2022`, `ubuntu-24.04-arm`) that no self-hosted
runner advertises. Those sat queued forever, and its one dispatchable leg
failed on `rustup: command not found` — the same bare-image gap the Gitea
workflow now provisions around.

`.gitea/workflows/release.yml` is the only pipeline; docs/RELEASE.md already
documents it as such.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 22:11:28 -04:00
268 changed files with 16587 additions and 2373 deletions

No files matched your search

-375
View File
@@ -1,375 +0,0 @@
name: Release
# PRD F8, Gitea edition. On a v* tag push, build the single-file kigi binary
# for the five supported targets, package kigi-<version>-<target-triple>.
# {tar.gz|zip} (binary + LICENSE + NOTICE + THIRD-PARTY-NOTICES), and publish
# them as a Gitea Release alongside SHA256SUMS. install.sh, install.ps1 and
# auto_update::release_asset_name() all resolve these exact names — keep the
# naming in lockstep with them.
#
# RUNNERS. Gitea has no hosted runners; every label below must be a runner you
# registered yourself (docs/RELEASE.md has the register commands):
#
# ubuntu-latest Linux x86_64, container mode — the label the Dokploy box's
# runner already advertises. Builds both Linux targets;
# aarch64 is a cross-compile, so this leg installs the
# aarch64 GNU toolchain. Because the image is a bare
# runner-image, the Linux legs provision their own apt
# packages and rustup (see the provision steps below).
# Registering a dedicated `linux-x86_64:docker://...` runner
# instead is fine — swap the three labels back if you do.
# macos-arm64 Apple Silicon Mac, host mode. Builds both Darwin targets;
# x86_64 is a cross-compile against the same SDK. macOS
# CANNOT be built anywhere else — Apple's SDK licence
# restricts it to Apple hardware.
# windows-x86_64 Windows x86_64, host mode, with VS 2022 Build Tools
# (C++ workload). Needed because the tree links ring,
# mimalloc, zstd-sys and libz-sys against a static MSVC CRT
# (-C target-feature=+crt-static).
#
# SHELLS. Every step names its shell explicitly and the Windows leg never uses
# bash. On a normal Windows box `bash` resolves to WSL (not Git Bash), and
# `pwsh` (PowerShell 7) is usually absent — so Windows steps use `powershell`,
# which is the built-in 5.1. Keep those steps 5.1-compatible: no `&&`, no
# ternary, no null-coalescing.
#
# Note: because this file exists, Gitea ignores .github/workflows entirely.
#
# PUBLISH ORDER. The release is created as a draft and only made public once
# SHA256SUMS is attached, so `releases/latest` never hands the installer a
# release whose checksum manifest is still missing.
on:
push:
tags: ["v*"]
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
# Matches the version pinned by the dotslash launcher in bin/protoc. That
# launcher is not used here: it has no Windows entry and needs a `cargo
# install dotslash` first, so every platform fetches protoc directly.
PROTOC_VERSION: "29.3"
jobs:
prepare:
name: prepare draft release
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.draft.outputs.release_id }}
steps:
- uses: actions/checkout@v4
- name: Check tag matches workspace version
shell: bash
run: |
tag_version="${GITHUB_REF_NAME#v}"
cargo_version="$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1)"
if [ "$tag_version" != "$cargo_version" ]; then
echo "Tag $GITHUB_REF_NAME does not match workspace version $cargo_version" >&2
exit 1
fi
- name: Create or reuse draft release
id: draft
shell: bash
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -eu
# The runner-image is bare; jq and curl are both used below.
if ! command -v jq >/dev/null || ! command -v curl >/dev/null; then
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
export DEBIAN_FRONTEND=noninteractive
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq --no-install-recommends jq curl ca-certificates
fi
# Idempotent so a re-run of a failed tag build reuses the same draft
# instead of erroring on a duplicate tag_name.
existing="$(curl -sS -H "Authorization: token $TOKEN" \
"$API/releases/tags/$GITHUB_REF_NAME")"
release_id="$(printf '%s' "$existing" | jq -r '.id // empty')"
if [ -z "$release_id" ]; then
prerelease=false
case "$GITHUB_REF_NAME" in *-*) prerelease=true ;; esac
created="$(curl -fsS -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\",\"draft\":true,\"prerelease\":$prerelease}" \
"$API/releases")"
release_id="$(printf '%s' "$created" | jq -r '.id')"
echo "Created draft release $release_id (prerelease=$prerelease)"
else
echo "Reusing release $release_id for $GITHUB_REF_NAME"
fi
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
build:
name: build (${{ matrix.target }})
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-gnu
runner: ubuntu-latest
protoc: linux-x86_64
ext: tar.gz
linux: true
- target: aarch64-unknown-linux-gnu
runner: ubuntu-latest
protoc: linux-x86_64
ext: tar.gz
linux: true
cross_gnu: true
- target: aarch64-apple-darwin
runner: macos-arm64
protoc: osx-aarch_64
ext: tar.gz
- target: x86_64-apple-darwin
runner: macos-arm64
protoc: osx-aarch_64
ext: tar.gz
- target: x86_64-pc-windows-msvc
runner: windows-x86_64
protoc: win64
ext: zip
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
# ── linux container provisioning ──────────────────────────────────────
# The Linux legs run in a bare gitea runner-image, which ships git and
# node for the JavaScript actions but no compiler, no unzip (protoc) and
# no rustup. Host-mode macOS and Windows already have their toolchains
# installed, so this step is Linux-only.
- name: Provision Linux build deps
if: matrix.linux
shell: bash
run: |
set -eu
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
export DEBIAN_FRONTEND=noninteractive
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq --no-install-recommends \
build-essential pkg-config ca-certificates curl unzip tar jq
# ── protoc ────────────────────────────────────────────────────────────
- name: Install protoc (unix)
if: matrix.ext == 'tar.gz'
shell: bash
run: |
set -eu
url="https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-${{ matrix.protoc }}.zip"
curl -fsSL -o protoc.zip "$url"
rm -rf "$HOME/protoc"
unzip -q -o protoc.zip -d "$HOME/protoc"
chmod +x "$HOME/protoc/bin/protoc"
echo "$HOME/protoc/bin" >> "$GITHUB_PATH"
- name: Install protoc (windows)
if: matrix.ext == 'zip'
shell: powershell
run: |
$ErrorActionPreference = "Stop"
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v$env:PROTOC_VERSION/protoc-$env:PROTOC_VERSION-${{ matrix.protoc }}.zip"
Invoke-WebRequest -Uri $url -OutFile protoc.zip
$dest = Join-Path $env:USERPROFILE "protoc"
if (Test-Path $dest) { Remove-Item $dest -Recurse -Force }
Expand-Archive protoc.zip -DestinationPath $dest -Force
Add-Content -Path $env:GITHUB_PATH -Value (Join-Path $dest "bin")
# ── toolchain ─────────────────────────────────────────────────────────
# rust-toolchain.toml pins the channel (1.97.0) and preinstalls both
# Linux targets; `rustup show` materialises exactly that.
- name: Install toolchain (unix)
if: matrix.ext == 'tar.gz'
shell: bash
run: |
set -eu
# No-op on the macOS host runner, which has rustup installed already.
# `--default-toolchain none` leaves the channel choice to
# rust-toolchain.toml, which `rustup show` then materialises.
if ! command -v rustup >/dev/null; then
curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal --default-toolchain none
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.cargo/bin:$PATH"
fi
rustup show
rustup target add ${{ matrix.target }}
- name: Install toolchain (windows)
if: matrix.ext == 'zip'
shell: powershell
run: |
$ErrorActionPreference = "Stop"
rustup show
rustup target add ${{ matrix.target }}
# ring, mimalloc, zstd-sys and libz-sys are all cc-rs builds, so the
# cross target needs its own C toolchain — cc-rs picks these up from the
# target-suffixed vars.
- name: Install aarch64 cross toolchain
if: matrix.cross_gnu
shell: bash
run: |
set -eu
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu
{
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc"
echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc"
echo "CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++"
echo "AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar"
} >> "$GITHUB_ENV"
# ── build ─────────────────────────────────────────────────────────────
- name: Build kigi (unix)
if: matrix.ext == 'tar.gz'
shell: bash
run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
- name: Build kigi (windows)
if: matrix.ext == 'zip'
shell: powershell
run: |
$ErrorActionPreference = "Stop"
cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# ── package, upload, checksum ─────────────────────────────────────────
# Archives go straight to the release rather than through the artifact
# store: they are large, and the release is the only place install.sh
# can read them from anyway. Only the checksum line travels as an
# artifact, for the publish job to concatenate.
- name: Package and upload (unix)
if: matrix.ext == 'tar.gz'
shell: bash
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
run: |
set -eu
version="${GITHUB_REF_NAME#v}"
archive="kigi-${version}-${{ matrix.target }}.tar.gz"
staging="$(mktemp -d)"
cp "target/${{ matrix.target }}/release-dist/kigi" "$staging/kigi"
cp LICENSE "$staging/"
[ -f NOTICE ] && cp NOTICE "$staging/"
for f in THIRD-PARTY-NOTICES THIRD-PARTY-NOTICES.md; do
[ -f "$f" ] && cp "$f" "$staging/"
done
tar -C "$staging" -czf "$archive" .
curl -fsS -X POST \
-H "Authorization: token $TOKEN" \
-F "attachment=@${archive}" \
"$API/releases/$RELEASE_ID/assets?name=${archive}" > /dev/null
echo "Uploaded $archive"
if command -v sha256sum >/dev/null; then
sha256sum "$archive" > "checksum-${{ matrix.target }}.txt"
else
shasum -a 256 "$archive" > "checksum-${{ matrix.target }}.txt"
fi
cat "checksum-${{ matrix.target }}.txt"
- name: Package and upload (windows)
if: matrix.ext == 'zip'
shell: powershell
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
run: |
$ErrorActionPreference = "Stop"
$version = $env:GITHUB_REF_NAME.TrimStart("v")
$archive = "kigi-$version-${{ matrix.target }}.zip"
$staging = Join-Path $env:RUNNER_TEMP "staging"
if (Test-Path $staging) { Remove-Item $staging -Recurse -Force }
New-Item -ItemType Directory -Force -Path $staging | Out-Null
Copy-Item "target/${{ matrix.target }}/release-dist/kigi.exe" (Join-Path $staging "kigi.exe")
Copy-Item LICENSE $staging
if (Test-Path NOTICE) { Copy-Item NOTICE $staging }
foreach ($f in @("THIRD-PARTY-NOTICES", "THIRD-PARTY-NOTICES.md")) {
if (Test-Path $f) { Copy-Item $f $staging }
}
Compress-Archive -Path (Join-Path $staging "*") -DestinationPath $archive -Force
# System32\curl.exe, not PowerShell's `curl` alias for
# Invoke-WebRequest, which cannot do multipart uploads.
$api = "$env:API/releases/$env:RELEASE_ID/assets?name=$archive"
& "$env:SystemRoot\System32\curl.exe" -fsS -X POST `
-H "Authorization: token $env:TOKEN" `
-F "attachment=@$archive" $api
if ($LASTEXITCODE -ne 0) { throw "asset upload failed ($LASTEXITCODE)" }
Write-Host "Uploaded $archive"
# sha256sum's exact format, so the publish job can just concatenate.
# ASCII keeps the BOM out — a BOM would corrupt the first hash.
$hash = (Get-FileHash -Algorithm SHA256 -Path $archive).Hash.ToLowerInvariant()
"$hash $archive" | Out-File -FilePath "checksum-${{ matrix.target }}.txt" -Encoding ascii
Get-Content "checksum-${{ matrix.target }}.txt"
- uses: actions/upload-artifact@v3
with:
name: checksum-${{ matrix.target }}
path: checksum-${{ matrix.target }}.txt
if-no-files-found: error
publish:
name: publish release
needs: [prepare, build]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v3
with:
path: checksums
- name: Assemble SHA256SUMS
shell: bash
run: |
set -eu
# download-artifact v3 nests each artifact in its own directory. The
# Windows line arrives CRLF-terminated; strip CR so every line is LF
# for install.sh's `read -r hash name`.
find checksums -name 'checksum-*.txt' -exec cat {} + \
| tr -d '\r' | grep -v '^$' | sort -k2 > SHA256SUMS
lines="$(wc -l < SHA256SUMS)"
if [ "$lines" -ne 5 ]; then
echo "expected 5 checksum lines, got $lines" >&2
cat SHA256SUMS >&2
exit 1
fi
cat SHA256SUMS
- name: Attach SHA256SUMS and publish
shell: bash
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
run: |
set -eu
curl -fsS -X POST \
-H "Authorization: token $TOKEN" \
-F "attachment=@SHA256SUMS" \
"$API/releases/$RELEASE_ID/assets?name=SHA256SUMS" > /dev/null
# Flip the draft public only now that every asset install.sh looks
# for is attached.
curl -fsS -X PATCH \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d '{"draft":false}' \
"$API/releases/$RELEASE_ID" > /dev/null
echo "Published $GITHUB_REF_NAME"
+130 -11
View File
@@ -14,6 +14,52 @@ official `kimi` CLI: binary `kigi`, config dir `~/.kigi`
Never read or write `~/.kimi` (except the explicit one-time read-only
import) or any `KIMI_*` env var.
Upstream sync record (update on every sync): fork baseline is upstream
snapshot `8adf901` (2026-07-16, ≈v0.2.102); bug fixes evaluated and
selectively ported through upstream `77cd7eb` (2026-08-25, ≈v1.0.10) in
the 0.1.13 cycle, through `3794978` (2026-09-09, ≈v1.0.24) in the 0.1.15
cycle, through `a28ee2b` (2026-09-17, ≈v1.0.35) in the 0.1.16 cycle, and
through `2bdd1d6` (2026-09-29, ≈v1.0.45) in the 0.1.19 cycle.
Upstream is daily "Synced from monorepo" snapshots —
the commit BODIES carry per-change bullet lists, and
`crates/codegen/xai-grok-shell/changelogs/` maps releases to dates;
start any future sync from those two, not from raw diffs.
Known limitations left by the 0.1.15 sync (fix before relying on them):
the Windows frame writer writes through `std::io::stderr`, so a console
that stops reading still blocks exit-path prints behind std's stderr lock
(unix silences fd 2 after a timed-out writer join); the bwrap command
builder re-checks `Path::exists("/data")` and can omit the devbox bind on
a stat error that the profile classification treated as present.
Known limitation left by the 0.1.16 sync: `notifications/cancelled` for a
timed-out MCP call cannot reach a streamable-HTTP server that answers
`tools/call` with a plain JSON body. rmcp 2.2.0's HTTP worker awaits each
POST in turn, so the cancel queues behind the hung call;
`servers/cancel_aware.rs` waits 500 ms, warns, and the transport reset
then drops the connection. stdio and SSE-mode servers get the cancel.
The cure is rmcp 3.x (upstream runs 3.2), a major bump not yet taken.
Evaluated in the 0.1.19 window and left for a later cycle: headless `-p`
interrupt handling (upstream `SignalStreams` plus a headless signals module; Kigi's
`headless.rs` installs no handler, so SIGINT kills the run without session
cleanup), the subagent "Cancelling" overlay that outlives the child turn (needs
upstream's child-turn finalizer and its prompt-id bookkeeping), the honest
answer for a `spawn_subagent` call cancelled by send-now (a chat-state answers
channel), the effort picker preselecting the model's default effort and
accepting menu labels (tied to upstream's `ModelChoice` refactor), and every MCP
change that rides rmcp 3.x (`bearer_token_file`, argument coercion, elicitation
notes). The rmcp and tree-sitter major bumps stay open.
Evaluated in the 0.1.16 window and left for a later cycle: MCP qualified
tool names past 64 chars (first prove the name never reaches a provider
function list), the auto-update guard for a binary still in use (needs
per-OS process scans), the DA1 fence around the kitty keyboard pop (needs
a design against the 0.1.15 teardown), MCP servers on protocol 2026-07-28
(rmcp 3.x again), and `pager.toml`, whose writer in
`kigi-pager-render/src/appearance/config.rs` still ends in a bare
`fs::rename` and replaces a symlink.
## Hard constraints
- **Zero egress**: only `auth.kimi.com`, `api.kimi.com`, `api.moonshot.cn`,
@@ -40,6 +86,15 @@ import) or any `KIMI_*` env var.
switch that never stuck). Plain rename stays correct only for true moves
whose destination doesn't pre-exist (worktree-pool markers, corrupt-file
backups). Write failures must at least `warn!` — never `let _ =`.
- **config.toml writers resolve their destination through
`util::config::config_write_dest` before they name the tmp file.** A
symlinked `~/.kigi/config.toml` (dotfiles) keeps its link: the tmp sits
beside the referent and replaces it. Only that one path is followed, and
only while `user_kigi_home()` resolves (with no home, `kigi_home()` is the
project's own `./.kigi`). A project `.kigi/config.toml` is
repo-controlled, and following its symlink would overwrite whatever file
a hostile repo points it at; there the link itself is replaced.
Credential and managed stores never follow symlinks.
- The root `Cargo.toml` is hand-maintained (upstream's generator is not in
this repo). Members sorted; versions inherited from
`workspace.package.version` — the single source of truth for the release
@@ -73,6 +128,24 @@ import) or any `KIMI_*` env var.
- Graph node worktrees are removed right after a successful merge-back;
only FAILED nodes keep theirs for postmortem.
## Terminal keyboard
`Shift+Enter` inserts a newline in the prompt wherever the terminal can
report the modifier, and `kitty_skip_reason` decides where that is —
`shift_enter_unavailable` matches on it rather than keeping a second brand
list, because the two drifted once and the UI advertised a chord that never
arrived. Two skips still deliver the modifier: Apple Terminal, where
`route_enter` polls CoreGraphics, and Windows Terminal, where the console
key record carries SHIFT with no protocol at all (a bare ConHost is only
optimistically refined to Windows Terminal, so it keeps advertising
Alt+Enter). Everywhere else — VTE, xterm.js, JediTerm, screen, tmux below
3.3, tmux with `extended-keys off` — the byte on the wire is a plain CR and
no client-side trick recovers it: crossterm 0.28 parses kitty `CSI 13;2u`
but not xterm's `CSI 27;2;13~`, so `modifyOtherKeys` buys nothing. NO VTE
release carries the protocol (checked at tag 0.82.0:
`_vte_keymap_GDK_Return` encodes only Alt); a version cutoff for it is a
bug, not a feature. `/terminal-setup` owns the per-terminal recipe.
## Test seams
Cross-crate test hooks are behind the `test-support` cargo feature
@@ -195,6 +268,13 @@ client-side, no backend surface.
(`MAX_SWARM_RUNTIME`) are bounded: the swarm blocks the caller's turn,
so every wait needs a ceiling. Stragglers at the deadline are reported
as still-running WITH their ids.
- Underneath every fan-out (swarm, graph, raw Task) sits the
session-scoped spawn bound: `handle_subagent_request` holds one permit
from the parent session's semaphore for the whole child run
(kigi-tools `task/admission`: 32 default via
`KIGI_MAX_CONCURRENT_SUBAGENTS`, queue|fail via
`KIGI_SUBAGENT_LIMIT_BEHAVIOR`), so wide fan-outs queue instead of
exhausting file descriptors.
- `ToolKind::AgentSwarm` is its own variant because `TemplateRenderer`'s
`by_kind` map holds ONE tool name per kind — sharing `Task` would
silently redirect `${{ tools.by_kind.task }}` in other tools' prompts.
@@ -332,15 +412,20 @@ client-side, no backend surface.
reasoning replay requires `include:["reasoning.encrypted_content"]`.
API-key `openai` Responses requests carry NONE of this
(byte-identical, pinned by a control wire test). `reasoning.effort`
carries the thinking level (incl. the codex-only `ultra`). NO
websocket, NO base_instructions.
carries the thinking level. NO websocket, NO base_instructions.
CATALOG is HARDCODED (`PlatformId::hardcoded_catalog` →
`openai_codex_wire_models`, mapped through the SAME
`platform_wire_model_to_entry` output): exactly the 4 `visibility=list` &&
`supported_in_api=true` models (`gpt-5.6-sol/terra/luna`, `gpt-5.5`, ctx
272000, per-model efforts) — NO live `/models` fetch, NO codex-CLI /
`~/.codex` dependency; `gpt-5.3-codex-spark` (api=false) and
`gpt-5.4`/`gpt-5.4-mini`/`codex-auto-review` (hidden) are EXCLUDED.
`platform_wire_model_to_entry` output): the 8 `visibility=list` &&
`supported_in_api=true` models the backend served on 2026-09-29
(`gpt-6.1-sol`, `gpt-6-astra/sol/luna`, `gpt-5.6-sol/terra/luna`,
`gpt-5.5`, ctx 272000, per-model efforts) — NO live `/models` fetch, NO codex-CLI / `~/.codex`
dependency; the hidden `gpt-reserve` and `codex-auto-review` are
EXCLUDED. Refresh the table by reading
`GET {base}/models?client_version=<a current codex release>` with a
ChatGPT bearer: an old `client_version` is answered with a truncated
list, so a stale version reads as "the model does not exist".
`CODEX_USER_AGENT` (`codex_cli_rs/0.159.0`) moves with the release used
for that read.
- `OAuthFlow::GithubDeviceCopilot` → `auth::github_copilot` (TWO-STAGE).
Provider: `github-copilot` (`scope_key oauth/github-copilot`, base
`api.individual.githubcopilot.com`, ChatCompletions wire). Stage 1 is an
@@ -374,6 +459,39 @@ client-side, no backend surface.
token (`resolve_generic_oauth_tokens`, refreshed on expiry) and routes
`platform.oauth().is_some()` → `platform.base_url()` (kimi-code alone →
`proxy_url()`). Tokens/codes/verifiers are NEVER logged.
- CUSTOM PROVIDERS (0.1.19): `[platforms.<name>]` with `base_url` and `api =
"openai" | "anthropic"` declares a provider outside the closed registry.
`kigi-models/src/custom.rs` owns the validated type (name `[a-z0-9][a-z0-9_-]{0,31}`,
never a registry id; base URL `http(s)://host[/path]`, no userinfo, query or
fragment); `kigi-shell/src/agent/custom_providers/` owns the rest. Key order:
auth.json scope `<name>`, then the table's `api_key`; a key is REQUIRED (a
keyless declaration is inert). Wires: openai = `/chat/completions`, Bearer,
`GET {base}/models`; anthropic = `/messages`, `x-api-key`,
`GET {base}/models?limit=1000`. Catalog keys are `<name>/<model>`; the entry's
`description` carries the provider name for the picker. Entries reach the disk
cache without a key; `stamp_credentials` stamps `api_key` in memory, only onto
entries still on the provider's own host, and `drop_orphans` removes fetched
entries of a provider that is gone or keyless BEFORE the prefetched map replaces
the catalog (a stale unkeyed entry would fall through to the house key).
CREDENTIAL RULE: `EndpointsConfig.custom_provider_bases` (derived from
`[platforms.*]` in `Config::new_from_toml_cfg` and `from_config_value`, never
serialized) makes `CredentialAuthority::is_session_coding_endpoint` false for a
declared base, loopback included — without it an Ollama-style provider on
localhost classifies `Primary` and the Kimi bearer replaces its key. Every
authority is built from the models manager's config, so the field reaches all of
them. Turns and subagents read `auth_scheme` and BYOK status from the LIVE
catalog entry (`auth_facts_of`), not from a disk reload: fetched entries never
live on disk, and the reload sent fetched Messages models out with `Bearer`
instead of `x-api-key` (this also repaired `anthropic` and `minimax`).
Login: picker rows `custom-openai` / `custom-anthropic` are not loginable
(`AuthMethodKind::Unknown`); the TUI asks base URL, name (default from the
host), key, then `save_custom_provider` writes config.toml (through
`config_write_dest`) and auth.json, and `authenticate("custom:<name>")`
validates `GET {base}/models` (401/403 = rejected key, other non-2xx and network
errors surface). Limits: context window 256K unless the listing serves
`context_length` (Anthropic: `max_input_tokens`); no models.dev enrichment; a
failed key check leaves the saved definition (login again with the same name to
overwrite); no delete UI, edit config.toml and auth.json.
- A stored subscription-OAuth session IS a catalog fetch source. Every
fetch-PLAN decision that cannot afford real token resolution — the startup
prefetch arming gate, `on_auth_changed`'s wipe guard
@@ -482,10 +600,11 @@ client-side, no backend surface.
`parse_api_json` for bundled + runtime refresh; 24h cache
`~/.kigi/models_dev_cache.json`). Wire values always win; enrichment
never invents model availability. Canonical reasoning efforts:
none/minimal/low/medium/high/xhigh/max/ultra (`max` split from `xhigh`
2026-07; `ultra` is codex-only, above `max`, surfaced only via a model's
server-declared effort menu; Kimi wire spells its top tier `max`, kimi_compat
renames).
none/minimal/low/medium/high/xhigh/max (`max` split from `xhigh` 2026-07;
the Codex backend advertises an `ultra` tier above `max` that kigi has no
level for; Kimi wire spells its top tier `max`, kimi_compat renames).
config.toml accepts any letter case: `ReasoningEffort` deserializes
through `FromStr`.
## Milestones (PRD §8.3)
Generated
+96 -93
View File
@@ -228,7 +228,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -251,7 +251,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -720,9 +720,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.17.1"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -731,9 +731,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.42.0"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
@@ -1626,9 +1626,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.1"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -1810,7 +1810,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@@ -2654,7 +2654,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -2980,7 +2980,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3958,7 +3958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b305d85504de270ad3525d726a6b69cc59ee7b2269b014387651107ab9f0755b"
dependencies = [
"bstr",
"hashbrown 0.17.1",
"hashbrown 0.15.5",
]
[[package]]
@@ -4497,9 +4497,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.15"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
@@ -5442,7 +5442,7 @@ dependencies = [
[[package]]
name = "kigi-acp-lib"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5456,7 +5456,7 @@ dependencies = [
[[package]]
name = "kigi-agent"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -5486,7 +5486,7 @@ dependencies = [
[[package]]
name = "kigi-agent-lifecycle"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"async-trait",
"tokio",
@@ -5495,7 +5495,7 @@ dependencies = [
[[package]]
name = "kigi-auth"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"async-trait",
"http 1.4.2",
@@ -5508,7 +5508,7 @@ dependencies = [
[[package]]
name = "kigi-bin"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"anyhow",
"clap",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]]
name = "kigi-chat-state"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"indexmap",
"kigi-compaction",
@@ -5560,7 +5560,7 @@ dependencies = [
[[package]]
name = "kigi-codebase-graph"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"ahash",
"clap",
@@ -5596,7 +5596,7 @@ dependencies = [
[[package]]
name = "kigi-compaction"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"anyhow",
"async-trait",
@@ -5609,7 +5609,7 @@ dependencies = [
[[package]]
name = "kigi-config"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"base64",
"blake3",
@@ -5632,7 +5632,7 @@ dependencies = [
[[package]]
name = "kigi-config-types"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"indexmap",
@@ -5646,7 +5646,7 @@ dependencies = [
[[package]]
name = "kigi-crash-handler"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"backtrace",
"libc",
@@ -5657,7 +5657,7 @@ dependencies = [
[[package]]
name = "kigi-env"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"tracing",
"url",
@@ -5665,7 +5665,7 @@ dependencies = [
[[package]]
name = "kigi-fast-worktree"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"anyhow",
"bytes",
@@ -5697,7 +5697,7 @@ dependencies = [
[[package]]
name = "kigi-file-utils"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"anyhow",
"aws-config",
@@ -5721,7 +5721,7 @@ dependencies = [
[[package]]
name = "kigi-fsnotify"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"criterion",
"dunce",
@@ -5742,7 +5742,7 @@ dependencies = [
[[package]]
name = "kigi-gix-status"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"gix",
"kigi-test-utils",
@@ -5752,7 +5752,7 @@ dependencies = [
[[package]]
name = "kigi-hooks"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"fastrand",
"kigi-config",
@@ -5771,7 +5771,7 @@ dependencies = [
[[package]]
name = "kigi-hooks-plugins-types"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"serde",
"serde_json",
@@ -5779,7 +5779,7 @@ dependencies = [
[[package]]
name = "kigi-http"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"kigi-auth",
"kigi-log",
@@ -5794,7 +5794,7 @@ dependencies = [
[[package]]
name = "kigi-hunk-tracker"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"chrono",
"dunce",
@@ -5815,14 +5815,14 @@ dependencies = [
[[package]]
name = "kigi-interjection-core"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"serde",
]
[[package]]
name = "kigi-log"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"anyhow",
"chrono",
@@ -5840,7 +5840,7 @@ dependencies = [
[[package]]
name = "kigi-markdown"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"anstyle",
"anstyle-lossy",
@@ -5864,14 +5864,14 @@ dependencies = [
[[package]]
name = "kigi-markdown-core"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "kigi-mcp"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5908,7 +5908,7 @@ dependencies = [
[[package]]
name = "kigi-memory"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"anyhow",
"arc-swap",
@@ -5942,7 +5942,7 @@ dependencies = [
[[package]]
name = "kigi-mermaid"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"fontdb",
"image",
@@ -5960,7 +5960,7 @@ dependencies = [
[[package]]
name = "kigi-models"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"kigi-env",
"serde",
@@ -5970,7 +5970,7 @@ dependencies = [
[[package]]
name = "kigi-pager-minimal"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"chrono",
"crossterm",
@@ -5987,7 +5987,7 @@ dependencies = [
[[package]]
name = "kigi-pager-pty-harness"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -6012,7 +6012,7 @@ dependencies = [
[[package]]
name = "kigi-pager-render"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"anstyle",
@@ -6064,7 +6064,7 @@ dependencies = [
[[package]]
name = "kigi-paths"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"camino",
"serde",
@@ -6074,7 +6074,7 @@ dependencies = [
[[package]]
name = "kigi-prompt-queue"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"serde",
"serde_json",
@@ -6082,7 +6082,7 @@ dependencies = [
[[package]]
name = "kigi-proto-build"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"anyhow",
"pbjson-build",
@@ -6093,7 +6093,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-inline"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"ansi-width",
"anstyle-parse 0.2.7",
@@ -6110,7 +6110,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-textarea"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"arboard",
"chrono",
@@ -6131,7 +6131,7 @@ dependencies = [
[[package]]
name = "kigi-sampler"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"async-openai",
"async-stream",
@@ -6141,6 +6141,7 @@ dependencies = [
"futures-util",
"indexmap",
"kigi-sampling-types",
"kigi-secrets",
"kigi-test-support",
"kigi-version",
"reqwest 0.12.28",
@@ -6154,7 +6155,7 @@ dependencies = [
[[package]]
name = "kigi-sampling-types"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"assert_matches",
"async-openai",
@@ -6171,7 +6172,7 @@ dependencies = [
[[package]]
name = "kigi-sandbox"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"anyhow",
"chrono",
@@ -6186,13 +6187,14 @@ dependencies = [
"serde",
"serde_json",
"serial_test",
"tempfile",
"toml",
"tracing",
]
[[package]]
name = "kigi-secrets"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"regex",
"serde_json",
@@ -6230,7 +6232,7 @@ dependencies = [
[[package]]
name = "kigi-shell"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6293,6 +6295,7 @@ dependencies = [
"kigi-sampler",
"kigi-sampling-types",
"kigi-sandbox",
"kigi-secrets",
"kigi-shared",
"kigi-shell-base",
"kigi-sqlite-journal",
@@ -6367,7 +6370,7 @@ dependencies = [
[[package]]
name = "kigi-shell-base"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"anyhow",
"chrono",
@@ -6392,7 +6395,7 @@ dependencies = [
[[package]]
name = "kigi-sqlite-journal"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"libc",
"rusqlite",
@@ -6403,7 +6406,7 @@ dependencies = [
[[package]]
name = "kigi-subagent-resolution"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"kigi-sampling-types",
"kigi-tool-types",
@@ -6418,7 +6421,7 @@ dependencies = [
[[package]]
name = "kigi-system-power"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"windows-sys 0.59.0",
"zbus",
@@ -6426,7 +6429,7 @@ dependencies = [
[[package]]
name = "kigi-test-support"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6448,7 +6451,7 @@ dependencies = [
[[package]]
name = "kigi-test-utils"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"runfiles",
"tracing",
@@ -6457,11 +6460,11 @@ dependencies = [
[[package]]
name = "kigi-token-estimation"
version = "0.1.12"
version = "0.1.19"
[[package]]
name = "kigi-tool-protocol"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"kigi-tool-types",
"serde",
@@ -6472,7 +6475,7 @@ dependencies = [
[[package]]
name = "kigi-tool-runtime"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"anyhow",
"async-trait",
@@ -6490,7 +6493,7 @@ dependencies = [
[[package]]
name = "kigi-tool-types"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"minijinja",
"schemars 1.2.1",
@@ -6500,7 +6503,7 @@ dependencies = [
[[package]]
name = "kigi-tools"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"anyhow",
"arc-swap",
@@ -6577,7 +6580,7 @@ dependencies = [
[[package]]
name = "kigi-tools-api"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"kigi-proto-build",
"kigi-tool-protocol",
@@ -6590,11 +6593,11 @@ dependencies = [
[[package]]
name = "kigi-tracing-macros"
version = "0.1.12"
version = "0.1.19"
[[package]]
name = "kigi-tty-utils"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"libc",
"nix 0.30.1",
@@ -6604,7 +6607,7 @@ dependencies = [
[[package]]
name = "kigi-tui"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"ansi-to-tui",
@@ -6691,7 +6694,7 @@ dependencies = [
[[package]]
name = "kigi-update"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"anyhow",
"dunce",
@@ -6720,14 +6723,14 @@ dependencies = [
[[package]]
name = "kigi-version"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"semver",
]
[[package]]
name = "kigi-workspace"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6806,7 +6809,7 @@ dependencies = [
[[package]]
name = "kigi-workspace-types"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"base64",
"chrono",
@@ -7305,7 +7308,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7576,7 +7579,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -7997,7 +8000,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@@ -8840,7 +8843,7 @@ dependencies = [
[[package]]
name = "ptyctl"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -8858,7 +8861,7 @@ dependencies = [
[[package]]
name = "ptyctl-cli"
version = "0.1.12"
version = "0.1.19"
dependencies = [
"anyhow",
"axum",
@@ -8992,7 +8995,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -9617,7 +9620,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -9633,9 +9636,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.42"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"log",
@@ -9687,7 +9690,7 @@ dependencies = [
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -9698,9 +9701,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
@@ -10536,7 +10539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -10937,7 +10940,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -11882,7 +11885,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -12414,15 +12417,15 @@ dependencies = [
[[package]]
name = "webbrowser"
version = "1.2.1"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72"
checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94"
dependencies = [
"core-foundation 0.10.1",
"jni",
"log",
"ndk-context",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-foundation 0.3.2",
"url",
"web-sys",
@@ -12561,7 +12564,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
+1 -1
View File
@@ -76,7 +76,7 @@ members = [
]
[workspace.package]
version = "0.1.12"
version = "0.1.19"
edition = "2024"
license = "Apache-2.0"
+24 -2
View File
@@ -86,8 +86,8 @@ On by default. `KIGI_GRAPH=0` turns it off; `KIGI_GRAPH_CONCURRENCY` (default
## Providers and API keys
29 platforms ship compiled in: 5 you sign into, 24 you hand a key. Nothing is
registered at runtime — if it's not in this list, it's not there.
29 platforms ship compiled in: 5 you sign into, 24 you hand a key. Anything
else is an endpoint you declare yourself ([your own endpoint](#your-own-endpoint)).
**Sign in with a subscription you already pay for.** Run `kigi login` and pick.
Each provider's token is stored under its own key, and one provider's
@@ -149,6 +149,28 @@ api_key = "sk-..."
api_key = "xai-..."
```
### Your own endpoint
Anything that speaks the OpenAI Chat Completions API or the Anthropic Messages
API works: a gateway, a proxy, Ollama, LM Studio, vLLM. In `kigi`, open `/login`
(or the welcome login screen) and pick **Custom provider (OpenAI compatible)**
or **(Anthropic compatible)**, then enter a base URL, a name, and an API key.
Kigi saves the provider,
checks the key against `GET {base}/models`, and `/model` lists what the endpoint
serves as `name/model`.
```toml
# ~/.kigi/config.toml
[platforms.my-gateway]
base_url = "https://gateway.example.com/v1" # include /v1 when the API lives there
api = "openai" # or "anthropic"
api_key = "sk-..." # or store it in ~/.kigi/auth.json under "my-gateway"
```
A key is required. A local server that ignores auth takes any value. The
context window is 256K unless the endpoint reports one. To remove a provider,
delete its table and its `auth.json` entry.
Model lists sync on startup. Pick one with `/model`, set its thinking level
with `/effort`.
+1 -1
View File
@@ -1,7 +1,7 @@
# Security Policy
Kigi is a community build; report vulnerabilities on the
[Kigi issue tracker](https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/issues).
[Kigi issue tracker](https://github.com/ZacharyZhang-NY/Kigi-CLI/issues).
Issues there are public, so please leave out any detail that would make a
report directly exploitable — open with the affected area and impact, and a
maintainer will follow up for the rest.
+1518 -88
View File
File diff suppressed because it is too large. Load diff
@@ -252,11 +252,20 @@ pub fn by_name(name: &str) -> Option<AgentDefinition> {
by_name_with_home(name, dirs::home_dir().as_deref(), kigi.as_deref())
}
/// An agent name is one file stem: a separator or `..` would resolve a file outside the searched directory with that directory's trust scope.
fn is_single_path_component(name: &str) -> bool {
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\'])
}
fn by_name_with_home(
name: &str,
home: Option<&Path>,
kigi_home: Option<&Path>,
) -> Option<AgentDefinition> {
if !is_single_path_component(name) {
tracing::warn!(name, "rejected agent name: not a single path component");
return None;
}
// Check built-ins first — type-safe via BuiltinAgentName strum enum.
// Legacy pre-rebrand agent types (persisted in old session files) are
// mapped onto their current names first; see `canonical_agent_type`.
@@ -298,6 +307,10 @@ fn by_name_in_cwd_with_home(
home: Option<&Path>,
kigi_home: Option<&Path>,
) -> Option<AgentDefinition> {
if !is_single_path_component(name) {
tracing::warn!(name, "rejected agent name: not a single path component");
return None;
}
if let Some(def) = load_project_definition_by_name(name, cwd) {
return Some(def);
}
@@ -827,6 +840,30 @@ mod tests {
assert!(def.is_none());
}
/// `../outside` must not load a file beyond the agents directory under that directory's scope.
#[test]
fn by_name_rejects_names_that_leave_the_agents_directory() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
let agents_dir = project.join(".kigi").join("agents");
fs::create_dir_all(&agents_dir).unwrap();
write_agent_file(
&project,
"outside.md",
"outside",
"Lives outside the agents dir",
);
write_agent_file(&agents_dir, "inside.md", "inside", "Lives inside");
assert!(by_name_in_cwd_with_home("../../outside", &project, None, None).is_none());
assert!(by_name_in_cwd_with_home("..", &project, None, None).is_none());
assert!(by_name_in_cwd_with_home("a/b", &project, None, None).is_none());
assert!(by_name_with_home("../../outside", None, None).is_none());
assert_eq!(
by_name_in_cwd_with_home("inside", &project, None, None).map(|d| d.name),
Some("inside".to_string())
);
}
#[test]
fn test_discover_finds_md_files() {
let tmp = tempfile::tempdir().unwrap();
+5
View File
@@ -17,6 +17,11 @@ pub enum AgentBuildError {
#[error("IO error during agent construction: {0}")]
IoError(#[from] std::io::Error),
/// The per-session runtime could not be built — fd or thread
/// exhaustion (EMFILE/EAGAIN), not a definition problem.
#[error("failed to build the session runtime: {0}")]
RuntimeBuild(std::io::Error),
/// Carries template line numbers and surrounding context.
#[error("template rendering error: {0}")]
MiniJinjaError(#[from] minijinja::Error),
@@ -32,5 +32,5 @@ fn get_global_gitignore_path() -> Option<PathBuf> {
git2::Config::open_default()
.ok()
.and_then(|cfg| cfg.get_path("core.excludesFile").ok())
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".gitignore")))
.or_else(|| std::env::home_dir().map(|h| h.join(".gitignore")))
}
+80 -29
View File
@@ -146,27 +146,33 @@ async fn run_setup_command(json: bool) {
.expect("setup report has no non-serializable values");
println!("{out}");
if !report.configured {
eprintln!(
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one."
kigi_tui::best_effort_stderr::eprint_line(
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one.",
);
}
}
Err(e) => {
eprintln!("Couldn't fetch managed configuration. {e}");
kigi_tui::best_effort_stderr::eprint_line(&format!(
"Couldn't fetch managed configuration. {e}"
));
std::process::exit(1);
}
}
return;
}
match managed_config::run_setup().await {
SetupOutcome::Installed => eprintln!("Applied managed configuration."),
SetupOutcome::Installed => {
kigi_tui::best_effort_stderr::eprint_line("Applied managed configuration.")
}
SetupOutcome::NothingConfigured => {
eprintln!(
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one."
kigi_tui::best_effort_stderr::eprint_line(
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one.",
);
}
SetupOutcome::Failed(e) => {
eprintln!("Couldn't apply managed configuration. {e}");
kigi_tui::best_effort_stderr::eprint_line(&format!(
"Couldn't apply managed configuration. {e}"
));
std::process::exit(1);
}
}
@@ -208,8 +214,8 @@ async fn run_leader_mgmt(args: LeaderMgmtArgs) -> Result<()> {
println!("{info:#?}");
} else {
print_leader_descriptor(&descriptor);
eprintln!(
" (detailed info unavailable — leader does not advertise control capabilities)"
kigi_tui::best_effort_stderr::eprint_line(
" (detailed info unavailable — leader does not advertise control capabilities)",
);
}
client.cancel();
@@ -220,7 +226,7 @@ async fn run_leader_mgmt(args: LeaderMgmtArgs) -> Result<()> {
async fn kill_leaders() -> Result<()> {
let leaders = kigi_shell::leader::discover_leaders().await;
if leaders.is_empty() {
eprintln!("No leader candidates found.");
kigi_tui::best_effort_stderr::eprint_line("No leader candidates found.");
return Ok(());
}
let mut killed = 0u32;
@@ -231,7 +237,9 @@ async fn kill_leaders() -> Result<()> {
};
if !kigi_shell::util::is_kigi_process(pid) {
if let Some(ref lock) = d.lock_path {
eprintln!(" PID {pid} is not a kigi process, removing stale lock");
kigi_tui::best_effort_stderr::eprint_line(&format!(
" PID {pid} is not a kigi process, removing stale lock"
));
let _ = std::fs::remove_file(lock);
cleaned += 1;
}
@@ -240,19 +248,23 @@ async fn kill_leaders() -> Result<()> {
}
continue;
}
eprintln!(" Killing leader PID {pid}");
kigi_tui::best_effort_stderr::eprint_line(&format!(" Killing leader PID {pid}"));
if let Err(e) = kigi_shell::util::kill_process_by_pid(pid) {
eprintln!(" warning: failed to terminate PID {pid}: {e}");
kigi_tui::best_effort_stderr::eprint_line(&format!(
" warning: failed to terminate PID {pid}: {e}"
));
continue;
}
killed += 1;
}
if killed > 0 {
eprintln!("Killed {killed} leader process(es).");
kigi_tui::best_effort_stderr::eprint_line(&format!("Killed {killed} leader process(es)."));
} else if cleaned > 0 {
eprintln!("No live leader processes found (cleaned up {cleaned} stale lock(s)).");
kigi_tui::best_effort_stderr::eprint_line(&format!(
"No live leader processes found (cleaned up {cleaned} stale lock(s))."
));
} else {
eprintln!("No live leader processes found.");
kigi_tui::best_effort_stderr::eprint_line("No live leader processes found.");
}
Ok(())
}
@@ -295,7 +307,7 @@ fn print_leader_descriptor(d: &LeaderDescriptor) {
.map(|p| p.display().to_string())
.unwrap_or_else(|| "?".into());
let state = format!("{:?}", d.classification);
eprintln!(" PID {pid} ({state}) -- {sock}");
kigi_tui::best_effort_stderr::eprint_line(&format!(" PID {pid} ({state}) -- {sock}"));
}
fn leader_descriptor_json(d: &LeaderDescriptor) -> serde_json::Value {
serde_json::json!(
@@ -1221,15 +1233,34 @@ fn main() {
"Found crashed sessions from a previous run"
);
}
let runtime = tokio::runtime::Builder::new_multi_thread()
// Capped workers: tokio's per-core default pins 100+ thread slots on
// many-core shared hosts and later spawns die with EAGAIN.
let runtime = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(kigi_tty_utils::runtime::capped_worker_threads().get())
.enable_all()
.build()
.unwrap_or_else(|e| panic!("failed to start tokio runtime: {e}"));
{
Ok(runtime) => runtime,
Err(e) => {
// A host out of threads (RLIMIT_NPROC / pids.max) is an
// environment problem, not a bug — report it, don't panic.
kigi_tty_utils::restore_native_stderr();
kigi_tui::best_effort_stderr::eprint_line(&format!(
"Error: failed to start the async runtime: {e}"
));
kigi_tui::best_effort_stderr::eprint_line(
"The host may be out of threads (RLIMIT_NPROC or the cgroup pids ceiling); \
close other processes or raise the limit, then retry.",
);
std::process::exit(1);
}
};
let result = run_and_shutdown(runtime, async_main(), RUNTIME_SHUTDOWN_GRACE);
kigi_log::debug_log::flush();
if let Err(e) = result {
kigi_tty_utils::restore_native_stderr();
eprintln!("Error: {e:#}");
// fd 2 may be a closed pane: one attempt, never a panic under panic = "abort".
kigi_tui::best_effort_stderr::eprint_line(&format!("Error: {e:#}"));
std::process::exit(1);
}
}
@@ -1580,16 +1611,25 @@ async fn async_main() -> Result<()> {
let result = kigi_tui::app::run(args, bg_update_rx).await;
kigi_sandbox::flush();
match result {
Ok(true) => {
Ok(exit) if exit.quit_for_update => {
let adopted = bg_update_wait.lock().await.take();
if finish_update_on_exit(adopted, &update_config).await {
eprintln!("Update installed. Run `kigi` to start.");
} else {
eprintln!("Update did not complete. Run `kigi update` to retry.");
let installed =
finish_update_on_exit(adopted, &update_config, exit.terminal_reading).await;
// A terminal that stopped reading at teardown would block these prints.
if exit.terminal_reading {
if installed {
kigi_tui::best_effort_stderr::eprint_line(
"Update installed. Run `kigi` to start.",
);
} else {
kigi_tui::best_effort_stderr::eprint_line(
"Update did not complete. Run `kigi update` to retry.",
);
}
}
Ok(())
}
Ok(false) => Ok(()),
Ok(_) => Ok(()),
Err(e) => Err(e),
}
}
@@ -1605,10 +1645,15 @@ async fn async_main() -> Result<()> {
async fn finish_update_on_exit(
adopted: Option<tokio::task::JoinHandle<std::io::Result<std::process::ExitStatus>>>,
update_config: &UpdateConfig,
terminal_reading: bool,
) -> bool {
let run_blocking = |reason: Option<String>| async move {
// The blocking updater prints and its child inherits stderr: not on a stalled terminal.
if !terminal_reading {
return false;
}
if let Some(reason) = reason {
eprintln!("{reason}");
kigi_tui::best_effort_stderr::eprint_line(&reason);
}
auto_update::run_update_if_available(
auto_update::UpdateRunMode::Blocking,
@@ -1620,7 +1665,11 @@ async fn finish_update_on_exit(
};
match adopted {
Some(handle) => {
eprintln!("Waiting for the update download to finish...");
if terminal_reading {
kigi_tui::best_effort_stderr::eprint_line(
"Waiting for the update download to finish...",
);
}
match handle.await {
Ok(Ok(status)) if status.success() => true,
Ok(Ok(status)) => {
@@ -1793,7 +1842,9 @@ async fn signal_leaders_to_relaunch(installed_version: &str) {
to_version,
..
})) => {
eprintln!(" ↻ Relaunching shared session (leader {from_version} → {to_version})…");
kigi_tui::best_effort_stderr::eprint_line(&format!(
" ↻ Relaunching shared session (leader {from_version} → {to_version})…"
));
}
Ok(Ok(kigi_shell::leader::ControlPayload::RelaunchDeclined { reason })) => {
tracing::debug!(% reason, "Leader declined relaunch");
@@ -390,14 +390,24 @@ impl ChatStateActor {
});
}
/// Replace the entire conversation, persist, re-estimate `total_tokens`,
/// and emit reset + token-update events.
///
/// Compaction replaces carry the provider-side overhead forward as a
/// *ratio* (`base_estimate × provider_total ÷ estimate_at_last_response`,
/// capped at the pre-compaction total; `base_estimate` when that estimate is
/// 0) so the reseed neither springs back nor over-counts (see
/// `COMPACTION.md`).
/// Reseed after a rewrite: the raw estimate scaled by the provider-confirmed ratio, capped at the pre-rewrite total (see COMPACTION.md).
pub(super) fn reseed_total_tokens(&self, base_estimate: u64) -> u64 {
let pre_replace_total = self.state.total_tokens;
let mut estimated_tokens =
if pre_replace_total > 0 && self.state.estimate_at_last_response > 0 {
let ratio = pre_replace_total as f64 / self.state.estimate_at_last_response as f64;
(base_estimate as f64 * ratio).round() as u64
} else {
base_estimate
};
// Over-counting fires premature compaction; an under-count self-heals on the next response.
if pre_replace_total > 0 {
estimated_tokens = estimated_tokens.min(pre_replace_total);
}
estimated_tokens
}
/// Replace the conversation, persist, reseed `total_tokens` and emit reset + token events; `is_compaction` only marks the turn capture.
pub(super) fn replace_conversation(
&mut self,
items: Vec<ConversationItem>,
@@ -407,28 +417,16 @@ impl ChatStateActor {
if is_compaction && let Some(cap) = &mut self.state.turn_capture {
cap.compaction_occurred = true;
}
let pre_replace_total = self.state.total_tokens;
// `harness_trace_buffer` / `harness_trace_turns` intentionally untouched:
// the planner/verifier subagents ran, so their sealed trace turns survive
// a conversation replace (same intent as the `TruncateToPromptIndex` arm).
self.persistence.replace_history(&items);
let base_estimate = super::state::estimate_conversation_tokens(&items);
let mut estimated_tokens =
if is_compaction && pre_replace_total > 0 && self.state.estimate_at_last_response > 0 {
let ratio = pre_replace_total as f64 / self.state.estimate_at_last_response as f64;
(base_estimate as f64 * ratio).round() as u64
} else {
base_estimate
};
// Compaction must never appear to increase usage.
if is_compaction && pre_replace_total > 0 {
estimated_tokens = estimated_tokens.min(pre_replace_total);
}
let estimated_tokens = self.reseed_total_tokens(base_estimate);
self.state.conversation = items;
self.state.estimated_tokens_since_model = 0;
self.state.total_tokens = estimated_tokens;
self.state.estimate_at_last_response =
super::state::estimate_conversation_tokens(&self.state.conversation);
self.state.estimate_at_last_response = base_estimate;
self.rebase_turn_capture_offset();
self.send_event(ChatStateEvent::ConversationReset {
new_len: self.state.conversation.len(),
@@ -68,10 +68,10 @@ impl ChatStateActor {
self.state.conversation.truncate(truncate_at);
self.state.prompt_texts.truncate(target_prompt_index);
self.state.prompt_index = target_prompt_index;
self.state.total_tokens =
super::state::estimate_conversation_tokens(&self.state.conversation);
let base_estimate = super::state::estimate_conversation_tokens(&self.state.conversation);
self.state.total_tokens = self.reseed_total_tokens(base_estimate);
self.state.estimated_tokens_since_model = 0;
self.state.estimate_at_last_response = self.state.total_tokens;
self.state.estimate_at_last_response = base_estimate;
self.persistence.replace_history(&self.state.conversation);
@@ -68,12 +68,10 @@ pub fn estimate_item_tokens(item: &ConversationItem) -> u64 {
kigi_token_estimation::estimate_tokens(&b.text_summary())
}
ConversationItem::Reasoning(r) => {
// Summary + content text follow the standard bytes-per-token
// estimate; encrypted blobs are base64 and don't survive
// tokenization 1:1, so estimate at len/4 as well.
// Text and ciphertext are the same reasoning twice: the larger wins (base64 is 4/3 over raw).
let text_bytes = kigi_sampling_types::reasoning_item_text(r).len();
let enc_bytes = r.encrypted_content.as_deref().map(str::len).unwrap_or(0);
((text_bytes + enc_bytes) as u64) / kigi_token_estimation::BYTES_PER_TOKEN
(text_bytes.max(enc_bytes * 3 / 4) as u64) / kigi_token_estimation::BYTES_PER_TOKEN
}
}
}
@@ -658,22 +658,66 @@ async fn compaction_reseed_without_provider_count_matches_plain_estimate() {
}
#[tokio::test]
async fn non_compaction_replace_does_not_carry_overhead() {
async fn non_compaction_replace_carries_confirmed_total() {
// A rewind/mode-switch replace scales from the provider-confirmed count, not the raw estimate.
let h = TestHarness::new();
h.handle
.push_user_message(ConversationItem::user("x".repeat(4000)));
h.handle.record_token_usage(51_000);
h.handle.record_token_usage(500);
// Estimate at last response = 1_000, confirmed = 500 → ratio 0.5.
h.handle
.replace_conversation(vec![ConversationItem::user("q".repeat(4000))]);
let total = h.handle.get_total_tokens().await;
assert_eq!(
total, 1_000,
"non-compaction replace (e.g. rewind) keeps the plain estimate"
total, 500,
"same-size replace carries the provider-confirmed count"
);
}
#[tokio::test]
async fn replace_never_increases_total_tokens() {
// A growing replace is capped at the confirmed total; the under-count self-heals on the next usage.
let h = TestHarness::new();
h.handle
.push_user_message(ConversationItem::user("x".repeat(4000)));
h.handle.record_token_usage(1_500);
h.handle
.replace_conversation(vec![ConversationItem::user("q".repeat(40_000))]);
assert_eq!(h.handle.get_total_tokens().await, 1_500);
}
#[tokio::test]
async fn truncate_scales_from_confirmed_total() {
// Rewind path: TruncateToPromptIndex must use the same carry as replace_conversation.
let h = TestHarness::new();
h.handle
.push_user_message(ConversationItem::user("x".repeat(4000)));
h.handle.increment_prompt_index();
h.handle
.push_user_message(ConversationItem::user("y".repeat(4000)));
h.handle.increment_prompt_index();
h.handle.record_token_usage(1_000);
// Estimate at last response = 2_000, confirmed = 1_000 → ratio 0.5.
h.handle.truncate_to_prompt_index(1).await;
// Keeps the first user item (raw estimate 1_000) → scaled to 500.
assert_eq!(h.handle.get_total_tokens().await, 500);
}
#[test]
fn reasoning_estimate_takes_the_larger_of_text_and_ciphertext() {
// "thinking for r1" is 15 bytes; 800 base64 bytes are ~600 raw bytes.
let encrypted = reasoning_sibling("r1", Some(&"A".repeat(800)));
assert_eq!(crate::estimate_conversation_tokens(&[encrypted]), 150);
let plain = reasoning_sibling("r1", None);
assert_eq!(crate::estimate_conversation_tokens(&[plain]), 3);
}
#[tokio::test]
async fn flush_calls_persistence_flush() {
let mut h = TestHarness::new();
@@ -668,6 +668,11 @@ pub struct RemoteSettings {
/// remote settings verbatim-input flag; env (`KIGI_COMPACTION_VERBATIM_INPUT`) and config override it. `None` = default (true).
#[serde(default)]
pub compaction_verbatim_input: Option<bool>,
/// Compaction summarizer `tool_choice` (`auto` | `none`) from remote
/// settings. Env (`KIGI_COMPACTION_TOOL_CHOICE`) and config override it.
/// Some backends reject `tool_choice: "none"` with tools attached.
#[serde(default)]
pub compaction_tool_choice: Option<String>,
/// Master switch for jemalloc heap sampling + threshold dumps.
/// `Some(true)` enables, `Some(false)` kill-switch, `None` = client default off.
#[serde(default)]
+11 -13
View File
@@ -78,11 +78,10 @@ pub fn detect_windows_shell() -> &'static WindowsShell {
kigi_tty_utils::detach_std_command(&mut cmd);
cmd.arg("pwsh.exe").stdin(std::process::Stdio::null());
cmd.output()
} {
if output.status.success() {
tracing::info!("Windows shell: pwsh");
return WindowsShell::Pwsh;
}
} && output.status.success()
{
tracing::info!("Windows shell: pwsh");
return WindowsShell::Pwsh;
}
// powershell.exe (Windows PowerShell 5.1).
@@ -131,14 +130,13 @@ fn find_git_bash() -> Option<String> {
kigi_tty_utils::detach_std_command(&mut cmd);
cmd.arg("bash.exe").stdin(std::process::Stdio::null());
cmd.output()
} {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let line = line.trim();
if line.to_ascii_lowercase().contains("git") {
return Some(line.to_string());
}
} && output.status.success()
{
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let line = line.trim();
if line.to_ascii_lowercase().contains("git") {
return Some(line.to_string());
}
}
}
@@ -616,7 +616,7 @@ mod win {
}
let signal = exception_to_signal(exception_code);
let si_code = exception_code as i32;
let si_code = exception_code;
// ExceptionInformation[1] holds the faulting address for ACCESS_VIOLATION.
let si_addr = if exception_code == EXCEPTION_ACCESS_VIOLATION
+1 -1
View File
@@ -21,7 +21,7 @@ pub struct KigiEndpoints {
pub const PRODUCTION_ENDPOINTS: KigiEndpoints = KigiEndpoints {
coding_api_base_url: "https://api.kimi.com/coding/v1",
oauth_host: "https://auth.kimi.com",
update_base_url: "https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/Kigi-CLI/releases",
update_base_url: "https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases",
upgrade_page_url: "https://www.kimi.com/code/",
};
@@ -76,6 +76,7 @@ mod tests {
assert_eq!(std::fs::read(&dest).unwrap(), data);
}
#[cfg(unix)]
#[test]
fn test_clone_preserves_permissions() {
use std::os::unix::fs::PermissionsExt;
@@ -341,12 +341,14 @@ pub fn resolve_kigi_home() -> Result<PathBuf> {
if let Ok(v) = std::env::var("KIGI_SHARE_DIR") {
return Ok(PathBuf::from(v));
}
let home =
PathBuf::from(std::env::var("HOME").context("neither $KIGI_SHARE_DIR nor $HOME is set")?);
// Canonicalize the home dir so worktree paths share the same physical .kigi
// tree as trust/hooks even when it is symlinked. The dunce canonicalization
// must stay in sync with kigi_config::default_kigi_home();
// home resolution deliberately differs ($HOME here vs std::env::home_dir()).
// Standard home-dir lookup (`USERPROFILE`/Known Folders on Windows, where
// `HOME` is usually unset outside Git Bash) — the same resolution as
// kigi_config::default_kigi_home(), so worktree paths land in the same
// physical .kigi tree as trust/hooks.
let home = std::env::home_dir()
.context("neither $KIGI_SHARE_DIR nor a home directory could be resolved")?;
// Canonicalize so a symlinked home still maps to one physical tree; the
// dunce canonicalization must stay in sync with default_kigi_home().
Ok(dunce::canonicalize(&home).unwrap_or(home).join(".kigi"))
}
@@ -37,6 +37,7 @@ pub(crate) fn execute_plan(plan: WorktreePlan) -> Result<CreateWorktreeResult> {
#[cfg(test)]
mod tests {
#[cfg(unix)]
use super::*;
use crate::{IgnoredFilesMode, WorkingTreeMode, WorktreeBuilder};
use kigi_test_utils::git::{git_commit_all, init_git_repo};
@@ -89,10 +89,10 @@ fn is_platform_system_dir(cwd: &Path) -> bool {
#[cfg(target_os = "windows")]
fn is_platform_system_dir(cwd: &Path) -> bool {
if let Ok(temp) = std::env::var("TEMP").or_else(|_| std::env::var("TMP")) {
if cwd.starts_with(&temp) {
return true;
}
if let Ok(temp) = std::env::var("TEMP").or_else(|_| std::env::var("TMP"))
&& cwd.starts_with(&temp)
{
return true;
}
let path_lower = cwd.to_string_lossy().to_lowercase();
@@ -103,7 +103,7 @@ fn is_platform_system_dir(cwd: &Path) -> bool {
return true;
}
if cwd.parent().map_or(false, |p| p.parent().is_none()) && cwd.to_string_lossy().len() <= 3 {
if cwd.parent().is_some_and(|p| p.parent().is_none()) && cwd.to_string_lossy().len() <= 3 {
return true;
}
+8 -2
View File
@@ -971,7 +971,7 @@ pub(crate) fn start_with_timeout(
let progress_for_thread = progress.clone();
let thread = std::thread::spawn(move || {
let watcher_loop = move || {
let update_stage = |stage: &'static str| {
if let Ok(mut p) = progress_for_thread.lock() {
p.set_stage(stage);
@@ -1351,7 +1351,13 @@ pub(crate) fn start_with_timeout(
let _ = ready_tx.send(Err(Box::new(e)));
}
}
});
};
// Recoverable spawn: a host out of threads must surface as an error the
// caller can degrade on, not a `std::thread::spawn` panic.
let thread = std::thread::Builder::new()
.name("fsnotify-watcher".into())
.spawn(watcher_loop)
.map_err(|e| crate::FsNotifyError::WatcherStart(Box::new(e)))?;
// Wait for watcher to be ready (with timeout)
if let Ok(mut p) = progress.lock() {
@@ -318,6 +318,7 @@ mod tests {
assert!(matches!(state, FileContentState::TooLarge { .. }));
}
#[cfg(unix)]
#[tokio::test]
async fn test_read_file_bounded_symlink() {
let dir = tempfile::tempdir().unwrap();
+35 -29
View File
@@ -32,6 +32,9 @@ use rmcp::{
use crate::oauth_config::McpOAuthConfig;
mod cancel_aware;
use cancel_aware::call_tool_cancel_aware;
use kigi_tools::types::{
output::{MCPOutput, MCPOutputDetails, ToolOutput},
tool::{ToolKind, ToolNamespace},
@@ -1599,12 +1602,24 @@ impl McpErasedTool {
let mut params = CallToolRequestParams::new(self.tool.name.clone());
params.arguments = raw.as_object().cloned();
let result =
tokio::time::timeout(timeout_duration, mcp_service.call_tool(params.clone())).await;
match result {
Ok(Ok(call_result)) => Ok(call_result),
Ok(Err(service_err))
match call_tool_cancel_aware(&mcp_service, params.clone(), timeout_duration).await {
Ok(call_result) => Ok(call_result),
Err(ServiceError::Timeout { .. }) => {
*is_timeout = true;
// Reset for the next call but don't retry — a slow side-effecting tool must not run twice.
if client.is_http() && !*reconnect_attempted {
client.reset_transport().await;
*reconnect_attempted = true;
}
Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
format!(
"MCP tool '{}' timed out after {} seconds",
self.tool.name, tool_timeout
),
))
}
Err(service_err)
if should_recover_service_error(
&service_err,
client.is_http(),
@@ -1623,25 +1638,10 @@ impl McpErasedTool {
)
.await
}
Ok(Err(e)) => Err(kigi_tool_runtime::ToolError::custom(
Err(e) => Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
e.to_string(),
)),
Err(_) => {
*is_timeout = true;
// Reset for the next call but don't retry — a slow side-effecting tool must not run twice.
if client.is_http() && !*reconnect_attempted {
client.reset_transport().await;
*reconnect_attempted = true;
}
Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
format!(
"MCP tool '{}' timed out after {} seconds",
self.tool.name, tool_timeout
),
))
}
}
}
@@ -1692,13 +1692,9 @@ impl McpErasedTool {
));
}
};
match tokio::time::timeout(timeout_duration, mcp_service.call_tool(params)).await {
Ok(Ok(call_result)) => Ok(call_result),
Ok(Err(retry_err)) => Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
retry_err.to_string(),
)),
Err(_) => {
match call_tool_cancel_aware(&mcp_service, params, timeout_duration).await {
Ok(call_result) => Ok(call_result),
Err(ServiceError::Timeout { .. }) => {
*is_timeout = true;
Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
@@ -1708,6 +1704,10 @@ impl McpErasedTool {
),
))
}
Err(retry_err) => Err(kigi_tool_runtime::ToolError::custom(
"process_manager",
retry_err.to_string(),
)),
}
}
}
@@ -6223,11 +6223,17 @@ mod tests {
let mut reconnect = false;
let mut is_timeout = false;
let raw = serde_json::json!({});
let started = std::time::Instant::now();
let err = tool
.try_call_tool(&client, &raw, &mut reconnect, &mut is_timeout, &ew)
.await
.expect_err("call must time out");
assert!(
started.elapsed() < std::time::Duration::from_millis(2500),
"cancel must not wait out the 3s hang: {:?}",
started.elapsed()
);
assert!(err.to_string().contains("timed out"), "got: {err}");
assert!(is_timeout, "is_timeout must be set");
assert!(reconnect, "timeout arm flags the reconnect after resetting");
@@ -0,0 +1,224 @@
//! `tools/call` that tells the server when the host stops waiting.
use rmcp::model::{
CallToolRequest, CallToolRequestParams, CallToolResult, CancelledNotificationParam,
ClientRequest, RequestId, ServerResult,
};
use rmcp::service::{Peer, PeerRequestOptions};
use rmcp::{RoleClient, ServiceError};
use super::McpService;
/// rmcp 2.2.0's HTTP worker queues the cancel behind a hung POST, so the wait is bounded.
const CANCEL_DELIVERY_BUDGET: std::time::Duration = std::time::Duration::from_millis(500);
/// A timeout cancels inline, before the caller can reset the transport; a drop cancels from a task.
pub(super) async fn call_tool_cancel_aware(
service: &McpService,
params: CallToolRequestParams,
timeout: std::time::Duration,
) -> Result<CallToolResult, ServiceError> {
let handle = service
.peer()
.send_cancellable_request(
ClientRequest::CallToolRequest(CallToolRequest::new(params)),
PeerRequestOptions::no_options(),
)
.await?;
let mut guard = CancelOnDrop {
peer: service.peer().clone(),
request_id: Some(handle.id.clone()),
};
let Ok(response) = tokio::time::timeout(timeout, handle.await_response()).await else {
guard.cancel_within_budget().await;
return Err(ServiceError::Timeout { timeout });
};
guard.request_id = None;
match response? {
ServerResult::CallToolResult(result) => Ok(result),
_ => Err(ServiceError::UnexpectedResponse),
}
}
/// Armed while the request is in flight; disarmed once it settles.
struct CancelOnDrop {
peer: Peer<RoleClient>,
request_id: Option<RequestId>,
}
impl CancelOnDrop {
async fn cancel_within_budget(&mut self) {
let Some(request_id) = self.request_id.clone() else {
return;
};
let delivery = notify_cancelled(self.peer.clone(), request_id.clone());
if tokio::time::timeout(CANCEL_DELIVERY_BUDGET, delivery)
.await
.is_err()
{
tracing::warn!(
?request_id,
"notifications/cancelled still queued after budget"
);
}
self.request_id = None;
}
}
impl Drop for CancelOnDrop {
fn drop(&mut self) {
let Some(request_id) = self.request_id.take() else {
return;
};
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
tracing::warn!(
?request_id,
"MCP call dropped outside a runtime; cancel not sent"
);
return;
};
runtime.spawn(notify_cancelled(self.peer.clone(), request_id));
}
}
async fn notify_cancelled(peer: Peer<RoleClient>, request_id: RequestId) {
let params = CancelledNotificationParam::new(
Some(request_id.clone()),
Some("client cancelled".to_owned()),
);
if let Err(e) = peer.notify_cancelled(params).await {
tracing::warn!(?request_id, error = %e, "notifications/cancelled not delivered");
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use rmcp::ServiceExt;
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use super::super::{KigiClientHandler, McpClient};
use super::*;
type Received = Arc<parking_lot::Mutex<Vec<Value>>>;
/// Duplex MCP server: answers `initialize`, records all, answers `tools/call` only when `reply`.
async fn recording_service(reply: bool) -> (McpService, Received) {
let received = Received::default();
let (client_read, mut server_write) = tokio::io::duplex(64 * 1024);
let (server_read, client_write) = tokio::io::duplex(64 * 1024);
let sink = received.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(server_read).lines();
while let Ok(Some(line)) = lines.next_line().await {
let msg: Value = serde_json::from_str(&line).expect("client sends JSON");
sink.lock().push(msg.clone());
let result = match msg["method"].as_str() {
Some("initialize") => serde_json::json!({
"protocolVersion": msg["params"]["protocolVersion"],
"capabilities": { "tools": {} },
"serverInfo": { "name": "recording", "version": "0.0.0" },
}),
Some("tools/call") if reply => serde_json::json!({
"content": [{ "type": "text", "text": "done" }],
"isError": false,
}),
_ => continue,
};
let response =
serde_json::json!({ "jsonrpc": "2.0", "id": msg["id"], "result": result });
let encoded = format!("{response}\n");
server_write
.write_all(encoded.as_bytes())
.await
.expect("client reads replies");
}
});
let handler = KigiClientHandler {
info: McpClient::make_client_info("recording"),
server_name: "recording".to_string(),
notify_tx: Arc::default(),
};
let transport = rmcp::transport::async_rw::AsyncRwTransport::<RoleClient, _, _>::new(
client_read,
client_write,
);
let service = Arc::new(handler.serve(transport).await.expect("handshake"));
(service, received)
}
fn values_at(received: &Received, method: &str, pointer: &str) -> Vec<Value> {
received
.lock()
.iter()
.filter(|m| m["method"] == method)
.map(|m| m.pointer(pointer).cloned().unwrap_or(Value::Null))
.collect()
}
fn call_ids(received: &Received) -> Vec<Value> {
values_at(received, "tools/call", "/id")
}
fn cancelled_ids(received: &Received) -> Vec<Value> {
values_at(received, "notifications/cancelled", "/params/requestId")
}
#[tokio::test]
async fn dropped_call_cancels_its_request() {
let (service, received) = recording_service(false).await;
let mut call = Box::pin(call_tool_cancel_aware(
&service,
CallToolRequestParams::new("slow"),
Duration::from_secs(30),
));
let pending = tokio::time::timeout(Duration::from_millis(100), &mut call).await;
assert!(pending.is_err(), "server never replies");
drop(call);
tokio::time::sleep(Duration::from_millis(100)).await;
let ids = call_ids(&received);
assert_eq!(ids.len(), 1, "{ids:?}");
assert_eq!(cancelled_ids(&received), ids);
}
#[tokio::test]
async fn timed_out_call_cancels_its_request_once() {
let (service, received) = recording_service(false).await;
let round = call_tool_cancel_aware(
&service,
CallToolRequestParams::new("slow"),
Duration::from_millis(50),
)
.await;
assert!(
matches!(round, Err(ServiceError::Timeout { .. })),
"{round:?}"
);
// The HTTP timeout arm resets the transport, which drops the last service handle.
drop(service);
tokio::time::sleep(Duration::from_millis(100)).await;
let ids = call_ids(&received);
assert_eq!(ids.len(), 1, "{ids:?}");
assert_eq!(cancelled_ids(&received), ids);
}
#[tokio::test]
async fn completed_call_sends_no_cancellation() {
let (service, received) = recording_service(true).await;
let result = call_tool_cancel_aware(
&service,
CallToolRequestParams::new("fast"),
Duration::from_secs(5),
)
.await
.expect("server replies");
assert_eq!(result.is_error, Some(false));
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(cancelled_ids(&received), Vec::<Value>::new());
}
}
+1
View File
@@ -150,6 +150,7 @@ fn write_private(path: &Path, contents: &str) -> std::io::Result<()> {
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use std::time::Instant;
/// Write an executable `#!/bin/sh` fake `mmdc` and return (dir-guard, path).
@@ -179,9 +179,12 @@ fn reap_process_group(_child: &Child) {
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use std::process::Stdio;
#[cfg(unix)]
use std::time::Instant;
#[cfg(unix)]
fn detached(mut cmd: Command) -> Command {
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
@@ -1 +1 @@
{"_meta":{"note":"filtered snapshot for kigi model-metadata enrichment; regenerate via scripts/gen_enrichment_snapshot.py","source":"https://models.dev/api.json (github.com/sst/models.dev, MIT)"},"alibaba-token-plan":{"models":{"MiniMax-M2.5":{"limit":{"context":196608,"input":196601,"output":24576},"modalities":{"input":["text"],"output":["text"]},"name":"MiniMax-M2.5","reasoning":true,"reasoning_options":[],"tool_call":true},"deepseek-v3.2":{"limit":{"context":131072,"output":65536},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V3.2","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"deepseek-v4-flash":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4-pro":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Pro","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"glm-5":{"limit":{"context":202752,"output":16384},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"glm-5.1":{"limit":{"context":202752,"output":128000},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.1","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"glm-5.2":{"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.2","reasoning":true,"reasoning_options":[{"type":"effort","values":["high","max"]}],"tool_call":true},"kimi-k2.5":{"limit":{"context":262144,"output":32768},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.5","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"kimi-k2.6":{"limit":{"context":262144,"output":16384},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.6","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"kimi-k2.7-code":{"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.7 Code","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"qwen-image-2.0":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Qwen Image 2.0","reasoning":false,"tool_call":false},"qwen-image-2.0-pro":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Qwen Image 2.0 Pro","reasoning":false,"tool_call":false},"qwen3.6-flash":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.6 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":81920,"type":"budget_tokens"}],"tool_call":true},"qwen3.6-plus":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.6 Plus","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":81920,"type":"budget_tokens"}],"tool_call":true},"qwen3.7-max":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text"],"output":["text"]},"name":"Qwen3.7 Max","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":262144,"type":"budget_tokens"}],"tool_call":true},"qwen3.7-plus":{"limit":{"context":1000000,"output":64000},"modalities":{"input":["text","image"],"output":["text"]},"name":"Qwen3.7 Plus","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":262144,"type":"budget_tokens"}],"tool_call":true},"qwen3.8-max-preview":{"limit":{"context":1000000,"output":131072},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.8 Max Preview","reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","xhigh"]},{"max":262144,"min":0,"type":"budget_tokens"}],"tool_call":true},"wan2.7-image":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Wan2.7 Image","reasoning":false,"tool_call":false},"wan2.7-image-pro":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Wan2.7 Image Pro","reasoning":false,"tool_call":false}}},"alibaba-token-plan-cn":{"models":{"MiniMax-M2.5":{"limit":{"context":196608,"input":196601,"output":24576},"modalities":{"input":["text"],"output":["text"]},"name":"MiniMax-M2.5","reasoning":true,"reasoning_options":[],"tool_call":true},"deepseek-v3.2":{"limit":{"context":131072,"output":65536},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V3.2","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"deepseek-v4-flash":{"limit":{"context":1000000,"output":3Line truncated
{"_meta":{"note":"filtered snapshot for kigi model-metadata enrichment; regenerate via scripts/gen_enrichment_snapshot.py","source":"https://models.dev/api.json (github.com/sst/models.dev, MIT)"},"alibaba-token-plan":{"models":{"MiniMax-M2.5":{"limit":{"context":196608,"input":196601,"output":32768},"modalities":{"input":["text"],"output":["text"]},"name":"MiniMax-M2.5","reasoning":true,"reasoning_options":[],"tool_call":true},"deepseek-v3.2":{"limit":{"context":131072,"output":65536},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V3.2","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"deepseek-v4-flash":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4-flash-0731":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Flash 0731","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4-pro":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Pro","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4-pro-0813":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text"],"output":["text"]},"name":"DeepSeek V4 Pro 0813","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],"tool_call":true},"deepseek-v4.1-flash":{"limit":{"context":1000000,"output":384000},"modalities":{"input":["text","image"],"output":["text"]},"name":"DeepSeek V4.1 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"effort","values":["low","high","max"]}],"tool_call":true},"glm-5":{"limit":{"context":202752,"output":16384},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"glm-5.1":{"limit":{"context":202752,"output":128000},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.1","reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true},"glm-5.2":{"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.2","reasoning":true,"reasoning_options":[{"type":"effort","values":["high","max"]}],"tool_call":true},"glm-5.3":{"limit":{"context":1000000,"output":131072},"modalities":{"input":["text"],"output":["text"]},"name":"GLM-5.3","reasoning":true,"reasoning_options":[{"type":"effort","values":["low","high","max"]}],"tool_call":true},"happyhorse-1.1-i2v":{"limit":{"context":0,"output":0},"modalities":{"input":["image","text"],"output":["video"]},"name":"HappyHorse 1.1 Image-to-Video","reasoning":false,"tool_call":false},"happyhorse-1.1-r2v":{"limit":{"context":0,"output":0},"modalities":{"input":["image","text"],"output":["video"]},"name":"HappyHorse 1.1 Reference-to-Video","reasoning":false,"tool_call":false},"happyhorse-1.1-t2v":{"limit":{"context":0,"output":0},"modalities":{"input":["text"],"output":["video"]},"name":"HappyHorse 1.1 Text-to-Video","reasoning":false,"tool_call":false},"kimi-k2.5":{"limit":{"context":262144,"output":98304},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.5","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"kimi-k2.6":{"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.6","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"kimi-k2.7-code":{"limit":{"context":262144,"output":262144},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Kimi K2.7 Code","reasoning":true,"reasoning_options":[{"type":"toggle"},{"type":"budget_tokens"}],"tool_call":true},"qwen-image-2.0":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Qwen Image 2.0","reasoning":false,"tool_call":false},"qwen-image-2.0-pro":{"limit":{"context":8192,"output":0},"modalities":{"input":["text"],"output":["image"]},"name":"Qwen Image 2.0 Pro","reasoning":false,"tool_call":false},"qwen3.6-flash":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.6 Flash","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":131072,"type":"budget_tokens"}],"tool_call":true},"qwen3.6-plus":{"limit":{"context":1000000,"output":65536},"modalities":{"input":["text","image","video"],"output":["text"]},"name":"Qwen3.6 Plus","reasoning":true,"reasoning_options":[{"type":"toggle"},{"max":131072,"type":"budget_tokens"}],"tool_call":true},"qwen3.7-max":{"limit":{"context":1000000,Line truncated
+324
View File
@@ -0,0 +1,324 @@
//! User-declared providers: a name, a base URL, and a wire.
use crate::{ListingDialect, PlatformChatCompat, PlatformKeyHeader, PlatformWireApi};
const MAX_NAME_LEN: usize = 32;
/// Wire dialect of a custom provider.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CustomApi {
/// Chat Completions with a Bearer key.
OpenAi,
/// Messages with an `x-api-key` header.
Anthropic,
}
impl CustomApi {
pub fn as_str(self) -> &'static str {
match self {
Self::OpenAi => "openai",
Self::Anthropic => "anthropic",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"openai" => Some(Self::OpenAi),
"anthropic" => Some(Self::Anthropic),
_ => None,
}
}
/// Login picker wording.
pub fn label(self) -> &'static str {
match self {
Self::OpenAi => "OpenAI compatible",
Self::Anthropic => "Anthropic compatible",
}
}
pub fn wire_api(self) -> PlatformWireApi {
match self {
Self::OpenAi => PlatformWireApi::ChatCompletions,
Self::Anthropic => PlatformWireApi::Messages,
}
}
pub fn listing(self) -> ListingDialect {
match self {
Self::OpenAi => ListingDialect::OpenAi,
Self::Anthropic => ListingDialect::Anthropic,
}
}
pub fn key_header(self) -> PlatformKeyHeader {
match self {
Self::OpenAi => PlatformKeyHeader::Bearer,
Self::Anthropic => PlatformKeyHeader::XApiKey,
}
}
pub fn chat_compat(self) -> PlatformChatCompat {
PlatformChatCompat::Passthrough
}
}
/// Why a custom provider definition was refused.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CustomProviderError {
Name(String),
BaseUrl(String),
}
impl std::fmt::Display for CustomProviderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Name(reason) => write!(f, "provider name: {reason}"),
Self::BaseUrl(reason) => write!(f, "provider base_url: {reason}"),
}
}
}
impl std::error::Error for CustomProviderError {}
/// A validated provider definition.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomProvider {
pub name: String,
pub api: CustomApi,
/// Without a trailing slash.
pub base_url: String,
}
impl CustomProvider {
pub fn new(name: &str, api: CustomApi, base_url: &str) -> Result<Self, CustomProviderError> {
validate_name(name)?;
Ok(Self {
name: name.to_owned(),
api,
base_url: normalize_base_url(base_url)?,
})
}
/// The listing endpoint, with the dialect's query.
pub fn models_url(&self) -> String {
match self.api {
CustomApi::OpenAi => format!("{}/models", self.base_url),
// Anthropic paginates (default 20); 1000 is the documented max.
CustomApi::Anthropic => format!("{}/models?limit=1000", self.base_url),
}
}
/// Catalog key `{name}/{model_id}`.
pub fn managed_model_key(&self, model_id: &str) -> String {
format!("{}/{model_id}", self.name)
}
}
/// Names become auth.json scopes and catalog key prefixes.
pub fn validate_name(name: &str) -> Result<(), CustomProviderError> {
let err = |reason: &str| Err(CustomProviderError::Name(reason.to_owned()));
if name.is_empty() || name.len() > MAX_NAME_LEN {
return err("must be 1 to 32 characters");
}
let mut chars = name.chars();
if !chars
.next()
.is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
{
return err("must start with a lowercase letter or digit");
}
if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') {
return err("may hold only lowercase letters, digits, '-' and '_'");
}
if crate::PlatformId::parse(name).is_some() {
return err("is a built-in platform id");
}
Ok(())
}
/// Accepts `http(s)://host[:port][/path]`; the URL is logged, so no secrets.
pub fn normalize_base_url(raw: &str) -> Result<String, CustomProviderError> {
let err = |reason: &str| Err(CustomProviderError::BaseUrl(reason.to_owned()));
let url = raw.trim().trim_end_matches('/');
if url.chars().any(|c| c.is_whitespace() || c.is_control()) {
return err("must not contain whitespace");
}
let rest = ["https://", "http://"].iter().find_map(|scheme| {
url.get(..scheme.len())
.filter(|head| head.eq_ignore_ascii_case(scheme))
.map(|_| &url[scheme.len()..])
});
let Some(rest) = rest else {
return err("must start with http:// or https://");
};
let authority = rest.split('/').next().unwrap_or("");
if authority.is_empty() {
return err("has no host");
}
if authority.contains('@') {
return err("must not carry credentials");
}
if url.contains('?') || url.contains('#') {
return err("must not carry a query or fragment");
}
Ok(url.to_owned())
}
/// A valid provider name derived from a host.
pub fn default_name(base_url: &str) -> String {
let host = base_url
.split("://")
.nth(1)
.and_then(|rest| rest.split('/').next())
.and_then(|authority| authority.split(':').next())
.unwrap_or("")
.to_ascii_lowercase();
let labels: Vec<&str> = host
.split('.')
.filter(|label| !label.is_empty() && !matches!(*label, "api" | "www"))
.collect();
let joined: String = labels
.join("-")
.chars()
.filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
.take(MAX_NAME_LEN)
.collect();
let name = joined.trim_matches('-').to_owned();
if validate_name(&name).is_ok() {
name
} else {
"custom".to_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_names_come_from_the_host() {
for (url, expected) in [
("https://api.together.xyz/v1", "together-xyz"),
("https://openrouter.ai/api/v1", "openrouter-ai"),
("http://localhost:11434/v1", "localhost"),
("http://127.0.0.1:8080/v1", "127-0-0-1"),
("https://api.openai.com/v1", "openai-com"),
(
"https://gateway.internal.example.org/anthropic",
"gateway-internal-example-org",
),
("https://API.Example.COM/v1", "example-com"),
("not a url", "custom"),
("https://api/v1", "custom"),
("https://[::1]:9000/v1", "custom"),
] {
assert_eq!(default_name(url), expected, "{url}");
assert!(validate_name(&default_name(url)).is_ok(), "{url}");
}
assert!(default_name(&format!("https://{}.example/v1", "a".repeat(60))).len() <= 32);
}
#[test]
fn api_round_trips_and_maps_to_the_wire() {
for api in [CustomApi::OpenAi, CustomApi::Anthropic] {
assert_eq!(CustomApi::parse(api.as_str()), Some(api));
}
assert_eq!(CustomApi::parse(" OpenAI "), Some(CustomApi::OpenAi));
assert_eq!(CustomApi::parse("responses"), None);
assert_eq!(
CustomApi::OpenAi.wire_api(),
PlatformWireApi::ChatCompletions
);
assert_eq!(CustomApi::Anthropic.wire_api(), PlatformWireApi::Messages);
assert_eq!(CustomApi::OpenAi.key_header(), PlatformKeyHeader::Bearer);
assert_eq!(
CustomApi::Anthropic.key_header(),
PlatformKeyHeader::XApiKey
);
assert_eq!(CustomApi::OpenAi.listing(), ListingDialect::OpenAi);
assert_eq!(CustomApi::Anthropic.listing(), ListingDialect::Anthropic);
for api in [CustomApi::OpenAi, CustomApi::Anthropic] {
assert_eq!(api.chat_compat(), PlatformChatCompat::Passthrough);
}
}
#[test]
fn names_follow_the_rules() {
for ok in ["proxy", "my-proxy_2", "0x", "a"] {
assert!(validate_name(ok).is_ok(), "{ok}");
}
for bad in [
"",
"Proxy",
"pRoxy",
"-x",
"a/b",
"a b",
"a:b",
"é",
&"x".repeat(33),
] {
assert!(validate_name(bad).is_err(), "{bad}");
}
for id in crate::PlatformId::ALL {
assert!(validate_name(id.as_str()).is_err(), "{}", id.as_str());
}
}
#[test]
fn base_urls_are_normalized_or_refused() {
assert_eq!(
normalize_base_url(" https://api.example.com/v1/ ").unwrap(),
"https://api.example.com/v1"
);
assert_eq!(
normalize_base_url("HTTP://localhost:11434/v1").unwrap(),
"HTTP://localhost:11434/v1"
);
for bad in [
"",
"example.com/v1",
"ftp://example.com",
"https://",
"https:///v1",
"https://user:pw@example.com/v1",
"https://example.com/v1?key=x",
"https://example.com/v1#frag",
"https://exa mple.com",
"https://h\u{0}x",
"ht€ps://example.com",
"€https://example.com",
"https:/€",
"http:/€",
] {
assert!(normalize_base_url(bad).is_err(), "{bad}");
}
}
#[test]
fn listing_urls_and_keys_follow_the_dialect() {
let openai =
CustomProvider::new("proxy", CustomApi::OpenAi, "https://h.example/v1/").unwrap();
assert_eq!(openai.models_url(), "https://h.example/v1/models");
assert_eq!(openai.managed_model_key("gpt-x"), "proxy/gpt-x");
let anthropic =
CustomProvider::new("claude-gw", CustomApi::Anthropic, "https://h.example/v1").unwrap();
assert_eq!(
anthropic.models_url(),
"https://h.example/v1/models?limit=1000"
);
}
#[test]
fn provider_new_reports_which_field_failed() {
assert!(matches!(
CustomProvider::new("openai", CustomApi::OpenAi, "https://h.example/v1"),
Err(CustomProviderError::Name(_))
));
assert!(matches!(
CustomProvider::new("proxy", CustomApi::OpenAi, "h.example"),
Err(CustomProviderError::BaseUrl(_))
));
}
}
+16 -1
View File
@@ -223,7 +223,7 @@ mod tests {
"snapshot lost providers: {}",
catalog.len()
);
let k3 = lookup(catalog, "kimi-for-coding", "k3").expect("k3 present");
let k3 = lookup(catalog, "kimi-code-plan-cn", "k3").expect("k3 present");
assert_eq!(k3.context, 1_048_576, "k3 context must match the live wire");
assert_eq!(k3.efforts, ["low", "high", "max"]);
assert!(k3.reasoning);
@@ -232,6 +232,21 @@ mod tests {
assert_eq!(opus.efforts, ["low", "medium", "high", "xhigh", "max"]);
}
/// `restrict_to_enriched` drops a live listing id the enrichment catalog
/// does not know, so the bundled snapshot must admit the current flagship
/// models offline: grok-4.7 on xai (the `xai`/`xai-grok` rows) and
/// gpt-6-astra on the API-key `openai` row.
#[test]
fn bundled_snapshot_admits_the_current_flagship_models() {
let catalog = bundled_enrichment();
let grok = lookup(catalog, "xai", "grok-4.7").expect("grok-4.7 present");
assert!(grok.tool_call, "a non-tool-calling model is dropped");
assert_eq!(grok.context, 500_000);
let astra = lookup(catalog, "openai", "gpt-6-astra").expect("gpt-6-astra present");
assert!(astra.tool_call);
assert_eq!(astra.context, 1_050_000);
}
/// Wire-served fields are never overwritten; absent fields are filled.
#[test]
fn enrich_fills_gaps_and_never_overwrites_wire() {
+79 -27
View File
@@ -16,6 +16,7 @@
use std::sync::LazyLock;
pub mod custom;
pub mod enrichment;
// Platform registry (PRD F2)
@@ -400,7 +401,7 @@ const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec {
vendor: "Kimi",
console_host: None,
login_label: None,
models_dev_id: Some("kimi-for-coding"),
models_dev_id: Some("kimi-code-plan-cn"),
wire_serves_metadata: true,
wire_api: PlatformWireApi::ChatCompletions,
listing: ListingDialect::OpenAi,
@@ -987,7 +988,7 @@ const KIMI_CODING_SPEC: PlatformSpec = PlatformSpec {
vendor: "Kimi",
console_host: Some("www.kimi.com"),
login_label: Some("Kimi For Coding (API key)"),
models_dev_id: Some("kimi-for-coding"),
models_dev_id: Some("kimi-code-plan-cn"),
wire_serves_metadata: true,
wire_api: PlatformWireApi::ChatCompletions,
listing: ListingDialect::OpenAi,
@@ -1285,8 +1286,8 @@ const OPENAI_CODEX_SPEC: PlatformSpec = PlatformSpec {
strip_listing_id_prefix: None,
};
/// The platform registry. Platforms are compiled-in spec rows; there is no
/// dynamic provider registration (PRD F2).
/// The platform registry. Platforms are compiled-in spec rows; user-declared
/// providers live in [`custom`], never here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum PlatformId {
/// Kimi Code subscription (OAuth bearer from the F1 device flow).
@@ -1570,9 +1571,10 @@ impl PlatformId {
/// The compiled-in catalog for a platform that serves NO live `/models`
/// listing (openai-codex), or `None` when the catalog comes from the wire.
///
/// openai-codex's 4 models are HARDCODED (read from the official Codex CLI's
/// `models_cache.json`, the `visibility=="list"` AND `supported_in_api==true`
/// set) because OpenAI exposes no stable public models endpoint for the
/// openai-codex's 8 models are HARDCODED (read from the Codex backend
/// catalog on 2026-09-29, the `visibility=="list"` AND
/// `supported_in_api==true` set) because OpenAI exposes no stable public
/// models endpoint for the
/// ChatGPT Codex backend. Each entry carries context window + per-model
/// selectable reasoning efforts (incl. the codex-only `xhigh`/`max`
/// tiers), so the fetch path maps them through the SAME
@@ -1606,14 +1608,39 @@ fn codex_wire_model(slug: &str, display_name: &str, efforts: &[&str], default: &
}
}
/// The HARDCODED openai-codex catalog: exactly the 4 `visibility=="list"` AND
/// `supported_in_api==true` models from the Codex CLI model cache. The
/// `gpt-5.3-codex-spark` (supported_in_api=false → not served by /responses),
/// `gpt-5.4`, `gpt-5.4-mini`, and `codex-auto-review` (visibility="hide") models
/// are deliberately EXCLUDED — they would list-but-not-work or are not
/// user-facing (fail-fast: never advertise a model the backend rejects).
/// The HARDCODED openai-codex catalog: exactly the 8 `visibility=="list"` AND
/// `supported_in_api==true` models the Codex backend served on 2026-09-29, in
/// its own priority order. `gpt-reserve` and `codex-auto-review`
/// (visibility="hide") are deliberately EXCLUDED — they are not user-facing
/// (fail-fast: never advertise a model the backend rejects). Several models
/// also advertise an `ultra` tier above `max`; kigi has no such level, so it
/// is not listed here.
fn openai_codex_wire_models() -> Vec<WireModel> {
vec![
codex_wire_model(
"gpt-6.1-sol",
"GPT-6.1-Sol",
&["low", "medium", "high", "xhigh", "max"],
"low",
),
codex_wire_model(
"gpt-6-astra",
"GPT-6-Astra",
&["low", "medium", "high", "xhigh", "max"],
"medium",
),
codex_wire_model(
"gpt-6-sol",
"GPT-6-Sol",
&["low", "medium", "high", "xhigh", "max"],
"medium",
),
codex_wire_model(
"gpt-6-luna",
"GPT-6-Luna",
&["low", "medium", "high", "xhigh", "max"],
"medium",
),
codex_wire_model(
"gpt-5.6-sol",
"GPT-5.6-Sol",
@@ -2653,29 +2680,33 @@ mod tests {
assert_eq!(c.base_url(), "https://mock.codex/codex");
}
/// The HARDCODED openai-codex catalog is exactly the 4 supported+listed
/// The HARDCODED openai-codex catalog is exactly the 8 supported+listed
/// models, keyed by slug, ctx 272000, each exposing its exact supported
/// efforts (incl. the codex-only `xhigh`/`max` tiers). The
/// list-but-broken / hidden models are absent. Every other platform serves
/// NO hardcoded catalog (its models come from the live wire).
/// efforts (incl. the codex-only `xhigh`/`max` tiers). The hidden models
/// are absent. Every other platform serves NO hardcoded catalog (its
/// models come from the live wire).
#[test]
fn openai_codex_hardcoded_catalog_is_the_four_supported_models() {
fn openai_codex_hardcoded_catalog_is_the_eight_supported_models() {
let catalog = PlatformId::OpenaiCodex
.hardcoded_catalog()
.expect("openai-codex serves a hardcoded catalog");
let ids: Vec<&str> = catalog.iter().map(|m| m.id.as_str()).collect();
assert_eq!(
ids,
vec!["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"],
"exactly the 4 visibility=list AND supported_in_api=true models"
vec![
"gpt-6.1-sol",
"gpt-6-astra",
"gpt-6-sol",
"gpt-6-luna",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5"
],
"exactly the 8 visibility=list AND supported_in_api=true models"
);
// Excluded: list-but-not-served + hidden models never appear.
for absent in [
"gpt-5.3-codex-spark",
"gpt-5.4",
"gpt-5.4-mini",
"codex-auto-review",
] {
// Excluded: hidden models never appear.
for absent in ["gpt-reserve", "codex-auto-review"] {
assert!(
!ids.contains(&absent),
"{absent} must be excluded from the hardcoded catalog"
@@ -2700,6 +2731,23 @@ mod tests {
.valid_efforts
.clone()
};
assert_eq!(
efforts("gpt-6-astra"),
["low", "medium", "high", "xhigh", "max"],
"`ultra` is advertised above `max` but kigi has no such level"
);
assert_eq!(
efforts("gpt-6.1-sol"),
["low", "medium", "high", "xhigh", "max"]
);
assert_eq!(
efforts("gpt-6-sol"),
["low", "medium", "high", "xhigh", "max"]
);
assert_eq!(
efforts("gpt-6-luna"),
["low", "medium", "high", "xhigh", "max"]
);
assert_eq!(
efforts("gpt-5.6-sol"),
["low", "medium", "high", "xhigh", "max"]
@@ -2725,6 +2773,10 @@ mod tests {
.default_effort
.clone()
};
assert_eq!(default("gpt-6.1-sol").as_deref(), Some("low"));
assert_eq!(default("gpt-6-astra").as_deref(), Some("medium"));
assert_eq!(default("gpt-6-sol").as_deref(), Some("medium"));
assert_eq!(default("gpt-6-luna").as_deref(), Some("medium"));
assert_eq!(default("gpt-5.6-sol").as_deref(), Some("low"));
assert_eq!(default("gpt-5.6-terra").as_deref(), Some("medium"));
// Only openai-codex has a hardcoded catalog; every wire platform is None.
@@ -214,6 +214,7 @@ pub fn open_url_if_safe(url: &str, filter: SchemeFilter) -> bool {
mod tests {
use super::*;
#[cfg(not(target_os = "windows"))]
#[test]
fn open_path_command_passes_path_as_a_single_arg() {
// Path with spaces must be one argument, never shell-interpolated.
@@ -146,6 +146,10 @@ impl TermWriter {
pub fn writer_sync(&self) -> &WriterSync {
&self.sync
}
/// A cloneable handle onto this writer's queue for out-of-band escapes.
pub fn escape_writer(&self) -> EscapeWriter {
EscapeWriter::new(self.tx.clone(), self.sync.clone())
}
}
impl Write for TermWriter {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
@@ -166,6 +170,56 @@ impl Drop for TermWriter {
let _ = self.flush();
}
}
/// Out-of-band escapes ride the frame queue (an inline stderr write deadlocks on a stalled tty); drop every clone before `join_within`.
#[derive(Clone)]
pub struct EscapeWriter {
tx: mpsc::Sender<Vec<u8>>,
sync: WriterSync,
}
impl EscapeWriter {
pub fn new(tx: mpsc::Sender<Vec<u8>>, sync: WriterSync) -> Self {
Self { tx, sync }
}
/// No writer thread behind it: sends are dropped. For tests and views built before the terminal exists.
pub fn disconnected() -> Self {
let (tx, _rx) = mpsc::channel::<Vec<u8>>();
Self {
tx,
sync: WriterSync::new(),
}
}
/// Never blocks; covered by [`WriterSync::wait_drained`].
pub fn emit(&self, bytes: impl Into<Vec<u8>>) {
let data: Vec<u8> = bytes.into();
if data.is_empty() {
return;
}
self.sync.mark_queued();
let _ = self.tx.send(data);
}
/// Winapi-only commands run synchronously: a console API call, not a tty write.
pub fn emit_command(&self, command: impl crossterm::Command) {
#[cfg(windows)]
if !command.is_ansi_code_supported() {
let _ = command.execute_winapi();
return;
}
let mut ansi = String::new();
if command.write_ansi(&mut ansi).is_ok() {
self.emit(ansi);
}
}
}
/// Outcome of [`WriterThread::join_within`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriterJoin {
/// The thread drained its queue and exited.
Joined,
/// Still running at the deadline (tty blocked, or a sender alive); detached.
TimedOut,
}
/// Teardown waits this long for the writer thread before detaching it, so a stalled tty cannot hang `/quit`.
pub const WRITER_JOIN_GRACE: Duration = Duration::from_secs(2);
/// Handle for the background writer thread.
///
/// Joining ensures all queued frames have been written to the terminal
@@ -174,19 +228,45 @@ pub struct WriterThread {
handle: Option<std::thread::JoinHandle<()>>,
}
impl WriterThread {
/// Block until the writer thread has processed all pending frames and
/// exited. The [`mpsc::Sender`] must be dropped *before* calling this,
/// otherwise the thread will never see the channel close.
pub fn join(mut self) {
if let Some(h) = self.handle.take() {
let _ = h.join();
/// Wait up to `grace` for the thread to drain and exit, else detach it; every sender (`EscapeWriter` clones too) must be dropped first.
pub fn join_within(mut self, grace: Duration) -> WriterJoin {
let Some(handle) = self.handle.take() else {
return WriterJoin::Joined;
};
if wait_finished(&handle, grace) {
let _ = handle.join();
WriterJoin::Joined
} else {
tracing::warn!(
grace_ms = grace.as_millis() as u64,
"term-writer thread still running at teardown; detaching"
);
WriterJoin::TimedOut
}
}
#[cfg(test)]
fn for_test(handle: std::thread::JoinHandle<()>) -> Self {
Self {
handle: Some(handle),
}
}
}
fn wait_finished(handle: &std::thread::JoinHandle<()>, grace: Duration) -> bool {
let deadline = Instant::now() + grace;
while !handle.is_finished() {
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(1));
}
true
}
impl Drop for WriterThread {
fn drop(&mut self) {
if let Some(h) = self.handle.take() {
let _ = h.join();
if let Some(handle) = self.handle.take()
&& wait_finished(&handle, WRITER_JOIN_GRACE)
{
let _ = handle.join();
}
}
}
@@ -197,7 +277,7 @@ impl Drop for WriterThread {
/// `BufWriter`. The [`WriterSync`] must be shared with every [`TermWriter`]
/// built on the sender so [`WriterSync::wait_drained`] tracks the queue.
/// Drop the sender to signal the thread to exit, then call
/// [`WriterThread::join`] to wait for it.
/// [`WriterThread::join_within`] to wait for it.
pub fn spawn_writer_thread() -> (mpsc::Sender<Vec<u8>>, WriterSync, WriterThread) {
let (tx, rx) = mpsc::channel::<Vec<u8>>();
let sync = WriterSync::new();
@@ -570,4 +650,47 @@ mod tests {
"Round-tripped bytes do not decode to original UTF-8 string"
);
}
/// Frames and escapes share one queue, so the writer sees them in call order.
#[test]
#[cfg(not(windows))]
fn escapes_share_the_frame_queue_in_call_order() {
let (tx, rx) = mpsc::channel::<Vec<u8>>();
let sync = WriterSync::new();
let mut frames = TermWriter::new(tx, sync.clone());
let escapes = frames.escape_writer();
frames.write_all(b"frame-1").unwrap();
frames.flush().unwrap();
escapes.emit("\x1b[?1000h");
frames.write_all(b"frame-2").unwrap();
frames.flush().unwrap();
escapes.emit_command(crossterm::event::DisableMouseCapture);
let payloads: Vec<Vec<u8>> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
assert_eq!(payloads.len(), 4);
assert_eq!(payloads[0], b"frame-1");
assert_eq!(payloads[1], b"\x1b[?1000h");
assert_eq!(payloads[2], b"frame-2");
assert!(payloads[3].starts_with(b"\x1b["), "{:?}", payloads[3]);
assert!(!sync.is_drained(), "four payloads queued, none written");
}
/// A writer thread parked in a blocked tty write must not hang teardown.
#[test]
fn join_within_times_out_on_a_stuck_writer_thread() {
let (release_tx, release_rx) = mpsc::channel::<()>();
let handle = std::thread::spawn(move || {
let _ = release_rx.recv();
});
let started = Instant::now();
let outcome = WriterThread::for_test(handle).join_within(Duration::from_millis(50));
assert_eq!(outcome, WriterJoin::TimedOut);
assert!(started.elapsed() < Duration::from_secs(2));
let _ = release_tx.send(());
}
#[test]
fn join_within_joins_a_finished_writer_thread() {
let handle = std::thread::spawn(|| {});
assert_eq!(
WriterThread::for_test(handle).join_within(Duration::from_secs(5)),
WriterJoin::Joined
);
}
}
@@ -189,11 +189,6 @@ impl TerminalName {
| Self::Zed
)
}
/// Only Otty is known to wrap macOS IME commits in bracketed paste.
pub fn delivers_ime_as_bracketed_paste(self) -> bool {
matches!(self, Self::Otty)
}
}
impl TerminalContext {
@@ -375,7 +370,6 @@ impl TerminalContext {
return Some("apple_terminal");
}
if self.is_vte_based() {
// WHY: central helper replaces VTE duplication
return Some("vte");
}
if self.brand == TerminalName::WindowsTerminal {
@@ -429,74 +423,29 @@ impl TerminalContext {
}
/// Whether the running terminal cannot distinguish `Shift+Enter` from
/// bare `Enter` at the byte level, so the UI should advertise
/// `Alt+Enter` for newline insertion instead.
/// bare `Enter`, so the UI should advertise `Alt+Enter` (delivered as
/// `ESC`+`CR`) for newline insertion instead.
///
/// Distinguishing `Shift+Enter` requires the Kitty keyboard protocol
/// (KKP) to be negotiated. This returns `true` for the environments
/// where the pager cannot rely on KKP for a usable `Shift+Enter`:
/// Follows [`Self::kitty_skip_reason`] rather than a second brand list:
/// when the protocol is not negotiated the modifier is lost, and the two
/// drifted once already (VTE advertised a chord that never arrived — its
/// `Return` keymap encodes only Alt, through 0.82.0 at least, and no VTE
/// release carries the protocol). Two skips still deliver it — Apple
/// Terminal drops the flags but `route_enter` polls CoreGraphics, and
/// Windows console key records carry SHIFT with no protocol at all.
///
/// 1. **Legacy VTE** (GNOME Terminal, Ptyxis, kgx, Tilix, etc.) whose
/// `VTE_VERSION` is below `8200` (VTE 0.82.0, the first release with
/// KKP, merged in
/// [MR !14](https://gitlab.gnome.org/GNOME/vte/-/merge_requests/14)).
/// Also true when the brand is detected as VTE but `VTE_VERSION` is
/// missing or unparseable — we conservatively assume old.
/// 2. **VS Code's integrated terminal (xterm.js) and VS Code-family /
/// xterm.js IDE forks**. xterm.js only partially implements KKP —
/// it mis-encodes shifted printable keys — so the pager deliberately
/// never negotiates KKP for them (see [`Self::kitty_skip_reason`]
/// `== "vscode"` and [xterm.js#5823](https://github.com/xtermjs/xterm.js/issues/5823)).
/// Without KKP, xterm.js sends a bare `CR` for `Shift+Enter`,
/// byte-for-byte identical to `Enter`.
/// 3. **Unidentified terminals with no multiplexer**, where the pager
/// also skips KKP (no positive evidence of support — typically
/// VS Code's xterm.js reached over SSH, where `TERM_PROGRAM` isn't
/// forwarded and the brand falls back to `Unknown`).
///
/// In every case `Alt+Enter` (delivered as `ESC`+`CR`) is the reliable
/// newline chord and is what the UI advertises.
/// KNOWN GAP: WezTerm inside screen or an old tmux gets Alt+Enter as the
/// advice while its own default binding spends that chord on
/// ToggleFullScreen. Neither chord reaches kigi there; the multiplexer
/// warning carries the real fix, and `\`+Enter still inserts a newline.
pub fn shift_enter_unavailable(&self) -> bool {
// WHY: central helper + version gating
let is_vte = self.is_vte_based();
if is_vte {
return match self
.vte_version
.as_deref()
.and_then(|v| v.parse::<u32>().ok())
{
Some(ver) => ver < 8200,
// Brand=Vte but no parseable version — conservative: assume old.
None => true,
};
match self.kitty_skip_reason() {
None | Some("apple_terminal") => false,
// A bare ConHost is only optimistically refined to Windows
// Terminal; it still advertises Alt+Enter.
Some("windows_terminal") => self.env_brand.is_capability_unclassified(),
Some(_) => true,
}
// VS Code / xterm.js and its forks: KKP is never negotiated, so
// Shift+Enter arrives as a bare CR indistinguishable from Enter.
if matches!(
self.brand,
TerminalName::VsCode
| TerminalName::Cursor
| TerminalName::Windsurf
| TerminalName::Zed
) {
return true;
}
// Unidentified / unclassified brand with no multiplexer: KKP is
// skipped (see `kitty_skip_reason`). This is the common
// VS Code-over-SSH shape (brand falls back to Unknown). On native
// Windows the effective `brand` is refined to WindowsTerminal, so
// consult `env_brand` — a bare ConHost is still env-Unknown and
// must advertise Alt+Enter even though we optimistically treat it
// as WT for capabilities.
if self.env_brand.is_capability_unclassified()
&& self.multiplexer == MultiplexerKind::Undetected
{
return true;
}
false
}
/// True when `Ctrl+.` cannot be delivered reliably as a shortcuts primary.
@@ -10,6 +10,7 @@
//! portable re-injection exists (TIOCSTI is blocked); accepted loss.
use std::io::Write;
#[cfg(unix)]
use std::time::Duration;
/// Bounds the reply buffer against terminals that stream without a terminator.
@@ -51,24 +51,6 @@ fn brand_otty_from_term_program() {
assert_eq!(detect_terminal_brand_from_env(&env), TerminalName::Otty);
}
#[test]
fn otty_delivers_ime_as_bracketed_paste_only() {
assert!(TerminalName::Otty.delivers_ime_as_bracketed_paste());
for brand in [
TerminalName::AppleTerminal,
TerminalName::Ghostty,
TerminalName::Iterm2,
TerminalName::Unknown,
TerminalName::WezTerm,
TerminalName::Kitty,
] {
assert!(
!brand.delivers_ime_as_bracketed_paste(),
"{brand:?} must not gate IME bracketed-paste origin"
);
}
}
#[test]
fn otty_is_capability_unclassified_like_unknown() {
assert!(TerminalName::Otty.is_capability_unclassified());
@@ -1443,7 +1425,7 @@ fn kitty_skip_vscode_over_tmux() {
#[test]
fn kitty_skip_vte_version() {
// VTE does not support Kitty keyboard protocol and crossterm's probe
// No VTE release has the Kitty keyboard protocol and crossterm's probe
// can false-positive on it. https://gitlab.gnome.org/GNOME/vte/-/issues/2601
let ctx = TerminalContext {
vte_version: Some("7402".to_owned()),
@@ -1461,73 +1443,68 @@ fn kitty_skip_vte_brand() {
assert_eq!(ctx.kitty_skip_reason(), Some("vte"));
}
// shift_enter_unavailable: VTE version gating for Shift+Enter
// shift_enter_unavailable: VTE never delivers Shift+Enter
//
// VTE 0.82.0 (= VTE_VERSION 8200) is the first release containing the
// Kitty keyboard protocol; earlier versions cannot distinguish
// Shift+Enter from bare Enter, so the UI should advertise Alt+Enter
// for newline insertion instead.
// No VTE release carries the Kitty keyboard protocol: at 0.82.0 the word
// "kitty" appears nowhere in the parser or widget, and
// `_vte_keymap_GDK_Return` encodes only Alt (`ESC CR`), so Shift+Enter is
// the same bare CR as Enter. The UI advertises Alt+Enter instead.
#[test]
fn shift_enter_unavailable_legacy_vte_version() {
// VTE 0.64.2 (real user report) — well below the KKP cutoff.
let ctx = TerminalContext {
vte_version: Some("6402".to_owned()),
..Default::default()
};
assert!(ctx.shift_enter_unavailable());
fn shift_enter_unavailable_on_every_vte() {
for version in [Some("6402"), Some("8200"), Some("8401"), None] {
let ctx = TerminalContext {
brand: TerminalName::Vte,
env_brand: TerminalName::Vte,
vte_version: version.map(str::to_owned),
..Default::default()
};
assert_eq!(ctx.kitty_skip_reason(), Some("vte"), "VTE {version:?}");
assert!(ctx.shift_enter_unavailable(), "VTE {version:?}");
}
}
#[test]
fn shift_enter_unavailable_just_below_cutoff() {
// VTE 0.81.99 — anything below 8200 must return true.
let ctx = TerminalContext {
vte_version: Some("8199".to_owned()),
..Default::default()
};
assert!(ctx.shift_enter_unavailable());
}
#[test]
fn shift_enter_available_modern_vte() {
// VTE 0.82.0 — first release with KKP.
let ctx = TerminalContext {
vte_version: Some("8200".to_owned()),
..Default::default()
};
assert!(!ctx.shift_enter_unavailable());
}
#[test]
fn shift_enter_available_future_vte() {
// VTE 0.84.1 — well above the cutoff.
let ctx = TerminalContext {
vte_version: Some("8401".to_owned()),
..Default::default()
};
assert!(!ctx.shift_enter_unavailable());
}
#[test]
fn shift_enter_unavailable_vte_brand_no_version() {
// Brand detected as VTE but VTE_VERSION missing — conservative: old.
let ctx = TerminalContext {
brand: TerminalName::Vte,
vte_version: None,
..Default::default()
};
assert!(ctx.shift_enter_unavailable());
}
#[test]
fn shift_enter_unavailable_unparseable_version() {
// Garbage in VTE_VERSION — conservative: old.
let ctx = TerminalContext {
brand: TerminalName::Vte,
vte_version: Some("not-a-number".to_owned()),
..Default::default()
};
assert!(ctx.shift_enter_unavailable());
fn shift_enter_unavailable_follows_every_kitty_skip() {
// A skipped protocol means the modifier never arrives, whatever the
// reason — JediTerm, screen and a multiplexer that eats extended keys
// included. Apple Terminal and Windows Terminal are the two skips with
// another way in.
for ctx in [
TerminalContext {
brand: TerminalName::JetBrains,
env_brand: TerminalName::JetBrains,
..Default::default()
},
TerminalContext {
brand: TerminalName::Kitty,
env_brand: TerminalName::Kitty,
multiplexer: MultiplexerKind::Screen,
..Default::default()
},
TerminalContext {
brand: TerminalName::Kitty,
env_brand: TerminalName::Kitty,
multiplexer: MultiplexerKind::Tmux,
tmux_version: Some("3.2".to_owned()),
..Default::default()
},
// A VTE carrying a recent VTE_VERSION is no different.
TerminalContext {
brand: TerminalName::Vte,
env_brand: TerminalName::Vte,
vte_version: Some("8200".to_owned()),
multiplexer: MultiplexerKind::Screen,
..Default::default()
},
] {
assert!(ctx.kitty_skip_reason().is_some());
assert!(
ctx.shift_enter_unavailable(),
"{:?} skips the protocol, so Shift+Enter cannot arrive",
ctx.brand
);
}
}
#[test]
@@ -267,6 +267,13 @@ struct WrapCache {
lines: Vec<Range<usize>>,
}
/// Whether a wrapped row's exclusive end is a cursor position on that row.
#[derive(Debug, Clone, Copy)]
enum RowEnd {
HardBreak,
SoftWrap,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct TextAreaState {
/// Index into wrapped lines of the first visible line.
@@ -382,27 +389,13 @@ impl TextArea {
}
}
/// Clamp a buffer position so it stays within a wrapped line's range
/// `[line_start, line_end)`. Without this, `display_col_to_buffer_pos`
/// can return `line_end` when the column exceeds the line's display
/// width — and `line_end` equals the *next* wrapped line's start,
/// which confuses `effective_scroll` into thinking the cursor hasn't
/// actually moved to the target line.
///
/// Uses `self.text` to find the last valid char boundary inside the line
/// so we never land in the middle of a multi-byte character.
/// Clamp `pos` to the start of the last grapheme in `[line_start, line_end)`.
fn clamp_to_line(&self, pos: usize, line_start: usize, line_end: usize) -> usize {
if line_end > line_start {
// Find the start of the last character in the line.
let last_char_start = self.text[line_start..line_end]
.char_indices()
.next_back()
.map(|(i, _)| line_start + i)
.unwrap_or(line_start);
pos.min(last_char_start)
} else {
line_start
}
let last_grapheme_start = self.text[line_start..line_end]
.grapheme_indices(true)
.next_back()
.map_or(line_start, |(i, _)| line_start + i);
pos.min(last_grapheme_start)
}
pub fn new() -> Self {
@@ -1699,6 +1692,35 @@ impl TextArea {
if idx == 0 { None } else { Some(idx - 1) }
}
/// Soft-wrapped rows are byte-adjacent; a hard row leaves its newline between it and the next.
fn row_with_end(lines: &[Range<usize>], idx: usize) -> Option<(Range<usize>, RowEnd)> {
let row = lines.get(idx)?;
let end_kind = match lines.get(idx + 1) {
Some(next) if next.start == row.end => RowEnd::SoftWrap,
_ => RowEnd::HardBreak,
};
Some((row.clone(), end_kind))
}
fn clamp_to_row(&self, pos: usize, row: &Range<usize>, end_kind: RowEnd) -> usize {
match end_kind {
RowEnd::HardBreak => pos,
RowEnd::SoftWrap => {
let last_grapheme_start = self.clamp_to_line(pos, row.start, row.end);
let Some(idx) = self.find_element_containing(last_grapheme_start) else {
return last_grapheme_start;
};
let element_start = self.elements[idx].range.start;
// A display-less element wraps as text; one that began on an earlier row keeps `pos`.
if element_start < row.start {
pos
} else {
element_start
}
}
}
}
/// Map a display column to a buffer byte position on a given wrapped line.
///
/// Pure query — does not mutate any state. Handles elements (snapping to
@@ -1791,13 +1813,12 @@ impl TextArea {
fn move_to_display_col_on_line(
&mut self,
line_start: usize,
line_end: usize,
row: Range<usize>,
target_col: usize,
end_kind: RowEnd,
) {
self.cursor_pos = self
.display_col_to_buffer_pos(line_start, line_end, target_col)
.0;
let (pos, _hit_element) = self.display_col_to_buffer_pos(row.start, row.end, target_col);
self.cursor_pos = self.clamp_to_row(pos, &row, end_kind);
}
fn beginning_of_line(&self, pos: usize) -> usize {
@@ -2652,14 +2673,10 @@ impl TextArea {
let target_col = self.preferred_col.unwrap_or_else(|| {
self.display_width_of_range(cur_range.start, self.cursor_pos)
});
if idx > 0 {
let prev = &lines[idx - 1];
let line_start = prev.start;
let line_end = prev.end;
Some((target_col, Some((line_start, line_end))))
} else {
Some((target_col, None))
}
let prev = idx
.checked_sub(1)
.and_then(|prev_idx| Self::row_with_end(lines, prev_idx));
Some((target_col, prev))
} else {
None
}
@@ -2669,11 +2686,11 @@ impl TextArea {
} {
// We had wrapping info. Apply movement accordingly.
match maybe_line {
Some((line_start, line_end)) => {
Some((prev, end_kind)) => {
if self.preferred_col.is_none() {
self.preferred_col = Some(target_col);
}
self.move_to_display_col_on_line(line_start, line_end, target_col);
self.move_to_display_col_on_line(prev, target_col, end_kind);
return;
}
None => {
@@ -2697,7 +2714,11 @@ impl TextArea {
};
let prev_line_start = self.text[..prev_nl].rfind('\n').map(|i| i + 1).unwrap_or(0);
let prev_line_end = prev_nl;
self.move_to_display_col_on_line(prev_line_start, prev_line_end, target_col);
self.move_to_display_col_on_line(
prev_line_start..prev_line_end,
target_col,
RowEnd::HardBreak,
);
} else {
self.cursor_pos = 0;
self.preferred_col = None;
@@ -2716,14 +2737,7 @@ impl TextArea {
let target_col = self.preferred_col.unwrap_or_else(|| {
self.display_width_of_range(cur_range.start, self.cursor_pos)
});
if idx + 1 < lines.len() {
let next = &lines[idx + 1];
let line_start = next.start;
let line_end = next.end;
Some((target_col, Some((line_start, line_end))))
} else {
Some((target_col, None))
}
Some((target_col, Self::row_with_end(lines, idx + 1)))
} else {
None
}
@@ -2732,11 +2746,11 @@ impl TextArea {
}
} {
match move_to_last {
Some((line_start, line_end)) => {
Some((next, end_kind)) => {
if self.preferred_col.is_none() {
self.preferred_col = Some(target_col);
}
self.move_to_display_col_on_line(line_start, line_end, target_col);
self.move_to_display_col_on_line(next, target_col, end_kind);
return;
}
None => {
@@ -2766,7 +2780,11 @@ impl TextArea {
.find('\n')
.map(|i| i + next_line_start)
.unwrap_or(self.text.len());
self.move_to_display_col_on_line(next_line_start, next_line_end, target_col);
self.move_to_display_col_on_line(
next_line_start..next_line_end,
target_col,
RowEnd::HardBreak,
);
} else {
self.cursor_pos = self.text.len();
self.preferred_col = None;
@@ -3899,6 +3917,9 @@ fn truncate_line_display(line: &Line<'static>, max_width: usize) -> Line<'static
Line::from(new_spans)
}
#[cfg(test)]
mod soft_wrap_nav_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -6293,6 +6314,7 @@ mod tests {
assert_eq!(t.cursor(), "👍👍".len());
}
/// Each ZWJ cluster fills a 4-col row; Up from the end lands on row 1's cluster start.
#[test]
fn wrapped_navigation_with_zwj_graphemes() {
let grapheme = "👩\u{200D}💻";
@@ -6306,7 +6328,7 @@ mod tests {
assert!(pos_after_down >= grapheme.len() * 2);
t.move_cursor_up();
assert_eq!(t.cursor(), grapheme.len() * 2);
assert_eq!(t.cursor(), grapheme.len());
}
#[test]
@@ -0,0 +1,121 @@
//! Up/Down across soft-wrapped rows: a soft row's exclusive end is the next row's start.
use rand::prelude::*;
use ratatui::layout::Rect;
use ratatui::text::Line;
use super::{ElementKind, TextArea};
fn ta_with(text: &str) -> TextArea {
let mut t = TextArea::new();
t.insert_str(text);
t
}
fn cursor_row(t: &TextArea, width: u16) -> usize {
TextArea::wrapped_line_index_by_start(&t.wrapped_lines(width), t.cursor()).unwrap()
}
#[test]
fn up_from_wide_row_lands_on_last_char_of_narrower_soft_row() {
let width = 8;
let mut t = ta_with("aaa bb ccccccc");
assert_eq!(vec![0..7, 7..14], *t.wrapped_lines(width));
t.move_cursor_up();
assert_eq!(6, t.cursor());
assert_eq!(0, cursor_row(&t, width));
assert_eq!(Some((6, 0)), t.cursor_pos(Rect::new(0, 0, width, 5)));
t.move_cursor_down();
assert_eq!(14, t.cursor());
t.move_cursor_up();
assert_eq!(6, t.cursor());
t.move_cursor_up();
assert_eq!(0, t.cursor());
}
#[test]
fn up_onto_newline_terminated_row_lands_on_the_newline() {
let mut t = ta_with("ab\ncdef");
assert_eq!(vec![0..2, 3..7], *t.wrapped_lines(8));
t.move_cursor_up();
assert_eq!(2, t.cursor());
assert_eq!(0, cursor_row(&t, 8));
}
#[test]
fn down_from_wide_row_lands_on_last_char_of_narrower_soft_row() {
let mut t = ta_with("ccccccc aaa bb ddddddd");
assert_eq!(vec![0..8, 8..15, 15..22], *t.wrapped_lines(8));
t.set_cursor(7);
t.move_cursor_down();
assert_eq!(14, t.cursor());
assert_eq!(1, cursor_row(&t, 8));
t.move_cursor_down();
assert_eq!(22, t.cursor());
}
#[test]
fn up_onto_soft_row_closed_by_element_lands_before_the_element() {
let mut t = TextArea::new();
t.insert_element("xyz", ElementKind(0), Some(Line::from("[ELEM]")));
t.insert_str("longword");
assert_eq!(vec![0..3, 3..11], *t.wrapped_lines(8));
t.move_cursor_up();
assert_eq!(0, t.cursor());
assert_eq!(0, cursor_row(&t, 8));
}
#[test]
fn down_through_element_spanning_rows_reaches_the_end() {
let mut t = TextArea::new();
t.insert_element("abcdefgh", ElementKind(0), None);
t.insert_str("z");
assert_eq!(vec![0..3, 3..6, 6..9], *t.wrapped_lines(3));
t.set_cursor(0);
t.move_cursor_down();
assert_eq!(8, t.cursor());
}
#[test]
fn up_and_down_move_one_row_at_a_time() {
let mut rng = StdRng::seed_from_u64(7);
for _ in 0..500 {
let words = rng.random_range(1..12);
let text = (0..words)
.map(|_| "x".repeat(rng.random_range(1..9)))
.collect::<Vec<_>>()
.join(" ");
let width = rng.random_range(3..12);
let mut t = ta_with(&text);
let rows = t.wrapped_lines(width).len();
t.set_cursor(rng.random_range(0..=text.len()));
let mut row = cursor_row(&t, width);
while row + 1 < rows {
t.move_cursor_down();
assert_eq!(
row + 1,
cursor_row(&t, width),
"down in {text:?} at width {width}"
);
row += 1;
}
while row > 0 {
t.move_cursor_up();
assert_eq!(
row - 1,
cursor_row(&t, width),
"up in {text:?} at width {width}"
);
row -= 1;
}
}
}
+1
View File
@@ -18,6 +18,7 @@ eventsource-stream = { workspace = true }
futures-util = { workspace = true }
indexmap = { workspace = true, features = ["serde"] }
reqwest = { workspace = true, features = ["stream"] }
kigi-secrets = { path = "../kigi-secrets" }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt", "macros", "time", "sync"] }
+49 -6
View File
@@ -193,6 +193,16 @@ fn extract_context_total(value: &serde_json::Value) -> Option<u32> {
Some(i.saturating_add(o))
}
/// The endpoint as the HTTP client parses it, minus userinfo, sensitive query values and fragment; an unparseable one is not echoed.
fn redacted_endpoint(endpoint: &str) -> String {
match reqwest::Url::parse(endpoint) {
Ok(mut url) => {
kigi_secrets::redact_url(&mut url);
url.to_string()
}
Err(_) => "<unparseable endpoint>".to_owned(),
}
}
/// Record `success=false` + `error` on the active inference span when a stream
/// request fails before any response (transport/connect/TLS errors). Without
/// this the `#[instrument]` span closes with both fields Empty, so an outage
@@ -806,7 +816,7 @@ impl SamplingClient {
let server_message_lower = server_message.to_lowercase();
let mut context_parts = vec![server_message.to_string()];
context_parts.push(format!("\nRequest URL: {}", endpoint));
context_parts.push(format!("\nRequest URL: {}", redacted_endpoint(endpoint)));
// Show headers if error mentions headers
if server_message_lower.contains("header") {
@@ -1006,7 +1016,8 @@ impl SamplingClient {
let endpoint = self.endpoint("chat/completions");
let server_message = response.text().await.unwrap_or_default();
return Err(SamplingError::Auth(format!(
"Unauthorized (401) from {endpoint}: {server_message}"
"Unauthorized (401) from {}: {server_message}",
redacted_endpoint(&endpoint)
)));
}
@@ -1196,7 +1207,8 @@ impl SamplingClient {
let endpoint = self.endpoint("responses");
let server_message = parse_error_bytes(bytes.as_ref());
return Err(SamplingError::Auth(format!(
"Unauthorized (401) from {endpoint}: {server_message}"
"Unauthorized (401) from {}: {server_message}",
redacted_endpoint(&endpoint)
)));
}
@@ -1363,7 +1375,8 @@ impl SamplingClient {
let endpoint = self.endpoint("responses");
let server_message = response.text().await.unwrap_or_default();
return Err(SamplingError::Auth(format!(
"Unauthorized (401) from {endpoint}: {server_message}"
"Unauthorized (401) from {}: {server_message}",
redacted_endpoint(&endpoint)
)));
}
let model_metadata = extract_model_metadata(response.headers());
@@ -1542,7 +1555,8 @@ impl SamplingClient {
let endpoint = self.endpoint("messages");
let server_message = parse_error_bytes(bytes.as_ref());
return Err(SamplingError::Auth(format!(
"Unauthorized (401) from {endpoint}: {server_message}"
"Unauthorized (401) from {}: {server_message}",
redacted_endpoint(&endpoint)
)));
}
@@ -1657,7 +1671,8 @@ impl SamplingClient {
let endpoint = self.endpoint("messages");
let server_message = response.text().await.unwrap_or_default();
return Err(SamplingError::Auth(format!(
"Unauthorized (401) from {endpoint}: {server_message}"
"Unauthorized (401) from {}: {server_message}",
redacted_endpoint(&endpoint)
)));
}
let model_metadata = extract_model_metadata(response.headers());
@@ -3048,3 +3063,31 @@ mod tests {
));
}
}
#[cfg(test)]
mod redacted_endpoint_tests {
/// Every spelling the client accepts (LF inside, one slash, no slash) loses its userinfo.
#[test]
fn redacted_endpoint_drops_userinfo_in_every_accepted_spelling() {
for spelling in [
"http://u:TEST_PASSWORD@127.0.0.1:1/v1/chat/completions",
"http://u:TEST PASSWORD@127.0.0.1:1/v1/chat/completions",
"http://u:TEST_PASSWORD\n@127.0.0.1:1/v1/chat/completions",
"http://u:TEST_PASSWORD\r\n@127.0.0.1:1/v1/chat/completions",
"http:/u:TEST_PASSWORD@127.0.0.1:1/v1/chat/completions",
"http:u:TEST_PASSWORD@127.0.0.1:1/v1/chat/completions",
"http:\\u:TEST_PASSWORD@127.0.0.1:1/v1/chat/completions",
] {
let redacted = super::redacted_endpoint(spelling);
assert_eq!(
redacted, "http://127.0.0.1:1/v1/chat/completions",
"{spelling:?}"
);
}
let gateway = super::redacted_endpoint(
"http://127.0.0.1:1/?access_token=TEST_QUERY_TOKEN&route=x#frag",
);
assert!(!gateway.contains("TEST_QUERY_TOKEN"), "{gateway}");
assert!(gateway.contains("route=x"), "{gateway}");
}
}
+47 -7
View File
@@ -5,8 +5,11 @@
//!
//! # Retry behavior summary
//!
//! **Retried** (up to [`DEFAULT_MAX_RETRIES`] = 15, ~6 min with 30s backoff cap):
//! - 500, 502, 503, 504, 520 (server errors)
//! **Retried** (up to [`DEFAULT_MAX_RETRIES`] = 15, ~6 min with the
//! [`MAX_RETRY_BACKOFF`] cap — a server `Retry-After` is clamped to it too):
//! - Any 5xx except Cloudflare 525/526 — covers the Cloudflare edge pages
//! (520-524 origin unreachable/timed out, 530 edge 1xxx) and upstream
//! overload (529); see `is_retryable_api_status`
//! - Connection errors (timeout, refused, reset)
//! - `EventStreamError` / `StreamError` (mid-stream failures)
//! - `EmptyResponse` (model returned no content/tool calls)
@@ -77,16 +80,35 @@ pub fn doom_loop_backoff(retry_count: u32) -> Duration {
Duration::from_millis(hasher.finish() % 251)
}
/// Exponential backoff (2s, 4s, 8s, ..., capped 30s) with +/-20% jitter
/// to prevent thundering-herd retry storms.
/// Longest single wait on the generic retry path — the exponential-backoff
/// ceiling, and the clamp for a server `Retry-After`. Cloudflare answers 52x
/// with `Retry-After: 60`-`120`; honoring that verbatim across the retry
/// budget would stall a turn ~28 min instead of the ~6 min budget. The 429
/// path deliberately waits the full `Retry-After` instead, bounded by
/// [`RATE_LIMIT_RETRY_THRESHOLD`] attempts (and by the parse-level 120s cap
/// on the header).
pub const MAX_RETRY_BACKOFF: Duration = Duration::from_secs(30);
/// Exponential backoff (2s, 4s, 8s, ..., capped at [`MAX_RETRY_BACKOFF`])
/// with +/-20% jitter to prevent thundering-herd retry storms.
pub fn retry_backoff_with_jitter(retry_count: u32) -> Duration {
let shift = retry_count.saturating_sub(1);
let base_ms = 2000u64
.checked_shl(shift)
.unwrap_or(u64::MAX)
.min(MAX_RETRY_BACKOFF.as_millis() as u64);
jittered(Duration::from_millis(base_ms))
}
/// +/-20% jitter around `base`, de-syncing clients that failed at the
/// same instant (e.g. a mass Cloudflare 52x event during an origin outage).
fn jittered(base: Duration) -> Duration {
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicU64, Ordering};
static JITTER_SEQ: AtomicU64 = AtomicU64::new(0);
let shift = retry_count.saturating_sub(1);
let base_ms = 2000u64.checked_shl(shift).unwrap_or(u64::MAX).min(30_000);
let base_ms = base.as_millis() as u64;
let jitter_range = base_ms / 5;
let mut hasher = std::hash::DefaultHasher::new();
JITTER_SEQ.fetch_add(1, Ordering::Relaxed).hash(&mut hasher);
@@ -210,9 +232,11 @@ pub fn classify_error(
if next_attempt >= max_retries {
return RetryDecision::Fatal(clone_error(err));
}
// A server `Retry-After` is clamped: Cloudflare 52x pages ask for
// 60-120s, which across the budget would stall a turn ~28 min.
let backoff = err
.retry_after()
.map(Duration::from_secs)
.map(|secs| jittered(Duration::from_secs(secs).min(MAX_RETRY_BACKOFF)))
.unwrap_or_else(|| retry_backoff_with_jitter(next_attempt));
if next_attempt == 1 {
return RetryDecision::RetryWithClientRebuild { backoff };
@@ -595,6 +619,22 @@ mod tests {
}
}
#[test]
fn classify_5xx_retry_after_is_clamped_to_backoff_cap() {
// Cloudflare answers 52x with Retry-After: 60-120; honoring it
// verbatim across the retry budget would stall a turn ~28 min.
let err = api_err_with_retry_after(StatusCode::from_u16(522).unwrap(), 120);
match classify_error(&err, 1, 15, RATE_LIMIT_RETRY_THRESHOLD) {
RetryDecision::Retry { backoff } => {
assert!(
backoff <= MAX_RETRY_BACKOFF.mul_f64(1.2),
"52x Retry-After must clamp to ~MAX_RETRY_BACKOFF, got {backoff:?}"
);
}
other => panic!("expected Retry, got {other:?}"),
}
}
#[test]
fn classify_5xx_first_retry_rebuilds_client() {
let err = api_err(StatusCode::INTERNAL_SERVER_ERROR, "boom");
@@ -46,6 +46,8 @@ struct BlockState {
args_acc: String,
thinking_acc: String,
signature: String,
/// The first `SignatureDelta` replaces a start-seeded signature; later ones append.
signature_delta_seen: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -55,6 +57,15 @@ enum BlockType {
Thinking,
}
/// Executable after a `max_tokens` stop: every call's arguments parse or are empty (zero-arg convention).
fn tool_calls_complete(tool_calls: &[ToolCall]) -> bool {
!tool_calls.is_empty()
&& tool_calls.iter().all(|tc| {
tc.arguments.trim().is_empty()
|| serde_json::from_str::<serde::de::IgnoredAny>(&tc.arguments).is_ok()
})
}
/// Transform a raw Anthropic Messages API stream into a stream of
/// [`SamplingEvent`]s.
///
@@ -100,13 +111,10 @@ pub fn stream_messages<'a>(
let mut final_stop_reason: Option<StopReason> = None;
let mut final_stop_message: Option<String> = None;
// Assistant-response accumulators (built up as ContentBlockStop
// events fire). Reasoning is collected into a synthesized
// `rs::ReasoningItem` and emitted as a sibling
// `ConversationItem::Reasoning` before the trailing Assistant.
// Assistant-response accumulators, filled as ContentBlockStop events fire.
let mut assistant_text = String::new();
let mut assistant_tool_calls: Vec<ToolCall> = Vec::new();
let mut assistant_reasoning: Option<rs::ReasoningItem> = None;
let mut assistant_reasoning: Vec<rs::ReasoningItem> = Vec::new();
// Index counters
let mut chunk_index: u64 = 0;
@@ -176,6 +184,7 @@ pub fn stream_messages<'a>(
args_acc: String::new(),
thinking_acc: thinking.clone(),
signature: signature.clone(),
signature_delta_seen: false,
},
);
if !first_token_emitted {
@@ -196,6 +205,7 @@ pub fn stream_messages<'a>(
args_acc: String::new(),
thinking_acc: String::new(),
signature: String::new(),
signature_delta_seen: false,
},
);
if !first_token_emitted {
@@ -228,6 +238,7 @@ pub fn stream_messages<'a>(
args_acc: String::new(),
thinking_acc: String::new(),
signature: String::new(),
signature_delta_seen: false,
},
);
@@ -267,7 +278,11 @@ pub fn stream_messages<'a>(
}
}
StreamDelta::SignatureDelta { signature } => {
state.signature = signature;
if !state.signature_delta_seen {
state.signature_delta_seen = true;
state.signature.clear();
}
state.signature.push_str(&signature);
}
StreamDelta::TextDelta { text } => {
if !text.is_empty() {
@@ -338,7 +353,7 @@ pub fn stream_messages<'a>(
} else {
Some(state.signature)
};
assistant_reasoning = Some(rs::ReasoningItem {
assistant_reasoning.push(rs::ReasoningItem {
id: String::new(),
summary,
content: None,
@@ -454,11 +469,18 @@ pub fn stream_messages<'a>(
}
if final_stop_reason == Some(StopReason::Length) {
yield SamplingEvent::Failed {
request_id: request_id.clone(),
error: SamplingErrorInfo::from(&SamplingError::MaxTokensTruncation),
};
return;
if !tool_calls_complete(&assistant_tool_calls) {
yield SamplingEvent::Failed {
request_id: request_id.clone(),
error: SamplingErrorInfo::from(&SamplingError::MaxTokensTruncation),
};
return;
}
tracing::info!(
request_id = %request_id,
tool_calls = assistant_tool_calls.len(),
"max_tokens stop after complete tool calls; completing the turn so they run"
);
}
// Build the final response
@@ -497,9 +519,7 @@ pub fn stream_messages<'a>(
});
let mut items: Vec<ConversationItem> = Vec::new();
if let Some(r) = assistant_reasoning {
items.push(ConversationItem::Reasoning(r));
}
items.extend(assistant_reasoning.into_iter().map(ConversationItem::Reasoning));
items.push(assistant_item);
let stream_end = Instant::now();
@@ -208,6 +208,91 @@ async fn thinking_block_emits_reasoning_channel_and_preserved_in_response() {
}
}
fn thinking_block(
index: u32,
start_signature: &str,
thinking: &str,
signature_deltas: &[&str],
) -> Vec<Result<MessageStreamEvent, SamplingError>> {
let mut events = vec![
Ok(MessageStreamEvent::ContentBlockStart {
index,
content_block: ContentBlock::Thinking {
thinking: String::new(),
signature: start_signature.into(),
},
}),
Ok(MessageStreamEvent::ContentBlockDelta {
index,
delta: StreamDelta::ThinkingDelta {
thinking: thinking.into(),
},
}),
];
for signature in signature_deltas {
events.push(Ok(MessageStreamEvent::ContentBlockDelta {
index,
delta: StreamDelta::SignatureDelta {
signature: (*signature).into(),
},
}));
}
events.push(Ok(block_stop(index)));
events
}
async fn reasoning_signatures(
blocks: Vec<Vec<Result<MessageStreamEvent, SamplingError>>>,
) -> Vec<(String, Option<String>)> {
let mut events = vec![Ok(message_start())];
events.extend(blocks.into_iter().flatten());
events.push(Ok(MessageStreamEvent::MessageStop));
let raw = stream::iter(events).boxed();
let evs = collect(stream_messages(raw, None, rid(), Duration::from_secs(60))).await;
match evs.last().unwrap() {
SamplingEvent::Completed { response, .. } => response
.reasoning_items()
.map(|r| {
let text = r
.summary
.iter()
.map(|rs::SummaryPart::SummaryText(t)| t.text.as_str())
.collect::<String>();
(text, r.encrypted_content.clone())
})
.collect(),
other => panic!("expected Completed, got {other:?}"),
}
}
#[tokio::test]
async fn every_thinking_block_is_kept_in_order() {
let items = reasoning_signatures(vec![
thinking_block(0, "", "first", &["sig-a"]),
thinking_block(1, "", "second", &["sig-b"]),
])
.await;
assert_eq!(
items,
vec![
("first".to_string(), Some("sig-a".to_string())),
("second".to_string(), Some("sig-b".to_string())),
]
);
}
#[tokio::test]
async fn split_signature_deltas_join_into_one_signature() {
let items = reasoning_signatures(vec![thinking_block(0, "", "t", &["abc", "def"])]).await;
assert_eq!(items, vec![("t".to_string(), Some("abcdef".to_string()))]);
}
#[tokio::test]
async fn first_signature_delta_replaces_a_start_seeded_signature() {
let items = reasoning_signatures(vec![thinking_block(0, "sig", "t", &["sig"])]).await;
assert_eq!(items, vec![("t".to_string(), Some("sig".to_string()))]);
}
#[tokio::test]
async fn tool_use_block_assembles_into_tool_call() {
let tool_start = MessageStreamEvent::ContentBlockStart {
@@ -653,3 +738,90 @@ async fn pure_cache_hit_with_zero_uncached_still_emits_usage() {
assert_eq!(usage.cached_prompt_tokens, 2500);
assert_eq!(usage.total_tokens, 2501);
}
fn tool_use_start(index: u32, id: &str) -> MessageStreamEvent {
MessageStreamEvent::ContentBlockStart {
index,
content_block: ContentBlock::ToolUse {
id: id.into(),
name: "do_thing".into(),
input: serde_json::json!({}),
},
}
}
fn arg_delta(index: u32, partial_json: &str) -> MessageStreamEvent {
MessageStreamEvent::ContentBlockDelta {
index,
delta: StreamDelta::InputJsonDelta {
partial_json: partial_json.into(),
},
}
}
async fn max_tokens_after(events: Vec<MessageStreamEvent>) -> Vec<SamplingEvent> {
let mut all = vec![Ok(message_start())];
all.extend(events.into_iter().map(Ok));
all.push(Ok(message_delta_with_stop(messages::StopReason::MaxTokens)));
all.push(Ok(MessageStreamEvent::MessageStop));
let raw = stream::iter(all).boxed();
collect(stream_messages(raw, None, rid(), Duration::from_secs(60))).await
}
/// Complete tool calls before a max_tokens stop complete the turn as ToolCalls.
#[tokio::test]
async fn max_tokens_after_complete_tool_calls_completes_as_tool_calls() {
let evs = max_tokens_after(vec![
tool_use_start(0, "call_done"),
arg_delta(0, "{\"x\": 1}"),
block_stop(0),
])
.await;
match evs.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
assert_eq!(response.stop_reason, Some(StopReason::ToolCalls));
assert_eq!(response.tool_calls().len(), 1);
assert_eq!(response.tool_calls()[0].arguments.as_ref(), "{\"x\": 1}");
}
other => panic!("expected Completed(ToolCalls), got {other:?}"),
}
}
/// A tool_use block with no argument deltas is the zero-argument convention, not truncation.
#[tokio::test]
async fn max_tokens_after_tool_call_without_arguments_completes() {
let evs = max_tokens_after(vec![tool_use_start(0, "call_no_args"), block_stop(0)]).await;
match evs.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
assert_eq!(response.stop_reason, Some(StopReason::ToolCalls));
assert_eq!(response.tool_calls()[0].arguments.as_ref(), "");
}
other => panic!("expected Completed(ToolCalls), got {other:?}"),
}
}
/// A call cut mid-arguments is truncation: the turn fails so the caller can compact.
#[tokio::test]
async fn max_tokens_with_truncated_tool_arguments_fails() {
let evs = max_tokens_after(vec![
tool_use_start(0, "call_ok"),
arg_delta(0, "{}"),
block_stop(0),
tool_use_start(1, "call_cut"),
arg_delta(1, "{\"x\": \"trunc"),
block_stop(1),
])
.await;
assert!(
!evs.iter()
.any(|e| matches!(e, SamplingEvent::Completed { .. })),
"a cut tool call must not complete: {evs:?}"
);
match evs.last().unwrap() {
SamplingEvent::Failed { error, .. } => assert_eq!(
error.kind,
crate::events::SamplingErrorKind::MaxTokensTruncation
),
other => panic!("expected Failed(MaxTokensTruncation), got {other:?}"),
}
}
@@ -239,9 +239,7 @@ impl SamplingError {
SamplingError::InvalidConfiguration(_) => false,
SamplingError::Http(err) => is_retryable_reqwest(err),
SamplingError::Serialization(_) => false,
SamplingError::Api { status, .. } => {
matches!(status.as_u16(), 429 | 500 | 502 | 503 | 504 | 520)
}
SamplingError::Api { status, .. } => is_retryable_api_status(status.as_u16()),
SamplingError::EventStreamError(_) => true,
SamplingError::StreamError { .. } => true,
SamplingError::IdleTimeout { .. } => false,
@@ -342,6 +340,15 @@ pub fn try_parse_stream_error(data: &str) -> Option<SamplingError> {
})
}
/// 429 and any 5xx except Cloudflare 525/526 (origin TLS handshake /
/// invalid certificate — a broken origin certificate never clears on its
/// own). The 5xx sweep covers the Cloudflare edge pages (520-524 origin
/// unreachable/timed out, 530 edge 1xxx) and upstream overload (529),
/// which an outage serves in place of the API's own statuses.
pub fn is_retryable_api_status(status: u16) -> bool {
status == 429 || ((500..=599).contains(&status) && !matches!(status, 525 | 526))
}
/// True when an error message indicates a context-window overflow. Backends report
/// this inconsistently with no stable error code, so we match the message text; it's
/// deterministic (re-sending the same payload always fails), so callers must not retry.
@@ -352,6 +359,7 @@ pub fn is_context_length_error(message: &str) -> bool {
|| m.contains("maximum prompt length")
|| m.contains("maximum context length")
|| m.contains("context_length_exceeded")
|| (m.contains("current message") && m.contains("exceeds budget"))
}
/// Decide whether a [`reqwest::Error`] is worth retrying.
@@ -378,6 +386,27 @@ pub fn is_retryable_reqwest(err: &reqwest::Error) -> bool {
mod tests {
use super::*;
#[test]
fn retryable_api_status_covers_edge_5xx_and_excludes_broken_tls() {
for status in [
429u16, 500, 502, 503, 504, 520, 521, 522, 523, 524, 529, 530, 599,
] {
assert!(is_retryable_api_status(status), "should retry {status}");
}
// Cloudflare 525/526: origin TLS handshake / invalid certificate —
// a broken origin certificate never clears on its own.
for status in [525u16, 526, 400, 401, 403, 404, 408, 422, 200] {
assert!(!is_retryable_api_status(status), "must not retry {status}");
}
let api = SamplingError::Api {
status: StatusCode::from_u16(522).unwrap(),
message: "cloudflare: connection timed out".into(),
model_metadata: None,
retry_after_secs: None,
};
assert!(api.is_retryable(), "522 edge page must be retryable");
}
#[test]
fn context_length_error_matches_backend_variants() {
for msg in [
@@ -387,6 +416,7 @@ mod tests {
"This model's maximum context length is 200000 tokens",
"invalid_request_error: prompt is too long: 300000 tokens > 200000 maximum",
"error type: context_length_exceeded",
"the current message exceeds budget: 120000 < 250000",
] {
assert!(is_context_length_error(msg), "should match: {msg}");
}
@@ -897,7 +897,7 @@ impl CompactionsRemaining {
}
/// Reasoning effort level. `None`/`Minimal` are omitted on the Anthropic Messages API.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ReasoningEffort {
None,
@@ -982,6 +982,16 @@ impl std::str::FromStr for ReasoningEffort {
}
}
impl<'de> serde::Deserialize<'de> for ReasoningEffort {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
raw.parse().map_err(serde::de::Error::custom)
}
}
/// Canonical wire parse; remapped menu ids need a model catalog.
pub fn parse_canonical_effort_token(token: &str) -> Option<ReasoningEffort> {
token.parse().ok()
@@ -1146,7 +1156,7 @@ pub const ANTHROPIC_OAUTH_BETA: &str = "claude-code-20250219,oauth-2025-04-20";
/// User-Agent kigi presents on the Claude-Code OAuth path (mirrors the
/// official Claude Code CLI). OAuth-gated: unrelated to the default kigi UA.
pub const CLAUDE_CODE_USER_AGENT: &str = "claude-cli/2.1.75";
pub const CLAUDE_CODE_USER_AGENT: &str = "claude-cli/2.1.280";
/// System-prompt prefix REQUIRED on the Claude-Code OAuth Messages path: the
/// OAuth token is Claude-Code-scoped, so Anthropic rejects the request unless
@@ -1192,7 +1202,7 @@ pub const CODEX_OPENAI_BETA: &str = "responses=experimental";
/// openai-codex-gated). The Codex backend does not strictly validate the UA
/// string (Pi ships its own and it works), so this is a stable best-effort
/// identity, not a pinned build.
pub const CODEX_USER_AGENT: &str = "codex_cli_rs/0.104.0";
pub const CODEX_USER_AGENT: &str = "codex_cli_rs/0.159.0";
/// JWT payload claim namespace carrying the ChatGPT account id.
const CODEX_JWT_AUTH_CLAIM: &str = "https://api.openai.com/auth";
@@ -1849,6 +1859,24 @@ mod tests {
assert!(serde_json::from_str::<ReasoningEffort>("\"BOGUS\"").is_err());
}
#[test]
fn reasoning_effort_deserializes_mixed_case() {
for (token, want) in [
("\"High\"", ReasoningEffort::High),
("\"XHIGH\"", ReasoningEffort::Xhigh),
("\"mAx\"", ReasoningEffort::Max),
] {
let got: ReasoningEffort = serde_json::from_str(token).unwrap();
assert_eq!(got, want, "deserialize {token}");
}
let err = serde_json::from_str::<ReasoningEffort>("\"Highest\"").unwrap_err();
assert!(
err.to_string().contains("invalid reasoning effort"),
"{err}"
);
assert!(serde_json::from_str::<ReasoningEffort>("7").is_err());
}
#[test]
fn reasoning_effort_from_str_max_and_xhigh_are_distinct() {
assert_eq!(
+1
View File
@@ -51,6 +51,7 @@ enforce = []
[dev-dependencies]
serial_test = { workspace = true }
tempfile = { workspace = true }
# Compiles the hand-rolled macOS deny-glob regex in tests to assert it matches
# EXACTLY the set globset (the Linux backend) matches — the cross-platform parity
# guard for the glob dialect.
@@ -14,9 +14,19 @@
//! cargo run -p kigi-sandbox --example sandbox_smoke_test -- read-only
//! ```
// Kernel enforcement is unix-only (Seatbelt / Landlock); Windows builds a
// stub so `--all-targets` stays green everywhere.
#[cfg(unix)]
use kigi_sandbox::{ProfileName, SandboxManager};
#[cfg(unix)]
use std::path::Path;
#[cfg(not(unix))]
fn main() {
eprintln!("sandbox_smoke_test exercises kernel enforcement and only runs on Unix.");
}
#[cfg(unix)]
fn main() {
let profile_name = std::env::args()
.nth(1)
@@ -121,6 +131,7 @@ fn main() {
println!("\n✅ Smoke test complete");
}
#[cfg(unix)]
fn test_read(label: &str, path: &Path) {
if path.is_file() {
match std::fs::read(path) {
@@ -150,6 +161,7 @@ fn test_read(label: &str, path: &Path) {
}
}
#[cfg(unix)]
fn test_write(label: &str, path: &Path) {
match std::fs::write(path, b"sandbox-test") {
Ok(()) => {
+210 -74
View File
@@ -1,91 +1,130 @@
//! Per-child seccomp network filter. No-op on non-Linux.
/// Install seccomp BPF filter blocking network syscalls.
#[cfg(target_os = "linux")]
mod bpf {
use libc::sock_filter;
pub(super) const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000;
pub(super) const SECCOMP_RET_ERRNO: u32 = 0x0005_0000;
pub(super) const EPERM_VAL: u32 = 1;
#[cfg(target_arch = "x86_64")]
pub(super) const X32_SYSCALL_BIT: u32 = 0x4000_0000;
/// `seccomp_data` field offsets.
pub(super) const OFF_NR: u32 = 0;
pub(super) const OFF_ARCH: u32 = 4;
#[cfg(target_arch = "x86_64")]
pub(super) const EXPECTED_ARCH: u32 = 0xc000_003e; // AUDIT_ARCH_X86_64
#[cfg(target_arch = "aarch64")]
pub(super) const EXPECTED_ARCH: u32 = 0xc000_00b7; // AUDIT_ARCH_AARCH64
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
pub(super) const EXPECTED_ARCH: u32 = 0;
pub(super) fn stmt(code: u32, k: u32) -> sock_filter {
sock_filter {
code: code as u16,
jt: 0,
jf: 0,
k,
}
}
pub(super) fn jump(code: u32, k: u32, jt: u8, jf: u8) -> sock_filter {
sock_filter {
code: code as u16,
jt,
jf,
k,
}
}
/// Reject a foreign-arch (and, on x86_64, x32) syscall number before
/// matching `nr`, so a syscall reached through another table cannot
/// alias past the deny list.
pub(super) fn push_arch_nr_gate(f: &mut Vec<sock_filter>) {
use libc::{BPF_ABS, BPF_JEQ, BPF_JMP, BPF_K, BPF_LD, BPF_RET, BPF_W};
f.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFF_ARCH));
f.push(jump(BPF_JMP | BPF_JEQ | BPF_K, EXPECTED_ARCH, 1, 0));
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
f.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFF_NR));
#[cfg(target_arch = "x86_64")]
{
f.push(jump(
BPF_JMP | libc::BPF_JSET | BPF_K,
X32_SYSCALL_BIT,
0,
1,
));
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
}
}
}
/// Syscalls that open or send on a socket, including the `io_uring` and
/// `sendmmsg` paths that bypass the classic entry points.
#[cfg(target_os = "linux")]
fn child_network_blocked_syscalls() -> [u32; 11] {
use libc::{
SYS_accept, SYS_accept4, SYS_bind, SYS_connect, SYS_io_uring_enter, SYS_io_uring_register,
SYS_io_uring_setup, SYS_listen, SYS_sendmmsg, SYS_sendmsg, SYS_sendto,
};
[
SYS_connect as u32,
SYS_bind as u32,
SYS_sendto as u32,
SYS_sendmsg as u32,
SYS_sendmmsg as u32,
SYS_listen as u32,
SYS_accept as u32,
SYS_accept4 as u32,
SYS_io_uring_setup as u32,
SYS_io_uring_enter as u32,
SYS_io_uring_register as u32,
]
}
#[cfg(target_os = "linux")]
fn build_child_network_filter() -> Vec<libc::sock_filter> {
use bpf::{EPERM_VAL, SECCOMP_RET_ALLOW, SECCOMP_RET_ERRNO, jump, stmt};
use libc::{BPF_JEQ, BPF_JMP, BPF_K, BPF_RET};
let blocked = child_network_blocked_syscalls();
let mut f = Vec::with_capacity(blocked.len() + 10);
bpf::push_arch_nr_gate(&mut f);
let n = blocked.len();
for (i, &sys) in blocked.iter().enumerate() {
let remaining = n - i - 1;
f.push(jump(BPF_JMP | BPF_JEQ | BPF_K, sys, remaining as u8 + 1, 0));
}
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW));
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
f
}
/// Built once: `pre_exec` runs after `fork`, where allocating is unsafe.
#[cfg(target_os = "linux")]
pub fn prebuilt_child_network_filter() -> &'static [libc::sock_filter] {
static FILTER: std::sync::OnceLock<Vec<libc::sock_filter>> = std::sync::OnceLock::new();
FILTER.get_or_init(build_child_network_filter)
}
/// Install the seccomp BPF filter blocking network syscalls.
///
/// # Safety
///
/// Must be called in a `pre_exec` context (after `fork`, before `exec`).
#[cfg(target_os = "linux")]
pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
use libc::{
BPF_ABS, BPF_JEQ, BPF_JMP, BPF_K, BPF_LD, BPF_RET, BPF_W, PR_SET_NO_NEW_PRIVS,
PR_SET_SECCOMP, SECCOMP_MODE_FILTER, SYS_accept, SYS_accept4, SYS_bind, SYS_connect,
SYS_listen, SYS_sendmsg, SYS_sendto, prctl, sock_filter, sock_fprog,
};
const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000;
const SECCOMP_RET_ERRNO: u32 = 0x0005_0000;
// libc::EPERM
const EPERM_VAL: u32 = 1;
macro_rules! bpf_stmt {
($code:expr, $k:expr) => {
sock_filter {
code: $code as u16,
jt: 0,
jf: 0,
k: $k as u32,
}
};
}
macro_rules! bpf_jump {
($code:expr, $k:expr, $jt:expr, $jf:expr) => {
sock_filter {
code: $code as u16,
jt: $jt,
jf: $jf,
k: $k as u32,
}
};
}
// seccomp_data.nr offset
const NR_OFFSET: u32 = 0;
let blocked_syscalls: &[i64] = &[
SYS_connect,
SYS_bind,
SYS_sendto,
SYS_sendmsg,
SYS_listen,
SYS_accept,
SYS_accept4,
];
let mut filter: Vec<sock_filter> = Vec::new();
let total_checks = blocked_syscalls.len();
filter.push(bpf_stmt!(BPF_LD | BPF_W | BPF_ABS, NR_OFFSET));
for (i, &syscall) in blocked_syscalls.iter().enumerate() {
let remaining = total_checks - i - 1;
filter.push(bpf_jump!(
BPF_JMP | BPF_JEQ | BPF_K,
syscall,
// match: jump to ERRNO
remaining as u8 + 1,
// no match: check next
0
));
}
filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ALLOW));
filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
pub unsafe fn install_child_network_filter(filter: &[libc::sock_filter]) -> std::io::Result<()> {
use libc::{PR_SET_NO_NEW_PRIVS, PR_SET_SECCOMP, SECCOMP_MODE_FILTER, prctl, sock_fprog};
let prog = sock_fprog {
len: filter.len() as u16,
filter: filter.as_mut_ptr(),
filter: filter.as_ptr().cast_mut(),
};
// Must set PR_SET_NO_NEW_PRIVS before applying seccomp filter
// SAFETY: prctl with PR_SET_NO_NEW_PRIVS is safe in pre_exec context.
if unsafe { prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
return Err(std::io::Error::last_os_error());
}
// SAFETY: prog is a valid sock_fprog pointing to our filter array.
// SAFETY: prog is a valid sock_fprog pointing to a filter the kernel copies.
if unsafe {
prctl(
PR_SET_SECCOMP,
@@ -98,7 +137,6 @@ pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
{
return Err(std::io::Error::last_os_error());
}
Ok(())
}
@@ -109,3 +147,101 @@ pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
Ok(())
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::bpf::*;
use super::{build_child_network_filter, child_network_blocked_syscalls};
use libc::sock_filter;
/// Minimal classic-BPF interpreter over synthetic `seccomp_data` fields.
fn eval(filter: &[sock_filter], arch: u32, nr: u32) -> u32 {
use libc::{BPF_ABS, BPF_JEQ, BPF_JMP, BPF_JSET, BPF_K, BPF_LD, BPF_RET, BPF_W};
let mut pc = 0usize;
let mut a = 0u32;
for _ in 0..filter.len().saturating_mul(2) {
let insn = &filter[pc];
let op = insn.code as u32;
if op == (BPF_LD | BPF_W | BPF_ABS) {
a = match insn.k {
OFF_NR => nr,
OFF_ARCH => arch,
_ => 0,
};
pc += 1;
} else if op == (BPF_JMP | BPF_JEQ | BPF_K) {
pc = if a == insn.k {
pc + 1 + insn.jt as usize
} else {
pc + 1 + insn.jf as usize
};
} else if op == (BPF_JMP | BPF_JSET | BPF_K) {
pc = if a & insn.k != 0 {
pc + 1 + insn.jt as usize
} else {
pc + 1 + insn.jf as usize
};
} else if op == (BPF_RET | BPF_K) {
return insn.k;
} else {
panic!("unsupported opcode {:#x} at {pc}", insn.code);
}
assert!(pc < filter.len(), "pc out of range");
}
panic!("filter did not RET");
}
fn is_allow(r: u32) -> bool {
r == SECCOMP_RET_ALLOW
}
fn is_eperm(r: u32) -> bool {
r == (SECCOMP_RET_ERRNO | EPERM_VAL)
}
#[test]
fn blocks_every_network_syscall_and_allows_read_and_socket() {
let f = build_child_network_filter();
for sys in child_network_blocked_syscalls() {
assert!(
is_eperm(eval(&f, EXPECTED_ARCH, sys)),
"syscall {sys} must be EPERM"
);
}
assert!(is_allow(eval(&f, EXPECTED_ARCH, libc::SYS_read as u32)));
assert!(is_allow(eval(&f, EXPECTED_ARCH, libc::SYS_socket as u32)));
}
#[test]
fn covers_io_uring_and_sendmmsg() {
let blocked = child_network_blocked_syscalls();
for sys in [
libc::SYS_sendmmsg,
libc::SYS_io_uring_setup,
libc::SYS_io_uring_enter,
libc::SYS_io_uring_register,
] {
assert!(blocked.contains(&(sys as u32)), "{sys} missing");
}
}
#[test]
fn wrong_arch_and_x32_are_denied_before_the_nr_match() {
let f = build_child_network_filter();
assert!(is_eperm(eval(&f, 0xdead_beef, libc::SYS_read as u32)));
#[cfg(target_arch = "x86_64")]
assert!(is_eperm(eval(
&f,
EXPECTED_ARCH,
(libc::SYS_read as u32) | X32_SYSCALL_BIT
)));
}
#[test]
fn prebuilt_filter_is_shared() {
let a = super::prebuilt_child_network_filter();
let b = super::prebuilt_child_network_filter();
assert!(std::ptr::eq(a, b));
assert_eq!(a.len(), build_child_network_filter().len());
}
}
+116 -4
View File
@@ -333,6 +333,27 @@ fn is_devbox_based(profile: &ProfileName, config: &SandboxConfig) -> bool {
_ => false,
}
}
/// Whether an existing `/data` needs the devbox bwrap read-only bind; a filesystem error other than NotFound conservatively requires it.
#[cfg(target_os = "linux")]
pub fn requires_data_write_deny(profile: &ProfileName, workspace: &Path) -> bool {
requires_data_write_deny_for(
profile,
&profiles::load_sandbox_config(workspace),
data_path_requires_bind(Path::new("/data")),
)
}
#[cfg(target_os = "linux")]
fn requires_data_write_deny_for(
profile: &ProfileName,
config: &SandboxConfig,
is_data_present: bool,
) -> bool {
is_devbox_based(profile, config) && is_data_present
}
#[cfg(target_os = "linux")]
fn data_path_requires_bind(path: &Path) -> bool {
path.try_exists().unwrap_or(true)
}
/// Whether kernel read-deny enforcement is required. The single source of truth
/// for this classification so callers (e.g. the shell's fail-closed startup path)
/// cannot drift and silently fail open.
@@ -360,6 +381,23 @@ pub fn requires_read_deny(profile: &ProfileName, workspace: &Path) -> bool {
pub fn requires_read_deny(_profile: &ProfileName, _workspace: &Path) -> bool {
false
}
/// Whether the resolved profile leaves `~/.kigi` read-only outside its
/// state dirs (strict and every profile extending it), so files first use
/// would write there must be minted before enforcement.
#[cfg(all(feature = "enforce", unix))]
pub fn restricts_home_writes(profile: &ProfileName, workspace: &Path) -> bool {
if *profile == ProfileName::Off {
return false;
}
let config = profiles::load_sandbox_config(workspace);
profile
.resolve_profile(workspace, &config)
.is_ok_and(|resolved| !resolved.read_write.contains(&paths::kigi_home()))
}
#[cfg(not(all(feature = "enforce", unix)))]
pub fn restricts_home_writes(_profile: &ProfileName, _workspace: &Path) -> bool {
false
}
/// A profile's resolved bwrap deny plan: read-only mounts (`deny_write`),
/// bound-over unreadable placeholders (`deny_read`), and whether the profile
/// carries deny globs (`has_globs`, so the re-exec proceeds even with zero
@@ -382,7 +420,11 @@ struct BwrapDenyPlan {
#[cfg(all(feature = "enforce", target_os = "linux"))]
fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> {
let config = profiles::load_sandbox_config(workspace);
let deny_write: Vec<String> = if is_devbox_based(profile, &config) {
let deny_write: Vec<String> = if requires_data_write_deny_for(
profile,
&config,
data_path_requires_bind(Path::new("/data")),
) {
vec!["/data".to_string()]
} else {
Vec::new()
@@ -424,7 +466,11 @@ fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyP
#[cfg(all(not(feature = "enforce"), target_os = "linux"))]
fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> {
let config = profiles::load_sandbox_config(workspace);
let deny_write: Vec<String> = if is_devbox_based(profile, &config) {
let deny_write: Vec<String> = if requires_data_write_deny_for(
profile,
&config,
data_path_requires_bind(Path::new("/data")),
) {
vec!["/data".to_string()]
} else {
Vec::new()
@@ -595,6 +641,43 @@ mod tests {
);
}
#[test]
#[cfg(target_os = "linux")]
fn data_write_deny_requires_devbox_and_a_present_data_dir() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".kigi")).unwrap();
std::fs::write(
tmp.path().join(".kigi/sandbox.toml"),
"[profiles.devext]\nextends = \"devbox\"\n\n[profiles.plain]\nextends = \"workspace\"\n",
)
.unwrap();
let config = profiles::load_sandbox_config(tmp.path());
assert!(requires_data_write_deny_for(
&ProfileName::Devbox,
&config,
true
));
assert!(!requires_data_write_deny_for(
&ProfileName::Devbox,
&config,
false
));
assert!(requires_data_write_deny_for(
&ProfileName::Custom("devext".into()),
&config,
true
));
assert!(!requires_data_write_deny_for(
&ProfileName::Custom("plain".into()),
&config,
true
));
assert!(!requires_data_write_deny_for(
&ProfileName::Workspace,
&config,
true
));
}
#[test]
fn configured_profile_is_recorded() {
set_configured_profile("read-only");
assert_eq!(configured_profile_name(), Some("read-only"));
@@ -700,10 +783,11 @@ mod tests {
"devbox-empty",
"[profiles.devempty]\nextends = \"devbox\"\n",
);
assert!(
assert_eq!(
bwrap_reexec_for_profile(&ProfileName::Custom("devempty".to_string()), &ws_empty)
.is_some(),
"devbox-extending custom must compose the /data write-deny re-exec"
Path::new("/data").exists(),
"devbox-extending custom composes the /data write-deny re-exec exactly when /data exists"
);
let _ = std::fs::remove_dir_all(&ws_empty);
let ws_ws = temp_workspace_with_sandbox_toml(
@@ -717,3 +801,31 @@ mod tests {
let _ = std::fs::remove_dir_all(&ws_ws);
}
}
#[cfg(all(test, feature = "enforce", unix))]
mod home_write_tests {
use super::{ProfileName, restricts_home_writes};
#[test]
fn strict_restricts_home_writes_and_workspace_does_not() {
let workspace = std::env::temp_dir();
assert!(restricts_home_writes(&ProfileName::Strict, &workspace));
assert!(!restricts_home_writes(&ProfileName::Workspace, &workspace));
assert!(!restricts_home_writes(&ProfileName::ReadOnly, &workspace));
assert!(!restricts_home_writes(&ProfileName::Off, &workspace));
}
#[test]
fn custom_profile_extending_strict_inherits_the_restriction() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join(".kigi");
std::fs::create_dir_all(&project).unwrap();
std::fs::write(
project.join("sandbox.toml"),
"[profiles.audit-home-write-test]\nextends = \"strict\"\n",
)
.unwrap();
let custom = ProfileName::Custom("audit-home-write-test".to_string());
assert!(restricts_home_writes(&custom, tmp.path()));
}
}
+6 -2
View File
@@ -1,7 +1,7 @@
//! Sandbox event logger.
//!
//! Events (profile applied, violations, bypasses) are buffered in memory and
//! flushed as JSONL to `~/.kigi/sandbox-events.jsonl`.
//! flushed as JSONL to `~/.kigi/sessions/sandbox-events.jsonl`.
use std::path::PathBuf;
use std::sync::Mutex;
@@ -87,8 +87,12 @@ impl SandboxLogger {
Ok(())
}
/// Under `sessions/`: the only home subtree a strict sandbox leaves
/// writable, and this flush runs after enforcement.
fn log_file_path() -> PathBuf {
kigi_config::kigi_home().join("sandbox-events.jsonl")
kigi_config::kigi_home()
.join("sessions")
.join("sandbox-events.jsonl")
}
}
+31
View File
@@ -96,6 +96,37 @@ pub(crate) fn essential_writable_paths(workspace: &Path) -> Vec<PathBuf> {
paths
}
/// Home subtrees the process keeps writing after enforcement: session and
/// worktree state, memory, diagnostics. Landlock cannot carve a child out of
/// a write grant, so the rest of `~/.kigi` (`config.toml`, `auth.json`,
/// `sandbox.toml`, `hooks/`, `bin/`, `vendor/`, `plugin-data/`) stays
/// read-only and a sandboxed child cannot arm the next launch.
#[cfg(all(feature = "enforce", unix))]
const HOME_STATE_DIRS: &[&str] = &[
"run",
"sessions",
"worktrees",
"worktree_pool",
"tasks",
"memory",
"logs",
"debug",
"crash",
"memtrace",
"trace-exports",
];
/// Writable paths for the strict profile: the workspace, the home state
/// subtrees, and temp.
#[cfg(all(feature = "enforce", unix))]
pub(crate) fn essential_writable_paths_strict(workspace: &Path) -> Vec<PathBuf> {
let home = kigi_home();
let mut paths = vec![workspace.to_path_buf()];
paths.extend(HOME_STATE_DIRS.iter().map(|dir| home.join(dir)));
paths.extend(temp_writable_paths());
paths
}
/// Writable directory paths for the read-only profile (minimal: just ~/.kigi + temp).
/// Device files are handled separately via `allow_file` in `to_capability_set_with_config`.
#[cfg(all(feature = "enforce", unix))]
+113 -6
View File
@@ -18,6 +18,7 @@ use crate::paths::kigi_home;
#[cfg(all(feature = "enforce", unix))]
use crate::paths::{
DEVICE_DIRS, DEVICE_FILES, essential_writable_paths, essential_writable_paths_minimal,
essential_writable_paths_strict,
};
/// A resolved sandbox profile ready to be converted to a `CapabilitySet`.
@@ -186,6 +187,32 @@ fn load_config_file(path: &Path) -> Option<SandboxConfig> {
#[cfg(all(feature = "enforce", unix))]
impl ProfileName {
/// Convert this profile into a nono `CapabilitySet` for the given workspace.
/// The directory a `read_write` entry really grants. A symlink under
/// `~/.kigi` is followed only to the default `~/.kigi/sessions` (the
/// `KIGI_SHARE_DIR` override case); any other target would widen the
/// grant to wherever the link points. Missing paths are created.
fn read_write_grant_path(path: &Path, home: &Path) -> Option<PathBuf> {
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.file_type().is_symlink() => {
if path == home || !path.starts_with(home) {
return Some(path.to_path_buf());
}
let real = dunce::canonicalize(path).ok()?;
let default_sessions = kigi_config::default_kigi_home().join("sessions");
(real.is_dir()
&& path.file_name() == Some(std::ffi::OsStr::new("sessions"))
&& real == default_sessions)
.then_some(real)
}
Ok(_) => Some(path.to_path_buf()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
std::fs::create_dir_all(path).ok()?;
Some(path.to_path_buf())
}
Err(_) => None,
}
}
pub fn to_capability_set(&self, workspace: &Path) -> anyhow::Result<CapabilitySet> {
let config = load_sandbox_config(workspace);
self.to_capability_set_with_config(workspace, &config)
@@ -231,13 +258,14 @@ impl ProfileName {
// apply time (it opens an O_PATH fd), but new files within it can
// be created freely after the sandbox is applied. Pre-create
// directories like ~/.kigi/ that may not exist on first run.
let home = kigi_home();
for path in &profile.read_write {
if !path.exists() && std::fs::create_dir_all(path).is_err() {
tracing::warn!(path = ?path, "read_write path does not exist and could not be created, skipping");
let Some(grant) = Self::read_write_grant_path(path, &home) else {
tracing::warn!(path = ?path, "read_write grant skipped");
continue;
}
let Some(path_str) = path.to_str() else {
tracing::warn!(path = ?path, "Skipping non-UTF8 read_write path");
};
let Some(path_str) = grant.to_str() else {
tracing::warn!(path = ?grant, "Skipping non-UTF8 read_write path");
continue;
};
caps = caps.allow_path(path_str, AccessMode::ReadWrite)?;
@@ -380,12 +408,15 @@ impl ProfileName {
.chain(std::iter::once(home.join("Library")))
.filter(|p| p.exists())
.chain(std::iter::once(workspace.to_path_buf()))
// Read-only: config, auth and global hooks are read after
// enforcement; only sessions/ below stays writable.
.chain(std::iter::once(kigi_home()))
.collect();
Ok(SandboxProfile {
name: "strict".to_string(),
read_only: system_read,
read_write: essential_writable_paths(workspace),
read_write: essential_writable_paths_strict(workspace),
deny: vec![],
default_read: false,
restrict_network: true,
@@ -544,6 +575,82 @@ mod tests {
}
}
#[test]
#[cfg(all(feature = "enforce", unix))]
fn strict_read_write_is_workspace_home_state_and_temp() {
let workspace = std::env::temp_dir();
let profile = ProfileName::Strict
.resolve_profile(&workspace, &SandboxConfig::default())
.expect("strict resolves");
let home = kigi_home();
assert_eq!(profile.read_write[0], workspace);
for dir in ["sessions", "worktrees", "memory", "logs", "debug"] {
assert!(
profile.read_write.contains(&home.join(dir)),
"{dir} must stay writable: {:?}",
profile.read_write
);
}
for denied in [
home.clone(),
home.join("hooks"),
home.join("vendor"),
home.join("bin"),
] {
assert!(
!profile.read_write.contains(&denied),
"strict must not grant {}: {:?}",
denied.display(),
profile.read_write
);
}
assert!(
profile.read_only.contains(&home),
"config/auth/hooks are read after enforcement: {:?}",
profile.read_only
);
}
#[test]
#[cfg(all(feature = "enforce", unix))]
fn read_write_grant_follows_real_dirs_and_creates_missing_ones() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
std::fs::create_dir_all(home.join("sessions")).unwrap();
assert_eq!(
ProfileName::read_write_grant_path(&home.join("sessions"), &home),
Some(home.join("sessions"))
);
let missing = home.join("fresh");
assert_eq!(
ProfileName::read_write_grant_path(&missing, &home),
Some(missing.clone())
);
assert!(missing.is_dir());
}
#[test]
#[cfg(all(feature = "enforce", unix))]
fn read_write_grant_refuses_a_home_symlink_pointing_elsewhere() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let outside = tmp.path().join("outside");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::os::unix::fs::symlink(&outside, home.join("sessions")).unwrap();
assert_eq!(
ProfileName::read_write_grant_path(&home.join("sessions"), &home),
None
);
// A symlinked workspace outside the home is the user's own choice.
let ws_link = tmp.path().join("ws");
std::os::unix::fs::symlink(&outside, &ws_link).unwrap();
assert_eq!(
ProfileName::read_write_grant_path(&ws_link, &home),
Some(ws_link.clone())
);
}
#[test]
#[cfg(all(feature = "enforce", unix))]
fn base_profile_capability_set_builds() {
+21 -185
View File
@@ -544,13 +544,16 @@ mod attachments_protocol {
}
}
#[cfg(any(target_os = "macos", test))]
mod osascript_probe;
// macOS: subprocess-based clipboard (no AppKit linkage)
#[cfg(target_os = "macos")]
mod platform {
use std::process::{Command, Stdio};
use std::sync::OnceLock;
use super::attachments_protocol::{FURL_MARKER, IMAGE_MARKER, parse_attachments_output};
use super::osascript_probe::{ProbeTemps, osascript_attachments, read_probe_raster};
use super::{ClipboardAttachments, ImageData};
// Fast pasteboard probes (NSPasteboard via lazy dlopen)
@@ -759,153 +762,15 @@ mod platform {
Ok(output.stdout)
}
fn attachments_probe_temp_paths() -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf)
{
let temp_dir = std::env::temp_dir();
(
temp_dir.join("kigi-clipboard-probe.png"),
temp_dir.join("kigi-clipboard-probe.tiff"),
temp_dir.join("kigi-clipboard-probe.jpg"),
)
}
fn read_clipboard_image_from_class(
class: &str,
path_png: &std::path::Path,
path_tiff: &std::path::Path,
path_jpg: &std::path::Path,
) -> anyhow::Result<Option<ImageData>> {
let (temp_path, mime) = match class {
"PNGf" => (path_png, "image/png"),
"TIFF" => (path_tiff, "image/tiff"),
"JPEG" => (path_jpg, "image/jpeg"),
_ => return Ok(None),
};
let data = match std::fs::read(temp_path) {
Ok(bytes) if !bytes.is_empty() => bytes,
Ok(_) => {
let _ = std::fs::remove_file(temp_path);
return Ok(None);
}
Err(e) => {
let _ = std::fs::remove_file(temp_path);
return Err(anyhow::anyhow!("failed to read clipboard temp file: {e}"));
}
};
let _ = std::fs::remove_file(temp_path);
Ok(Some(ImageData {
data,
mime_type: mime.to_owned(),
}))
}
fn remove_attachment_probe_temps(
path_png: &std::path::Path,
path_tiff: &std::path::Path,
path_jpg: &std::path::Path,
) {
let _ = std::fs::remove_file(path_png);
let _ = std::fs::remove_file(path_tiff);
let _ = std::fs::remove_file(path_jpg);
}
fn attachments_osascript(
path_png: &std::path::Path,
path_tiff: &std::path::Path,
path_jpg: &std::path::Path,
) -> String {
format!(
"set furlOut to \"none\"\n\
try\n\
set urlList to the clipboard as list\n\
set out to \"\"\n\
repeat with u in urlList\n\
try\n\
set itemRef to contents of u as \u{00AB}class furl\u{00BB}\n\
set out to out & POSIX path of itemRef & \"\\n\"\n\
end try\n\
end repeat\n\
if out is not \"\" then\n\
set furlOut to out\n\
else\n\
try\n\
set urlRef to the clipboard as \u{00AB}class furl\u{00BB}\n\
set furlOut to POSIX path of urlRef\n\
on error\n\
end try\n\
end if\n\
on error\n\
try\n\
set urlRef to the clipboard as \u{00AB}class furl\u{00BB}\n\
set furlOut to POSIX path of urlRef\n\
on error\n\
end try\n\
end try\n\
set imageOut to \"NONE\"\n\
if furlOut is \"none\" then\n\
try\n\
set imgData to the clipboard as \u{00AB}class PNGf\u{00BB}\n\
set filePath to POSIX file \"{png}\" as text\n\
set fRef to open for access file filePath with write permission\n\
set eof of fRef to 0\n\
write imgData to fRef\n\
close access fRef\n\
set imageOut to \"PNGf\"\n\
on error\n\
try\n\
set imgData to the clipboard as \u{00AB}class TIFF\u{00BB}\n\
set filePath to POSIX file \"{tiff}\" as text\n\
set fRef to open for access file filePath with write permission\n\
set eof of fRef to 0\n\
write imgData to fRef\n\
close access fRef\n\
set imageOut to \"TIFF\"\n\
on error\n\
try\n\
set imgData to the clipboard as \u{00AB}class JPEG\u{00BB}\n\
set filePath to POSIX file \"{jpg}\" as text\n\
set fRef to open for access file filePath with write permission\n\
set eof of fRef to 0\n\
write imgData to fRef\n\
close access fRef\n\
set imageOut to \"JPEG\"\n\
on error\n\
end try\n\
end try\n\
end try\n\
end if\n\
return \"{furl_marker}\" & linefeed & furlOut & linefeed & \"{image_marker}\" & linefeed & \"IMAGE:\" & imageOut",
png = path_png.display(),
tiff = path_tiff.display(),
jpg = path_jpg.display(),
furl_marker = FURL_MARKER,
image_marker = IMAGE_MARKER,
)
}
fn run_attachments_osascript() -> anyhow::Result<String> {
let (path_png, path_tiff, path_jpg) = attachments_probe_temp_paths();
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
let script = attachments_osascript(&path_png, &path_tiff, &path_jpg);
fn run_osascript(script: &str) -> anyhow::Result<Vec<u8>> {
let mut cmd = Command::new("osascript");
cmd.arg("-e")
.arg(&script)
.arg(script)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
kigi_tools::util::detach_std_command(&mut cmd);
let stdout = match checked_command_stdout("osascript", cmd.output()) {
Ok(stdout) => stdout,
Err(error) => {
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
return Err(error);
}
};
Ok(String::from_utf8_lossy(&stdout).into_owned())
checked_command_stdout("osascript", cmd.output())
}
/// Unified furl-then-image pasteboard probe.
@@ -923,28 +788,7 @@ mod platform {
image: Some(image),
});
}
let raw = run_attachments_osascript()?;
if raw.trim().is_empty() {
return Ok(ClipboardAttachments::default());
}
let (path_png, path_tiff, path_jpg) = attachments_probe_temp_paths();
let (file_urls, image_class) = parse_attachments_output(&raw);
let image = if file_urls.is_some() {
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
None
} else {
match image_class {
Some(class) => {
read_clipboard_image_from_class(class, &path_png, &path_tiff, &path_jpg)?
}
None => {
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
None
}
}
};
Ok(ClipboardAttachments { file_urls, image })
osascript_attachments(ProbeTemps::new(), run_osascript)
}
/// Read text via `pbpaste -Prefer txt`.
@@ -1020,8 +864,8 @@ mod platform {
return Ok(Some(image));
}
let (path_png, path_tiff, path_jpg) = attachments_probe_temp_paths();
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
let temps = ProbeTemps::new()?;
let (path_png, path_tiff, path_jpg) = temps.paths();
// Image-only AppleScript (ImageOnly paste route). Unicode guillemets
// (\u{AB}/\u{BB}) are required for `«class …»` in `osascript -e`.
@@ -1069,20 +913,13 @@ mod platform {
.stdout(Stdio::piped())
.stderr(Stdio::null());
kigi_tools::util::detach_std_command(&mut cmd);
let stdout = match checked_command_stdout("osascript", cmd.output()) {
Ok(stdout) => stdout,
Err(error) => {
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
return Err(error);
}
};
let stdout = checked_command_stdout("osascript", cmd.output())?;
let class = String::from_utf8_lossy(&stdout);
let class = class.trim();
if class == "none" {
remove_attachment_probe_temps(&path_png, &path_tiff, &path_jpg);
return Ok(None);
}
read_clipboard_image_from_class(class, &path_png, &path_tiff, &path_jpg)
read_probe_raster(class, &temps)
}
/// Read file URLs from the macOS pasteboard via `osascript`.
@@ -1164,6 +1001,7 @@ mod platform {
#[cfg(not(target_os = "macos"))]
mod platform {
use super::ImageData;
#[cfg(target_os = "linux")]
use std::process::{Command, Stdio};
/// No subprocess-free pasteboard probe exists off-macOS.
@@ -1926,19 +1764,18 @@ mod platform {
// Public API
pub fn get_text() -> anyhow::Result<Option<String>> {
let mut arboard_error = None;
match arboard_get_text() {
let arboard_error = match arboard_get_text() {
Ok(Some(text)) => return Ok(Some(text)),
// Wayland-only: arboard's empty answer is not authoritative,
// fall through to wl-paste (see `wayland_tool_selected`).
#[cfg(target_os = "linux")]
Ok(None) if wayland_tool_selected(linux_tool_spec()) => {}
Ok(None) if wayland_tool_selected(linux_tool_spec()) => None,
Ok(None) => return Ok(None),
Err(e) => {
tracing::debug!("arboard get_text failed: {e}");
arboard_error = Some(e);
Some(e)
}
}
};
#[cfg(target_os = "linux")]
if let Some(spec) = linux_tool_spec() {
let bytes = run_capture_out_checked(spec.read_text, CLI_READ_WAIT)?;
@@ -2039,19 +1876,18 @@ mod platform {
}
pub fn get_image() -> anyhow::Result<Option<ImageData>> {
let mut arboard_error = None;
match arboard_get_image() {
let arboard_error = match arboard_get_image() {
Ok(Some(image)) => return Ok(Some(image)),
// Wayland-only: arboard's empty answer is not authoritative,
// fall through to wl-paste (see `wayland_tool_selected`).
#[cfg(target_os = "linux")]
Ok(None) if wayland_tool_selected(linux_tool_spec()) => {}
Ok(None) if wayland_tool_selected(linux_tool_spec()) => None,
Ok(None) => return Ok(None),
Err(e) => {
tracing::debug!("arboard get_image failed: {e}");
arboard_error = Some(e);
Some(e)
}
}
};
#[cfg(target_os = "linux")]
if let Some(spec) = linux_tool_spec()
&& let Some(argv) = spec.read_png
@@ -0,0 +1,246 @@
//! The macOS `osascript` attachment probe: scratch files, script text, and the read flow.
use std::path::PathBuf;
use super::attachments_protocol::{FURL_MARKER, IMAGE_MARKER, parse_attachments_output};
use super::{ClipboardAttachments, ImageData};
/// Private 0700 dir, removed on drop: a shared path let a concurrent kigi swap the raster.
pub(crate) struct ProbeTemps(tempfile::TempDir);
impl ProbeTemps {
pub(crate) fn new() -> anyhow::Result<Self> {
let mut builder = tempfile::Builder::new();
builder.prefix("kigi-clipboard-probe-");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
builder.permissions(std::fs::Permissions::from_mode(0o700));
}
builder
.tempdir()
.map(Self)
.map_err(|e| anyhow::anyhow!("failed to create clipboard probe dir: {e}"))
}
/// PNG, TIFF, JPEG: the AppleScript writes the first class that coerces.
pub(crate) fn paths(&self) -> (PathBuf, PathBuf, PathBuf) {
(
self.0.path().join("probe.png"),
self.0.path().join("probe.tiff"),
self.0.path().join("probe.jpg"),
)
}
}
/// File URLs first, a raster only when none are present; without `temps` the raster half is skipped.
pub(crate) fn attachments_osascript(temps: Option<&ProbeTemps>) -> String {
let image_probe = temps.map_or_else(String::new, raster_half);
format!(
"set furlOut to \"none\"\n\
try\n\
set urlList to the clipboard as list\n\
set out to \"\"\n\
repeat with u in urlList\n\
try\n\
set itemRef to contents of u as \u{00AB}class furl\u{00BB}\n\
set out to out & POSIX path of itemRef & \"\\n\"\n\
end try\n\
end repeat\n\
if out is not \"\" then\n\
set furlOut to out\n\
else\n\
try\n\
set urlRef to the clipboard as \u{00AB}class furl\u{00BB}\n\
set furlOut to POSIX path of urlRef\n\
on error\n\
end try\n\
end if\n\
on error\n\
try\n\
set urlRef to the clipboard as \u{00AB}class furl\u{00BB}\n\
set furlOut to POSIX path of urlRef\n\
on error\n\
end try\n\
end try\n\
set imageOut to \"NONE\"\n\
{image_probe}\
return \"{furl_marker}\" & linefeed & furlOut & linefeed & \"{image_marker}\" & linefeed & \"IMAGE:\" & imageOut",
furl_marker = FURL_MARKER,
image_marker = IMAGE_MARKER,
)
}
fn raster_half(temps: &ProbeTemps) -> String {
let (path_png, path_tiff, path_jpg) = temps.paths();
format!(
"if furlOut is \"none\" then\n\
try\n\
set imgData to the clipboard as \u{00AB}class PNGf\u{00BB}\n\
set filePath to POSIX file \"{png}\" as text\n\
set fRef to open for access file filePath with write permission\n\
set eof of fRef to 0\n\
write imgData to fRef\n\
close access fRef\n\
set imageOut to \"PNGf\"\n\
on error\n\
try\n\
set imgData to the clipboard as \u{00AB}class TIFF\u{00BB}\n\
set filePath to POSIX file \"{tiff}\" as text\n\
set fRef to open for access file filePath with write permission\n\
set eof of fRef to 0\n\
write imgData to fRef\n\
close access fRef\n\
set imageOut to \"TIFF\"\n\
on error\n\
try\n\
set imgData to the clipboard as \u{00AB}class JPEG\u{00BB}\n\
set filePath to POSIX file \"{jpg}\" as text\n\
set fRef to open for access file filePath with write permission\n\
set eof of fRef to 0\n\
write imgData to fRef\n\
close access fRef\n\
set imageOut to \"JPEG\"\n\
on error\n\
end try\n\
end try\n\
end try\n\
end if\n",
png = path_png.display(),
tiff = path_tiff.display(),
jpg = path_jpg.display(),
)
}
/// The class on stdout picks the file and MIME; an empty file means no raster.
pub(crate) fn read_probe_raster(
class: &str,
temps: &ProbeTemps,
) -> anyhow::Result<Option<ImageData>> {
let (path_png, path_tiff, path_jpg) = temps.paths();
let (temp_path, mime) = match class {
"PNGf" => (path_png, "image/png"),
"TIFF" => (path_tiff, "image/tiff"),
"JPEG" => (path_jpg, "image/jpeg"),
_ => return Ok(None),
};
let data = std::fs::read(&temp_path)
.map_err(|e| anyhow::anyhow!("failed to read clipboard temp file: {e}"))?;
if data.is_empty() {
return Ok(None);
}
Ok(Some(ImageData {
data,
mime_type: mime.to_owned(),
}))
}
/// `run` executes a script and returns its stdout; a file paste needs no scratch dir.
pub(crate) fn osascript_attachments(
temps: anyhow::Result<ProbeTemps>,
run: impl FnOnce(&str) -> anyhow::Result<Vec<u8>>,
) -> anyhow::Result<ClipboardAttachments> {
if let Err(error) = &temps {
tracing::warn!(%error, "clipboard probe reads file URLs only");
}
let stdout = run(&attachments_osascript(temps.as_ref().ok()))?;
let (file_urls, image_class) = parse_attachments_output(&String::from_utf8_lossy(&stdout));
if file_urls.is_some() {
return Ok(ClipboardAttachments {
file_urls,
image: None,
});
}
let temps = temps?;
let image = match image_class {
Some(class) => read_probe_raster(class, &temps)?,
None => None,
};
Ok(ClipboardAttachments {
file_urls: None,
image,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn attachments_stdout(furl: &str, image: &str) -> Vec<u8> {
format!("{FURL_MARKER}\n{furl}\n{IMAGE_MARKER}\nIMAGE:{image}").into_bytes()
}
#[test]
fn two_probes_never_share_a_path() {
let first = ProbeTemps::new().unwrap();
let second = ProbeTemps::new().unwrap();
assert_ne!(first.paths().0, second.paths().0);
}
#[test]
fn scratch_dir_goes_away_with_the_probe() {
let temps = ProbeTemps::new().unwrap();
let (png, tiff, jpg) = temps.paths();
let dir = png.parent().unwrap().to_path_buf();
assert_eq!(tiff.parent().unwrap(), dir);
assert_eq!(jpg.parent().unwrap(), dir);
std::fs::write(&png, b"raster").unwrap();
drop(temps);
assert!(!dir.exists(), "{} must be removed", dir.display());
}
#[cfg(unix)]
#[test]
fn scratch_dir_is_private() {
use std::os::unix::fs::PermissionsExt;
let temps = ProbeTemps::new().unwrap();
let dir = temps.paths().0.parent().unwrap().to_path_buf();
let mode = std::fs::metadata(dir).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700);
}
#[test]
fn reported_class_is_read_from_this_probes_file() {
let temps = ProbeTemps::new().unwrap();
let (_png, tiff, _jpg) = temps.paths();
std::fs::write(&tiff, b"MM\x00\x2a").unwrap();
let script_names_the_file = tiff.display().to_string();
let read = osascript_attachments(Ok(temps), |script| {
assert!(script.contains(&script_names_the_file), "{script}");
Ok(attachments_stdout("none", "TIFF"))
})
.unwrap();
let image = read.image.expect("raster attached");
assert_eq!(image.mime_type, "image/tiff");
assert_eq!(image.data, b"MM\x00\x2a".to_vec());
assert_eq!(read.file_urls, None);
}
#[test]
fn file_urls_survive_a_missing_scratch_dir() {
let read = osascript_attachments(Err(anyhow::anyhow!("no scratch dir")), |script| {
assert!(
!script.contains("open for access"),
"raster half must be skipped"
);
Ok(attachments_stdout("/tmp/a.txt\n", "NONE"))
})
.unwrap();
assert_eq!(read.file_urls.as_deref(), Some("/tmp/a.txt"));
assert_eq!(read.image, None);
}
#[test]
fn no_scratch_dir_and_no_file_url_is_an_error_not_an_empty_board() {
let err = osascript_attachments(Err(anyhow::anyhow!("no scratch dir")), |_| {
Ok(attachments_stdout("none", "NONE"))
})
.unwrap_err();
assert!(err.to_string().contains("no scratch dir"), "{err}");
}
}
@@ -26,7 +26,7 @@ use std::io::{self, Write};
use std::path::Path;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
/// Creates or opens a file with secure permissions (owner read/write only).
///
@@ -58,17 +58,20 @@ pub fn write_secure_file(path: &Path, contents: &[u8]) -> io::Result<()> {
file.write_all(contents)?;
file.flush()?;
// On Windows, we need to set permissions after file creation
#[cfg(windows)]
{
set_windows_secure_permissions(path)?;
}
// Re-assert owner-only bits: `OpenOptions::mode` only applies on create,
// so an existing world-readable file would otherwise keep open perms.
ensure_owner_only_permissions(path)?;
Ok(())
}
/// Opens a file for writing with secure permissions set during creation (Unix)
/// or prepares it for permission setting after creation (Windows).
///
/// Callers that write secret material should also call
/// [`ensure_owner_only_permissions`] after the write (or use
/// [`write_secure_file`]), because `mode(0o600)` only applies when the file
/// is newly created — not when truncating an existing path.
pub fn open_secure_file(path: &Path) -> io::Result<File> {
let mut options = OpenOptions::new();
options.truncate(true).write(true).create(true);
@@ -82,6 +85,44 @@ pub fn open_secure_file(path: &Path) -> io::Result<File> {
options.open(path)
}
/// Ensure `path` is owner-read/write only (Unix `0o600` / Windows user ACL).
///
/// Best-effort on missing files (`NotFound` is ignored). Other errors
/// propagate so callers can fail closed when tightening a secret store.
///
/// Use on **load** of credential files so a hand-copied or restored
/// world-readable `auth.json` is tightened before the process continues.
pub fn ensure_owner_only_permissions(path: &Path) -> io::Result<()> {
match ensure_owner_only_permissions_inner(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
fn ensure_owner_only_permissions_inner(path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
let metadata = std::fs::metadata(path)?;
let mode = metadata.permissions().mode();
if mode & 0o777 != 0o600 {
let mut perms = metadata.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(path, perms)?;
}
Ok(())
}
#[cfg(windows)]
{
set_windows_secure_permissions(path)
}
#[cfg(not(any(unix, windows)))]
{
let _ = path;
Ok(())
}
}
/// Sets Windows-specific secure permissions on a file.
///
/// This function modifies the file's ACL to:
@@ -219,8 +260,6 @@ mod tests {
#[cfg(unix)]
#[test]
fn test_unix_permissions() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test_perms.txt");
@@ -231,4 +270,43 @@ mod tests {
// Check that only owner has read/write (0o600), ignoring file type bits
assert_eq!(mode & 0o777, 0o600);
}
#[cfg(unix)]
#[test]
fn ensure_owner_only_tightens_world_readable_file() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("loose.txt");
fs::write(&file_path, b"secret").unwrap();
let mut loose = fs::metadata(&file_path).unwrap().permissions();
loose.set_mode(0o644);
fs::set_permissions(&file_path, loose).unwrap();
assert_eq!(
fs::metadata(&file_path).unwrap().permissions().mode() & 0o777,
0o644
);
ensure_owner_only_permissions(&file_path).unwrap();
assert_eq!(
fs::metadata(&file_path).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[cfg(unix)]
#[test]
fn write_secure_file_tightens_existing_world_readable_file() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("existing.txt");
fs::write(&file_path, b"old").unwrap();
let mut loose = fs::metadata(&file_path).unwrap().permissions();
loose.set_mode(0o666);
fs::set_permissions(&file_path, loose).unwrap();
write_secure_file(&file_path, b"new secret").unwrap();
assert_eq!(
fs::metadata(&file_path).unwrap().permissions().mode() & 0o777,
0o600
);
assert_eq!(fs::read_to_string(&file_path).unwrap(), "new secret");
}
}
+1
View File
@@ -150,6 +150,7 @@ fs2 = { workspace = true }
zstd = { workspace = true }
tar = { workspace = true }
url = { workspace = true }
kigi-secrets = { path = "../kigi-secrets" }
walkdir = { workspace = true }
tdigests = "1.0"
semver = { workspace = true }
+2 -2
View File
@@ -2101,7 +2101,7 @@ kigi --sandbox strict
| `off` (default) | Unrestricted | Unrestricted | Unrestricted | No sandbox |
| `workspace` | Everywhere | CWD + `/tmp` + `~/.kigi/` | Allowed | Normal development |
| `read-only` | Everywhere | `~/.kigi/` only | Blocked | Exploration, code review |
| `strict` | CWD + system paths | CWD + `/tmp` + `~/.kigi/` | Blocked | Untrusted code |
| `strict` | CWD + system paths | CWD + `/tmp` + `~/.kigi/` state dirs | Blocked | Untrusted code |
Sensitive paths (`~/.ssh/`, `~/.aws/`, `~/.gnupg/`, `~/.kigi/auth/`) are always
write-protected regardless of profile.
@@ -2160,7 +2160,7 @@ model cannot convince the agent to relax restrictions at runtime.
### Event Logging
Sandbox events (profile applied, violations) are logged to `~/.kigi/sandbox-events.jsonl`
Sandbox events (profile applied, violations) are logged to `~/.kigi/sessions/sandbox-events.jsonl`
for telemetry and debugging.
---
@@ -114,6 +114,7 @@ pub struct BuiltAuthMethods {
/// `PlatformId::ALL` order — interactive logins after `kimi-code`
/// 5. every API-key registry platform, in `PlatformId::ALL` order
/// (`moonshot-cn`, `moonshot-ai`, …), always advertised
/// 6. the two custom provider rows (`custom-openai`, `custom-anthropic`)
///
/// The platform methods are for the INTERACTIVE login picker only: they come
/// after `kimi-code` so they can never become `auth_methods.first()` (the
@@ -174,6 +175,7 @@ pub fn build_auth_methods(inputs: AuthMethodsBuildInputs<'_>) -> BuiltAuthMethod
methods.push(platform_auth_method(platform));
}
}
methods.extend(crate::agent::custom_providers::picker_methods());
BuiltAuthMethods {
methods,
@@ -189,6 +191,8 @@ pub enum AuthMethodKind {
KimiCode,
/// Registry API-key platform login (method id = the platform id).
ApiKeyPlatform(kigi_models::PlatformId),
/// A saved custom provider's login (method id = `custom:<name>`).
CustomProvider,
/// Generic device-code OAuth platform login (method id = the platform id,
/// e.g. `xai-grok`). Interactive, like Kimi Code.
OAuthPlatform(kigi_models::PlatformId),
@@ -201,6 +205,9 @@ impl AuthMethodKind {
XAI_API_KEY_METHOD_ID => Self::XaiApiKey,
CACHED_TOKEN_AUTH_METHOD_ID => Self::CachedToken,
KIMI_CODE_METHOD_ID => Self::KimiCode,
other if crate::agent::custom_providers::login_provider_name(other).is_some() => {
Self::CustomProvider
}
other => match kigi_models::PlatformId::parse(other) {
Some(p) if p.oauth().is_some() => Self::OAuthPlatform(p),
Some(p) if !p.uses_oauth() => Self::ApiKeyPlatform(p),
@@ -213,7 +220,10 @@ impl AuthMethodKind {
/// The registry platform methods qualify — they validate a configured
/// platform key and then behave exactly like an external-API-key session.
pub fn is_api_key(self) -> bool {
matches!(self, Self::XaiApiKey | Self::ApiKeyPlatform(_))
matches!(
self,
Self::XaiApiKey | Self::ApiKeyPlatform(_) | Self::CustomProvider
)
}
/// `true` for session-based methods (cached_token, interactive login).
@@ -589,6 +599,23 @@ mod tests {
);
}
/// `custom:<name>` logs in; picker rows cannot.
#[test]
fn custom_provider_method_ids_classify_as_api_key() {
let kind = AuthMethodKind::from_id(&acp::AuthMethodId::new("custom:my-proxy"));
assert_eq!(kind, AuthMethodKind::CustomProvider);
assert!(kind.is_api_key());
assert!(!kind.is_session_based());
assert!(!kind.needs_interactive_login());
for picker in ["custom-openai", "custom-anthropic"] {
assert_eq!(
AuthMethodKind::from_id(&acp::AuthMethodId::new(picker)),
AuthMethodKind::Unknown,
"{picker} only opens the TUI flow; it cannot authenticate"
);
}
}
/// Classifier matrix for all auth method variants.
#[test]
fn auth_method_kind_classifier_matrix() {
@@ -896,7 +923,9 @@ mod tests {
"xiaomi",
"xiaomi-token-plan-cn",
"minimax",
"minimax-cn"
"minimax-cn",
"custom-openai",
"custom-anthropic"
]
);
assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID));
@@ -948,7 +977,9 @@ mod tests {
"xiaomi",
"xiaomi-token-plan-cn",
"minimax",
"minimax-cn"
"minimax-cn",
"custom-openai",
"custom-anthropic"
]
);
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
@@ -993,7 +1024,9 @@ mod tests {
"xiaomi",
"xiaomi-token-plan-cn",
"minimax",
"minimax-cn"
"minimax-cn",
"custom-openai",
"custom-anthropic"
]
);
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
@@ -1041,7 +1074,9 @@ mod tests {
"xiaomi",
"xiaomi-token-plan-cn",
"minimax",
"minimax-cn"
"minimax-cn",
"custom-openai",
"custom-anthropic"
]
);
assert_eq!(default_id(&built), None);
+149 -5
View File
@@ -178,6 +178,9 @@ pub struct EndpointsConfig {
/// Read by `load_gcs_service_account_key_sync()`. Declared for `serde_ignored`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gcs_service_account_key: Option<String>,
/// Base URLs of declared custom providers; derived, never serialized.
#[serde(skip)]
pub custom_provider_bases: Vec<String>,
}
/// A blank or whitespace-only override counts as unset. Single source of truth
/// for the "empty value = not configured" rule shared by the endpoint resolvers.
@@ -210,7 +213,13 @@ impl EndpointsConfig {
if let Some(endpoints) = config.get("endpoints") {
crate::config::deep_merge_toml(&mut base, endpoints);
}
base.try_into().unwrap_or_default()
let mut endpoints: Self = base.try_into().unwrap_or_default();
if let Some(platforms) = config.get("platforms").cloned()
&& let Ok(platforms) = platforms.try_into::<PlatformsConfig>()
{
endpoints.custom_provider_bases = super::custom_providers::bases(&platforms);
}
endpoints
}
/// The subscription proxy base URL through which all auxiliary services (and
/// OAuth/session inference) resolve: explicit `coding_api_base_url`, else
@@ -265,6 +274,7 @@ impl Default for EndpointsConfig {
managed_config_url: env_string("KIGI_MANAGED_CONFIG_URL"),
management_api_key: None,
gcs_service_account_key: None,
custom_provider_bases: Vec::new(),
}
}
}
@@ -771,8 +781,11 @@ impl PlatformsConfig {
/// registry platform, so a typo like `moonshot_cn` fails loudly instead of
/// silently never matching. Key values are not logged.
pub fn warn_unknown_platforms(&self) {
for id in self.entries.keys() {
if kigi_models::PlatformId::parse(id).is_none() {
super::custom_providers::warn_invalid_declarations(self);
for (id, entry) in &self.entries {
if kigi_models::PlatformId::parse(id).is_none()
&& !super::custom_providers::is_declared(entry)
{
tracing::warn!(
platform = %id,
known = ?kigi_models::PlatformId::ALL
@@ -793,6 +806,12 @@ pub struct PlatformCredentialConfig {
/// API key for this platform. NEVER logged; never re-serialized.
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
/// With `api`, declares a custom provider outside the registry.
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
/// `"openai"` or `"anthropic"`.
#[serde(skip_serializing_if = "Option::is_none")]
pub api: Option<String>,
}
/// Resolve the API key for an API-key registry platform with injected env
@@ -1582,6 +1601,7 @@ impl Config {
config.config_models = config_models;
config.model_override_warnings = model_override_warnings;
config.platforms.warn_unknown_platforms();
config.endpoints.custom_provider_bases = super::custom_providers::bases(&config.platforms);
if config.client_version.is_none() {
config.client_version = Self::default().client_version;
}
@@ -2171,6 +2191,19 @@ impl Config {
)
.with_segment_detail(self.resolve_compaction_detail())
}
/// Resolve summarizer `tool_choice`: env `KIGI_COMPACTION_TOOL_CHOICE` >
/// config > remote settings > default (`auto`).
pub(crate) fn resolve_compaction_tool_choice(
&self,
) -> crate::util::config::CompactionToolChoice {
crate::util::config::resolve_compaction_tool_choice_from(
env_string(crate::util::config::ENV_COMPACTION_TOOL_CHOICE).as_deref(),
self.features.compaction_tool_choice.as_deref(),
self.remote_settings
.as_ref()
.and_then(|r| r.compaction_tool_choice.as_deref()),
)
}
/// Resolve verbatim-input flag: env `KIGI_COMPACTION_VERBATIM_INPUT` > config > remote settings > default `true`.
pub(crate) fn resolve_compaction_verbatim_input(&self) -> bool {
BoolFlag::env("KIGI_COMPACTION_VERBATIM_INPUT")
@@ -2556,6 +2589,13 @@ pub(crate) fn resolve_model_list(
tracing::debug!(count = defaults.len(), "loaded default models");
resolved.extend(defaults);
}
let prefetched = prefetched.map(|models| {
if cfg.endpoints.has_custom_endpoint() {
models
} else {
crate::agent::custom_providers::drop_orphans(models, platform_keys.custom())
}
});
if let Some(mut prefetched) = prefetched {
tracing::debug!(count = prefetched.len(), "loaded prefetched models");
let default_cw = DEFAULT_CONTEXT_WINDOW;
@@ -2588,6 +2628,7 @@ pub(crate) fn resolve_model_list(
}
resolved = prefetched;
}
let mut explicit_api_backend_keys = std::collections::HashSet::new();
for (key, model_override) in &cfg.config_models {
let had_base = resolved.contains_key(key);
let base = resolved.shift_remove(key);
@@ -2604,6 +2645,9 @@ pub(crate) fn resolve_model_list(
);
}
}
if model_override.api_backend.is_some() {
explicit_api_backend_keys.insert(key.as_str());
}
let entry = model_override.apply(key, base, &cfg.endpoints);
tracing::debug!(
model_key = % key, base_url = % entry.info.base_url, has_api_key = entry
@@ -2625,7 +2669,7 @@ pub(crate) fn resolve_model_list(
)
})
.collect();
for entry in resolved.values_mut() {
for (key, entry) in resolved.iter_mut() {
if let Some((donor_cw, donor_backend)) = donors.get(&entry.info.model) {
if entry.info.context_window.get() == default_cw {
tracing::debug!(
@@ -2635,7 +2679,9 @@ pub(crate) fn resolve_model_list(
);
entry.info.context_window = *donor_cw;
}
if entry.info.api_backend == ApiBackend::default()
// An explicit `[model.X] api_backend` wins even when it is the default.
if !explicit_api_backend_keys.contains(key.as_str())
&& entry.info.api_backend == ApiBackend::default()
&& *donor_backend != ApiBackend::default()
{
entry.info.api_backend.clone_from(donor_backend);
@@ -2657,6 +2703,7 @@ pub(crate) fn resolve_model_list(
apply_global_extra_headers(&mut resolved, &cfg.models);
apply_global_scalar_defaults(&mut resolved, &cfg.models);
apply_platform_credentials(&mut resolved, &cfg.platforms, platform_keys);
crate::agent::custom_providers::stamp_credentials(&mut resolved, platform_keys.custom());
for entry in resolved.values_mut() {
entry.info.derive_reasoning_effort_fields();
}
@@ -3696,6 +3743,9 @@ pub struct Features {
/// Feed the summarizer the verbatim conversation instead of the lossy rewrite; `None` = defer to env/remote settings/default (true).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compaction_verbatim_input: Option<bool>,
/// Summarizer `tool_choice` (`auto` | `none`); `None` = defer to env/remote settings/default (`auto`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compaction_tool_choice: Option<String>,
/// Snapshot a completed subagent's isolated worktree into a durable git ref
/// and delete its directory (resume rehydrates from the ref). This is the
/// per-deployment rollout lever (set in managed_config.toml `[features]`).
@@ -3910,6 +3960,17 @@ pub fn resolve_model_auth_facts(model_id: &str) -> ModelAuthFacts {
},
})
}
/// Auth facts of a held live-catalog entry.
pub(crate) fn auth_facts_of(entry: &ModelEntry) -> ModelAuthFacts {
ModelAuthFacts {
byok: if entry.has_own_credentials() {
ModelByok::Byok
} else {
ModelByok::NotByok
},
auth_scheme: entry.info().auth_scheme,
}
}
fn byok_from_lookup(lookup: &ModelLookup) -> ModelByok {
match lookup {
ModelLookup::ConfigUnavailable => ModelByok::Unknown,
@@ -5224,6 +5285,17 @@ reasoning_effort = "low"
auth_scheme: Default::default(),
}
}
/// Scheme and BYOK status come from the entry.
#[test]
fn auth_facts_come_from_the_held_entry() {
let mut model = test_model_entry("claude-x", "https://gw.example/v1", None, None, None);
model.info.auth_scheme = AuthScheme::XApiKey;
let facts = auth_facts_of(&model);
assert_eq!(facts.auth_scheme, AuthScheme::XApiKey);
assert_eq!(facts.byok, ModelByok::NotByok);
model.api_key = Some("sk-own".into());
assert_eq!(auth_facts_of(&model).byok, ModelByok::Byok);
}
#[test]
fn x_api_key_auth_scheme_flows_from_config_to_sampler() {
let mut model = test_model_entry(
@@ -6685,7 +6757,10 @@ reasoning_effort = "low"
assert_eq!(model.info.base_url, "https://inference.example.com/v1");
}
#[test]
#[serial]
fn e2e_default_model_with_session_routes_to_proxy() {
// Hermetic: serial tests point KIGI_CODE_BASE_URL at wiremock.
let _base = EnvGuard::unset(kigi_env::CODE_BASE_URL_ENV);
let (_, models) = resolve_models_from_toml("", None);
let model = models
.get(BUNDLED_DEFAULT_KEY)
@@ -6817,7 +6892,10 @@ reasoning_effort = "low"
);
}
#[test]
#[serial]
fn e2e_duplicate_model_field_both_entries_survive() {
// Hermetic: serial tests point KIGI_CODE_BASE_URL at wiremock.
let _base = EnvGuard::unset(kigi_env::CODE_BASE_URL_ENV);
let dm = crate::models::default_model();
let (_, models) = resolve_models_from_toml(
&format!(
@@ -9051,6 +9129,47 @@ default = "kigi-4.5"
sibling 'kigi-build' (same model slug), not stay at {default_cw}"
);
}
/// An explicit `[model.X] api_backend` survives a same-slug sibling even when it is the default.
#[test]
fn slug_propagation_keeps_an_explicit_default_api_backend() {
// Only kigi-messages has a non-default context window: the slug's single donor.
let default_cw = DEFAULT_CONTEXT_WINDOW;
let raw: toml::Value = toml::from_str(&format!(
r#"
[model.kigi-messages]
model = "kigi-4.5"
context_window = 500000
base_url = "https://test.example.com/v1"
api_backend = "messages"
[model.kigi-chat]
model = "kigi-4.5"
context_window = {default_cw}
base_url = "https://test.example.com/v1"
api_backend = "chat_completions"
[model.kigi-plain]
model = "kigi-4.5"
context_window = {default_cw}
base_url = "https://test.example.com/v1"
"#
))
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
let resolved = resolve_model_list(&cfg, None, &Default::default());
let backend = |key: &str| resolved.get(key).expect(key).info.api_backend.clone();
assert_eq!(
backend("kigi-chat"),
ApiBackend::ChatCompletions,
"explicit default backend must not be overwritten by the sibling"
);
assert_eq!(
backend("kigi-plain"),
ApiBackend::Messages,
"unset backend still inherits"
);
assert_eq!(backend("kigi-messages"), ApiBackend::Messages);
}
/// Slug propagation should carry over api_backend but NOT agent_type.
#[test]
fn slug_propagation_inherits_api_backend_but_not_agent_type() {
@@ -9604,6 +9723,31 @@ default = "kigi-4.5"
"non-BYOK config overlay must preserve bundled supported_in_api=false"
);
}
/// Custom provider bases reach `EndpointsConfig` from both entry points.
#[test]
fn declared_custom_provider_bases_reach_the_endpoints() {
let raw: toml::Value = toml::from_str(
r#"
[platforms.proxy]
base_url = "http://localhost:11434/v1/"
api = "openai"
[platforms.broken]
api = "openai"
[platforms.moonshot-cn]
api_key = "sk-cn"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
assert_eq!(
cfg.endpoints.custom_provider_bases,
vec!["http://localhost:11434/v1".to_owned()]
);
assert_eq!(
EndpointsConfig::from_config_value(&raw).custom_provider_bases,
vec!["http://localhost:11434/v1".to_owned()]
);
}
/// PRD F2: a `[platforms.<id>].api_key` from config.toml is stamped onto
/// that platform's catalog entries (in-memory only), making them usable
/// and API-key-visible — and only onto that platform.
@@ -0,0 +1,450 @@
//! Listing fetch and key stamping for custom providers.
use indexmap::IndexMap;
use kigi_models::WireModel;
use kigi_models::custom::{CustomApi, CustomProvider};
use super::CredentialedProvider;
use crate::agent::config::{ModelEntry, ModelEntryConfig};
use crate::agent::models_fetch::{BackendError, wire_model_to_entry};
/// `GET {base}/models` for one provider; every model is keyed `{name}/{id}`.
pub(crate) fn fetch_models_blocking(
credentialed: &CredentialedProvider,
) -> Result<Vec<ModelEntryConfig>, BackendError> {
let provider = &credentialed.provider;
let url = provider.models_url();
tracing::info!(provider = %provider.name, api = provider.api.as_str(), %url, "fetching custom provider models");
let client = crate::http::shared_blocking_client();
let request = match provider.api {
CustomApi::OpenAi => client
.get(&url)
.header("Authorization", format!("Bearer {}", credentialed.key())),
CustomApi::Anthropic => client
.get(&url)
.header("x-api-key", credentialed.key())
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
};
let response = request.send()?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().unwrap_or_default();
return Err(BackendError::RequestFailed { status, body });
}
let body = response.text()?;
let listing = match provider.api {
CustomApi::OpenAi => kigi_models::parse_openai_listing(&body),
CustomApi::Anthropic => kigi_models::parse_anthropic_listing(&body),
};
let wire = listing.map_err(|e| BackendError::RequestFailed {
status: 200,
body: format!("{} listing parse failed: {e}", provider.api.as_str()),
})?;
if wire.is_empty() {
tracing::warn!(provider = %provider.name, "custom provider listed no models");
}
Ok(wire.into_iter().map(|w| entry(provider, w)).collect())
}
fn entry(provider: &CustomProvider, wire: WireModel) -> ModelEntryConfig {
let mut entry = wire_model_to_entry(
provider.managed_model_key(&wire.id),
provider.api.wire_api(),
provider.api.key_header(),
None,
true,
wire,
&provider.base_url,
);
entry.description = Some(provider.name.clone());
entry
}
/// Drops fetched entries of a provider that is gone or keyless.
pub(crate) fn drop_orphans(
prefetched: IndexMap<String, ModelEntry>,
keyed: &[CredentialedProvider],
) -> IndexMap<String, ModelEntry> {
prefetched
.into_iter()
.filter(|(key, entry)| {
let id = entry.info.id.as_deref().unwrap_or(key.as_str());
let Some((prefix, _)) = id.split_once('/') else {
return true;
};
let known = kigi_models::PlatformId::parse(prefix).is_some()
|| keyed.iter().any(|k| k.provider.name == prefix);
if !known {
tracing::warn!(model_key = %key, "dropping a fetched entry of an unknown or keyless provider");
}
known
})
.collect()
}
/// Stamps each entry with its provider's key, in memory only.
pub(crate) fn stamp_credentials(
resolved: &mut IndexMap<String, ModelEntry>,
keyed: &[CredentialedProvider],
) {
for (key, entry) in resolved.iter_mut() {
let id = entry.info.id.as_deref().unwrap_or(key.as_str());
let Some((name, _)) = id.split_once('/') else {
continue;
};
let Some(owner) = keyed.iter().find(|k| k.provider.name == name) else {
continue;
};
if entry.api_key.is_some() {
continue;
}
if !crate::util::matches_trusted_base_url(&entry.info.base_url, &owner.provider.base_url) {
tracing::warn!(
model_key = %key, provider = %name,
"entry points away from its custom provider; key not stamped"
);
continue;
}
entry.api_key = Some(owner.key().to_owned());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::models::{ModelFetchAuth, PlatformApiKeys};
use crate::agent::models_fetch::fetch_models_blocking as fetch_all;
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn credentialed(name: &str, api: CustomApi, base: &str, key: &str) -> CredentialedProvider {
CredentialedProvider::for_test(CustomProvider::new(name, api, base).unwrap(), key)
}
async fn fetch_on_thread(
c: CredentialedProvider,
) -> Result<Vec<ModelEntryConfig>, BackendError> {
tokio::task::spawn_blocking(move || fetch_models_blocking(&c))
.await
.unwrap()
}
#[tokio::test(flavor = "multi_thread")]
async fn openai_listing_becomes_chat_completions_entries() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("authorization", "Bearer sk-proxy"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"object": "list",
"data": [{"id": "m1", "context_length": 32000}, {"id": "m2"}]
})))
.expect(1)
.mount(&server)
.await;
let base = format!("{}/v1/", server.uri());
let models = fetch_on_thread(credentialed("proxy", CustomApi::OpenAi, &base, "sk-proxy"))
.await
.unwrap();
let ids: Vec<_> = models.iter().map(|m| m.id.as_deref().unwrap()).collect();
assert_eq!(ids, ["proxy/m1", "proxy/m2"]);
assert_eq!(models[0].model, "m1");
assert_eq!(models[0].context_window.get(), 32_000);
assert_eq!(
models[1].context_window.get(),
crate::agent::models_fetch::DEFAULT_CONTEXT_WINDOW
);
assert_eq!(models[0].base_url, format!("{}/v1", server.uri()));
assert_eq!(
models[0].api_backend,
crate::sampling::ApiBackend::ChatCompletions
);
assert_eq!(models[0].auth_scheme, None);
assert_eq!(models[0].description.as_deref(), Some("proxy"));
assert!(models[0].api_key.is_none() && models[0].env_key.is_none());
assert!(models[0].supported_in_api);
}
#[tokio::test(flavor = "multi_thread")]
async fn anthropic_listing_becomes_messages_entries() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(query_param("limit", "1000"))
.and(header("x-api-key", "sk-gw"))
.and(header(
"anthropic-version",
kigi_sampling_types::ANTHROPIC_VERSION,
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{
"id": "claude-x", "display_name": "Claude X",
"max_input_tokens": 200000, "max_tokens": 64000
}],
"has_more": false
})))
.expect(1)
.mount(&server)
.await;
let base = format!("{}/v1", server.uri());
let models = fetch_on_thread(credentialed("gw", CustomApi::Anthropic, &base, "sk-gw"))
.await
.unwrap();
assert_eq!(models.len(), 1);
let m = &models[0];
assert_eq!(m.id.as_deref(), Some("gw/claude-x"));
assert_eq!(m.name.as_deref(), Some("Claude X"));
assert_eq!(m.context_window.get(), 200_000);
assert_eq!(m.max_completion_tokens, Some(64_000));
assert_eq!(m.api_backend, crate::sampling::ApiBackend::Messages);
assert_eq!(m.auth_scheme, Some(kigi_sampler::AuthScheme::XApiKey));
}
#[tokio::test(flavor = "multi_thread")]
async fn listing_failures_surface_status_and_parse_errors() {
let server = MockServer::start().await;
Mock::given(path("/v1/models"))
.respond_with(ResponseTemplate::new(401).set_body_string("nope"))
.mount(&server)
.await;
let base = format!("{}/v1", server.uri());
let err = fetch_on_thread(credentialed("proxy", CustomApi::OpenAi, &base, "bad"))
.await
.unwrap_err();
assert!(
matches!(err, BackendError::RequestFailed { status: 401, .. }),
"{err}"
);
let server = MockServer::start().await;
Mock::given(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_string("not json"))
.mount(&server)
.await;
let base = format!("{}/v1", server.uri());
let err = fetch_on_thread(credentialed("proxy", CustomApi::OpenAi, &base, "k"))
.await
.unwrap_err();
assert!(
err.to_string().contains("openai listing parse failed"),
"{err}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn custom_providers_alone_satisfy_the_platform_fetch() {
let server = MockServer::start().await;
Mock::given(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"id": "m1"}]
})))
.mount(&server)
.await;
let base = format!("{}/v1", server.uri());
let keys = PlatformApiKeys::test_custom(vec![credentialed(
"proxy",
CustomApi::OpenAi,
&base,
"sk-origin-secret",
)]);
let endpoints = crate::agent::config::EndpointsConfig::default();
let origin = crate::agent::models_fetch::models_fetch_origin(
&endpoints,
ModelFetchAuth::Platforms,
false,
&Default::default(),
&keys,
);
assert!(origin.contains(&format!("proxy={base}/models")), "{origin}");
assert!(
!origin.contains("sk"),
"the origin must never carry a key: {origin}"
);
let result = tokio::task::spawn_blocking(move || {
fetch_all(
&endpoints,
None,
&Default::default(),
ModelFetchAuth::Platforms,
&keys,
)
})
.await
.unwrap()
.expect("a custom provider is a complete catalog source");
assert_eq!(result.models.len(), 1);
assert_eq!(result.models[0].id.as_deref(), Some("proxy/m1"));
}
#[tokio::test(flavor = "multi_thread")]
async fn a_failing_custom_provider_alone_fails_the_fetch() {
let server = MockServer::start().await;
Mock::given(path("/v1/models"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let base = format!("{}/v1", server.uri());
let keys = PlatformApiKeys::test_custom(vec![credentialed(
"proxy",
CustomApi::OpenAi,
&base,
"sk-origin-secret",
)]);
let err = tokio::task::spawn_blocking(move || {
fetch_all(
&crate::agent::config::EndpointsConfig::default(),
None,
&Default::default(),
ModelFetchAuth::Platforms,
&keys,
)
})
.await
.unwrap()
.err()
.expect("500 with no other source must fail");
assert!(
matches!(err, BackendError::RequestFailed { status: 500, .. }),
"{err}"
);
}
fn resolved_entry(provider: &CustomProvider, id: &str, base: &str) -> (String, ModelEntry) {
let wire: WireModel = serde_json::from_value(serde_json::json!({ "id": id })).unwrap();
let mut cfg = entry(provider, wire);
cfg.base_url = base.to_owned();
let key = cfg.id.clone().unwrap();
(key, ModelEntry::from_config_entry(&cfg))
}
#[test]
fn stamp_gives_each_entry_only_its_own_providers_key_on_its_own_host() {
let proxy =
CustomProvider::new("proxy", CustomApi::OpenAi, "https://p.example/v1").unwrap();
let gw = CustomProvider::new("gw", CustomApi::Anthropic, "https://g.example/v1").unwrap();
let mut resolved = IndexMap::new();
for (key, entry) in [
resolved_entry(&proxy, "m1", "https://p.example/v1"),
resolved_entry(&gw, "c1", "https://g.example/v1"),
resolved_entry(&proxy, "moved", "https://evil.example/v1"),
] {
resolved.insert(key, entry);
}
let (own_key, mut own) = resolved_entry(&proxy, "own", "https://p.example/v1");
own.api_key = Some("per-model".into());
resolved.insert(own_key, own);
let snapshot = vec![
CredentialedProvider::for_test(proxy, "sk-proxy"),
CredentialedProvider::for_test(gw, "sk-gw"),
];
stamp_credentials(&mut resolved, &snapshot);
assert_eq!(resolved["proxy/m1"].api_key.as_deref(), Some("sk-proxy"));
assert_eq!(resolved["gw/c1"].api_key.as_deref(), Some("sk-gw"));
assert_eq!(
resolved["proxy/moved"].api_key, None,
"off-host entry gets nothing"
);
assert_eq!(resolved["proxy/own"].api_key.as_deref(), Some("per-model"));
}
#[test]
fn resolve_model_list_stamps_fetched_custom_entries() {
let raw: toml::Value = toml::from_str(
"[platforms.proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\n",
)
.unwrap();
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
let proxy =
CustomProvider::new("proxy", CustomApi::OpenAi, "https://p.example/v1").unwrap();
let (key, entry) = resolved_entry(&proxy, "m1", "https://p.example/v1");
let keys =
PlatformApiKeys::test_custom(vec![CredentialedProvider::for_test(proxy, "sk-proxy")]);
let resolved = crate::agent::config::resolve_model_list(
&cfg,
Some(IndexMap::from([(key, entry)])),
&keys,
);
let m = resolved
.get("proxy/m1")
.expect("fetched entry survives resolution");
assert_eq!(m.api_key.as_deref(), Some("sk-proxy"));
assert!(m.has_own_credentials());
assert!(m.visible_for_auth(false), "API-key users see the entry");
assert_eq!(
m.info.api_backend,
crate::sampling::ApiBackend::ChatCompletions
);
}
#[test]
fn orphaned_custom_entries_never_enter_the_catalog() {
let proxy =
CustomProvider::new("proxy", CustomApi::OpenAi, "https://p.example/v1").unwrap();
let gone = CustomProvider::new("gone", CustomApi::OpenAi, "https://g.example/v1").unwrap();
let mut prefetched = IndexMap::new();
for (key, entry) in [
resolved_entry(&proxy, "m1", "https://p.example/v1"),
resolved_entry(&gone, "m2", "https://g.example/v1"),
] {
prefetched.insert(key, entry);
}
let registry: WireModel =
serde_json::from_value(serde_json::json!({ "id": "kimi-k2-turbo-preview" })).unwrap();
let registry = ModelEntry::from_config_entry(
&crate::agent::models_fetch::platform_wire_model_to_entry(
kigi_models::PlatformId::MoonshotCn,
registry,
"https://api.moonshot.cn/v1",
),
);
prefetched.insert("moonshot-cn/kimi-k2-turbo-preview".into(), registry);
let keyed = vec![CredentialedProvider::for_test(proxy, "sk-proxy")];
let cfg = crate::agent::config::Config::default();
let resolved = crate::agent::config::resolve_model_list(
&cfg,
Some(prefetched.clone()),
&PlatformApiKeys::test_custom(keyed.clone()),
);
let ids: Vec<_> = resolved.keys().map(String::as_str).collect();
assert_eq!(ids, ["proxy/m1", "moonshot-cn/kimi-k2-turbo-preview"]);
let unkeyed =
crate::agent::config::resolve_model_list(&cfg, Some(prefetched), &Default::default());
assert!(
!unkeyed.contains_key("proxy/m1") && !unkeyed.contains_key("gone/m2"),
"a keyless provider leaves no entries behind"
);
}
#[test]
fn custom_endpoint_mode_keeps_slashed_remote_ids() {
let raw: toml::Value =
toml::from_str("[endpoints]\nmodels_base_url = \"https://m.example/v1\"\n").unwrap();
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
let other = CustomProvider::new("org", CustomApi::OpenAi, "https://o.example/v1").unwrap();
let (key, entry) = resolved_entry(&other, "llama", "https://o.example/v1");
let resolved = crate::agent::config::resolve_model_list(
&cfg,
Some(IndexMap::from([(key, entry)])),
&Default::default(),
);
assert!(
resolved.contains_key("org/llama"),
"{:?}",
resolved.keys().collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,171 @@
//! Login for custom providers: picker rows, method ids, key validation.
use agent_client_protocol as acp;
use kigi_models::custom::CustomApi;
use super::CredentialedProvider;
const PICKER_PREFIX: &str = "custom-";
const LOGIN_PREFIX: &str = "custom:";
const ALL_APIS: [CustomApi; 2] = [CustomApi::OpenAi, CustomApi::Anthropic];
/// The picker row for one wire.
pub fn picker_method(api: CustomApi) -> acp::AuthMethod {
acp::AuthMethod::Agent(
acp::AuthMethodAgent::new(
acp::AuthMethodId::new(format!("{PICKER_PREFIX}{}", api.as_str())),
format!("Custom provider ({})", api.label()),
)
.description(Some("Your own base URL and API key".to_owned())),
)
}
/// Both picker rows, in picker order.
pub fn picker_methods() -> Vec<acp::AuthMethod> {
ALL_APIS.into_iter().map(picker_method).collect()
}
/// The wire behind a picker row id.
pub fn picker_api(id: &str) -> Option<CustomApi> {
let suffix = id.strip_prefix(PICKER_PREFIX)?;
ALL_APIS.into_iter().find(|api| api.as_str() == suffix)
}
/// The id `authenticate` takes once the provider is saved.
pub fn login_method_id(name: &str) -> acp::AuthMethodId {
acp::AuthMethodId::new(format!("{LOGIN_PREFIX}{name}"))
}
/// The provider name inside an `authenticate` method id.
pub fn login_provider_name(id: &str) -> Option<&str> {
id.strip_prefix(LOGIN_PREFIX)
}
/// Probes the listing endpoint; errors never hold the key.
pub(crate) async fn validate_key(credentialed: &CredentialedProvider) -> Result<(), String> {
let provider = &credentialed.provider;
let url = provider.models_url();
let client = crate::http::shared_client();
let request = match provider.api {
CustomApi::OpenAi => client
.get(&url)
.header("Authorization", format!("Bearer {}", credentialed.key())),
CustomApi::Anthropic => client
.get(&url)
.header("x-api-key", credentialed.key())
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
};
let response = request
.send()
.await
.map_err(|e| format!("Couldn't reach {url}: {e}"))?;
let status = response.status().as_u16();
match status {
200..=299 => Ok(()),
401 | 403 => Err(format!(
"{} rejected the API key (HTTP {status})",
provider.name
)),
_ => Err(format!("{url} answered HTTP {status}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use kigi_models::custom::CustomProvider;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn credentialed(api: CustomApi, base: &str, key: &str) -> CredentialedProvider {
CredentialedProvider::for_test(CustomProvider::new("proxy", api, base).unwrap(), key)
}
#[test]
fn picker_ids_round_trip_and_reject_lookalikes() {
for api in ALL_APIS {
let method = picker_method(api);
assert_eq!(picker_api(method.id().0.as_ref()), Some(api));
}
assert_eq!(picker_api("custom-responses"), None);
assert_eq!(picker_api("custom-OpenAI"), None);
assert_eq!(picker_api("custom:proxy"), None);
assert_eq!(picker_api("moonshot-cn"), None);
assert_eq!(picker_methods().len(), 2);
}
#[test]
fn login_ids_carry_the_provider_name() {
let id = login_method_id("my-proxy");
assert_eq!(id.0.as_ref(), "custom:my-proxy");
assert_eq!(login_provider_name(id.0.as_ref()), Some("my-proxy"));
assert_eq!(login_provider_name("custom-openai"), None);
assert_eq!(login_provider_name("proxy"), None);
}
#[tokio::test]
async fn validation_sends_the_dialects_key_header() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("authorization", "Bearer sk-openai"))
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
.expect(1)
.mount(&server)
.await;
let base = format!("{}/v1", server.uri());
validate_key(&credentialed(CustomApi::OpenAi, &base, "sk-openai"))
.await
.unwrap();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("x-api-key", "sk-anthropic"))
.and(header(
"anthropic-version",
kigi_sampling_types::ANTHROPIC_VERSION,
))
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
.expect(1)
.mount(&server)
.await;
let base = format!("{}/v1", server.uri());
validate_key(&credentialed(CustomApi::Anthropic, &base, "sk-anthropic"))
.await
.unwrap();
}
#[tokio::test]
async fn validation_names_the_failure_without_the_key() {
let server = MockServer::start().await;
for (status, path_suffix) in [(401, "a"), (403, "b"), (404, "c"), (500, "d")] {
Mock::given(path(format!("/{path_suffix}/models")))
.respond_with(ResponseTemplate::new(status))
.mount(&server)
.await;
}
let check = |suffix: &str| {
let base = format!("{}/{suffix}", server.uri());
async move {
validate_key(&credentialed(CustomApi::OpenAi, &base, "sk-secret-key"))
.await
.unwrap_err()
}
};
assert!(check("a").await.contains("rejected the API key (HTTP 401)"));
assert!(check("b").await.contains("rejected the API key (HTTP 403)"));
assert!(check("c").await.contains("answered HTTP 404"));
assert!(check("d").await.contains("answered HTTP 500"));
let err = validate_key(&credentialed(
CustomApi::OpenAi,
"http://127.0.0.1:1/v1",
"sk-secret-key",
))
.await
.unwrap_err();
assert!(err.contains("Couldn't reach"), "{err}");
assert!(!err.contains("sk-secret-key"), "{err}");
}
}
@@ -0,0 +1,229 @@
//! `[platforms.<name>]` tables that declare custom providers.
use kigi_models::custom::{CustomApi, CustomProvider};
use crate::agent::config::{PlatformCredentialConfig, PlatformsConfig};
mod fetch;
mod login;
mod save;
pub(crate) use fetch::{drop_orphans, fetch_models_blocking, stamp_credentials};
pub(crate) use login::validate_key;
pub use login::{login_method_id, login_provider_name, picker_api, picker_methods};
pub use save::save_custom_provider;
/// Tables setting `base_url` or `api`, valid or not.
fn declarations(platforms: &PlatformsConfig) -> Vec<(&str, Result<CustomProvider, String>)> {
platforms
.entries
.iter()
.filter(|(_, entry)| entry.base_url.is_some() || entry.api.is_some())
.map(|(name, entry)| (name.as_str(), parse_declaration(name, entry)))
.collect()
}
fn parse_declaration(
name: &str,
entry: &PlatformCredentialConfig,
) -> Result<CustomProvider, String> {
let api = entry
.api
.as_deref()
.ok_or("api is required (\"openai\" or \"anthropic\")")?;
let api = CustomApi::parse(api)
.ok_or_else(|| format!("api {api:?} is not \"openai\" or \"anthropic\""))?;
let base_url = entry.base_url.as_deref().ok_or("base_url is required")?;
CustomProvider::new(name, api, base_url).map_err(|e| e.to_string())
}
/// The valid declarations.
pub(crate) fn providers(platforms: &PlatformsConfig) -> Vec<CustomProvider> {
declarations(platforms)
.into_iter()
.filter_map(|(_, parsed)| parsed.ok())
.collect()
}
/// Base URLs of the valid declarations.
pub(crate) fn bases(platforms: &PlatformsConfig) -> Vec<String> {
providers(platforms)
.into_iter()
.map(|p| p.base_url)
.collect()
}
/// True when the table declares a provider, valid or not.
pub(crate) fn is_declared(entry: &PlatformCredentialConfig) -> bool {
entry.base_url.is_some() || entry.api.is_some()
}
/// Warns once per refused declaration; the table stays inert.
pub(crate) fn warn_invalid_declarations(platforms: &PlatformsConfig) {
for (name, parsed) in declarations(platforms) {
if let Err(reason) = parsed {
tracing::warn!(platform = %name, %reason, "[platforms.{name}] is not a valid custom provider; ignored");
}
}
}
/// A declared provider with a key; `Debug` hides the key.
#[derive(Clone)]
pub(crate) struct CredentialedProvider {
pub(crate) provider: CustomProvider,
key: String,
}
impl CredentialedProvider {
pub(crate) fn key(&self) -> &str {
&self.key
}
#[cfg(test)]
pub(crate) fn for_test(provider: CustomProvider, key: &str) -> Self {
Self {
provider,
key: key.to_owned(),
}
}
}
impl std::fmt::Debug for CredentialedProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CredentialedProvider")
.field("provider", &self.provider)
.field("key", &"<set>")
.finish()
}
}
/// Key order: auth.json scope, then the table's `api_key`.
pub(crate) fn credentialed(
platforms: &PlatformsConfig,
stored: impl Fn(&str) -> Option<String>,
) -> Vec<CredentialedProvider> {
providers(platforms)
.into_iter()
.filter_map(|provider| {
let key = stored(&provider.name)
.filter(|k| !k.trim().is_empty())
.or_else(|| {
platforms
.entries
.get(&provider.name)
.and_then(|e| e.api_key.clone())
.filter(|k| !k.trim().is_empty())
})?;
Some(CredentialedProvider { provider, key })
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn platforms(toml_src: &str) -> PlatformsConfig {
toml::from_str(toml_src).expect("platforms table parses")
}
#[test]
fn declarations_read_api_and_base_url() {
let cfg = platforms(
r#"
[proxy]
base_url = "https://h.example/v1/"
api = "openai"
[gw]
base_url = "https://g.example/v1"
api = "Anthropic"
"#,
);
let found = providers(&cfg);
assert_eq!(found.len(), 2);
assert_eq!(found[0].name, "proxy");
assert_eq!(found[0].api, CustomApi::OpenAi);
assert_eq!(found[0].base_url, "https://h.example/v1");
assert_eq!(found[1].api, CustomApi::Anthropic);
}
#[test]
fn invalid_declarations_are_refused_with_a_reason() {
let cfg = platforms(
r#"
[no-api]
base_url = "https://h.example/v1"
[no-url]
api = "openai"
[bad-api]
base_url = "https://h.example/v1"
api = "responses"
[bad-url]
base_url = "h.example"
api = "openai"
[openai]
base_url = "https://h.example/v1"
api = "openai"
[Upper]
base_url = "https://h.example/v1"
api = "openai"
"#,
);
assert!(providers(&cfg).is_empty());
let reasons: Vec<String> = declarations(&cfg)
.into_iter()
.map(|(_, parsed)| parsed.expect_err("every declaration is invalid"))
.collect();
assert_eq!(reasons.len(), 6);
assert!(reasons[0].contains("api is required"));
assert!(reasons[1].contains("base_url is required"));
assert!(reasons[2].contains("responses"));
assert!(reasons[3].contains("base_url"));
assert!(reasons[4].contains("built-in platform id"));
assert!(reasons[5].contains("provider name"));
}
#[test]
fn a_table_with_only_a_key_is_not_a_declaration() {
let cfg = platforms("[moonshot-cn]\napi_key = \"sk-x\"\n");
assert!(declarations(&cfg).is_empty());
assert!(!is_declared(&cfg.entries["moonshot-cn"]));
}
#[test]
fn key_prefers_auth_json_over_config_and_blank_is_unset() {
let cfg = platforms(
r#"
[proxy]
base_url = "https://h.example/v1"
api = "openai"
api_key = "from-config"
[blank]
base_url = "https://b.example/v1"
api = "openai"
api_key = " "
[keyless]
base_url = "https://k.example/v1"
api = "openai"
"#,
);
let stored = |name: &str| (name == "proxy").then(|| "from-auth-json".to_owned());
let found = credentialed(&cfg, stored);
assert_eq!(found.len(), 1);
assert_eq!(found[0].provider.name, "proxy");
assert_eq!(found[0].key(), "from-auth-json");
let found = credentialed(&cfg, |_| None);
assert_eq!(found.len(), 1);
assert_eq!(found[0].key(), "from-config");
}
#[test]
fn debug_never_prints_the_key() {
let cfg = platforms(
"[proxy]\nbase_url = \"https://h.example/v1\"\napi = \"openai\"\napi_key = \"sk-secret\"\n",
);
let found = credentialed(&cfg, |_| None);
assert!(!format!("{found:?}").contains("sk-secret"));
}
}
@@ -0,0 +1,169 @@
use anyhow::{Context, Result, anyhow};
use kigi_models::custom::CustomProvider;
use toml::Value as TomlValue;
use toml::map::Map as TomlMap;
/// Writes `[platforms.<name>]` to config.toml and the key to auth.json.
pub async fn save_custom_provider(provider: &CustomProvider, api_key: &str) -> Result<()> {
let provider = provider.clone();
let api_key = api_key.to_owned();
let _guard = crate::util::config::SAVE_LOCK.lock().await;
tokio::task::spawn_blocking(move || {
save_custom_provider_in(
&crate::util::config::user_config_path(),
&crate::util::kigi_home::kigi_home(),
&provider,
&api_key,
)
})
.await
.map_err(|e| anyhow!("custom provider save task: {e}"))?
}
/// Config first; a keyless definition is inert.
pub(crate) fn save_custom_provider_in(
config_path: &std::path::Path,
kigi_home: &std::path::Path,
provider: &CustomProvider,
api_key: &str,
) -> Result<()> {
let api_key = api_key.trim();
anyhow::ensure!(!api_key.is_empty(), "API key must not be empty");
let raw = crate::util::config::read_to_string_or_empty(config_path)
.with_context(|| format!("reading {}", config_path.display()))?;
let mut root: TomlValue = toml::from_str(&raw).map_err(|e| {
anyhow!(
"refusing to overwrite unparseable {}: {e}; fix the syntax error first",
config_path.display()
)
})?;
upsert_definition(&mut root, provider)?;
let dest = crate::util::config::config_write_dest(config_path)?;
crate::util::config::atomic_write_string(&dest, &toml::to_string_pretty(&root)?)
.with_context(|| format!("writing {}", dest.display()))?;
crate::auth::store_scoped_api_key(kigi_home, &provider.name, api_key)
.with_context(|| format!("saving the key for {} to auth.json", provider.name))?;
tracing::info!(
provider = %provider.name,
api = provider.api.as_str(),
base_url = %provider.base_url,
"custom provider saved"
);
Ok(())
}
fn upsert_definition(root: &mut TomlValue, provider: &CustomProvider) -> Result<()> {
let top = root
.as_table_mut()
.ok_or_else(|| anyhow!("config root is not a table"))?;
let platforms = top
.entry("platforms")
.or_insert_with(|| TomlValue::Table(TomlMap::new()))
.as_table_mut()
.ok_or_else(|| anyhow!("[platforms] is not a table"))?;
let entry = platforms
.entry(provider.name.as_str())
.or_insert_with(|| TomlValue::Table(TomlMap::new()))
.as_table_mut()
.ok_or_else(|| anyhow!("[platforms.{}] is not a table", provider.name))?;
entry.insert("api".into(), provider.api.as_str().into());
entry.insert("base_url".into(), provider.base_url.as_str().into());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use kigi_models::custom::CustomApi;
fn provider(name: &str, api: CustomApi, url: &str) -> CustomProvider {
CustomProvider::new(name, api, url).unwrap()
}
#[test]
fn save_writes_definition_and_key_and_keeps_the_rest() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config.toml");
std::fs::write(
&config,
"[ui]\ntheme = \"kiginight\"\n\n[platforms.moonshot-cn]\napi_key = \"sk-cn\"\n\n[platforms.proxy]\napi_key = \"hand-written\"\n",
)
.unwrap();
let p = provider("proxy", CustomApi::OpenAi, "https://h.example/v1/");
save_custom_provider_in(&config, dir.path(), &p, " sk-new ").unwrap();
let saved: TomlValue = toml::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
assert_eq!(saved["ui"]["theme"].as_str(), Some("kiginight"));
assert_eq!(
saved["platforms"]["moonshot-cn"]["api_key"].as_str(),
Some("sk-cn")
);
let entry = &saved["platforms"]["proxy"];
assert_eq!(entry["api"].as_str(), Some("openai"));
assert_eq!(entry["base_url"].as_str(), Some("https://h.example/v1"));
assert_eq!(entry["api_key"].as_str(), Some("hand-written"));
let stored = crate::auth::read_auth_json(&dir.path().join("auth.json")).unwrap();
assert_eq!(stored["proxy"].key, "sk-new");
}
#[test]
fn save_creates_config_and_a_second_save_rewrites_the_definition() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config.toml");
save_custom_provider_in(
&config,
dir.path(),
&provider("gw", CustomApi::OpenAi, "https://a.example/v1"),
"k1",
)
.unwrap();
save_custom_provider_in(
&config,
dir.path(),
&provider("gw", CustomApi::Anthropic, "https://b.example/v1"),
"k2",
)
.unwrap();
let saved: TomlValue = toml::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
assert_eq!(saved["platforms"]["gw"]["api"].as_str(), Some("anthropic"));
assert_eq!(
saved["platforms"]["gw"]["base_url"].as_str(),
Some("https://b.example/v1")
);
let stored = crate::auth::read_auth_json(&dir.path().join("auth.json")).unwrap();
assert_eq!(stored["gw"].key, "k2");
}
#[test]
fn empty_key_and_broken_config_fail_before_any_write() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config.toml");
let p = provider("proxy", CustomApi::OpenAi, "https://h.example/v1");
assert!(save_custom_provider_in(&config, dir.path(), &p, " ").is_err());
assert!(!config.exists());
assert!(!dir.path().join("auth.json").exists());
std::fs::write(&config, "[platforms\nnope").unwrap();
let err = save_custom_provider_in(&config, dir.path(), &p, "sk").unwrap_err();
assert!(err.to_string().contains("unparseable"), "{err}");
assert_eq!(
std::fs::read_to_string(&config).unwrap(),
"[platforms\nnope"
);
assert!(!dir.path().join("auth.json").exists());
}
#[test]
fn a_non_table_platforms_value_is_refused() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("config.toml");
std::fs::write(&config, "platforms = \"x\"\n").unwrap();
let p = provider("proxy", CustomApi::OpenAi, "https://h.example/v1");
let err = save_custom_provider_in(&config, dir.path(), &p, "sk").unwrap_err();
assert!(err.to_string().contains("not a table"), "{err}");
assert!(!dir.path().join("auth.json").exists());
}
}
@@ -367,7 +367,7 @@ mod tests {
let _url = EnvGuard::set(MODELS_DEV_URL_ENV, "http://127.0.0.1:1/api.json");
let catalog = load_enrichment_catalog_at(&path);
assert!(
kigi_models::enrichment::lookup(&catalog, "kimi-for-coding", "k3").is_some(),
kigi_models::enrichment::lookup(&catalog, "kimi-code-plan-cn", "k3").is_some(),
"bundled snapshot must back a total refresh failure"
);
assert!(!path.exists(), "failed refresh must not write a cache");
@@ -385,7 +385,7 @@ mod tests {
let _url = EnvGuard::set(MODELS_DEV_URL_ENV, token);
assert!(refresh_url().is_none(), "token {token:?} must disable");
let catalog = load_enrichment_catalog_at(&path);
assert!(kigi_models::enrichment::lookup(&catalog, "kimi-for-coding", "k3").is_some());
assert!(kigi_models::enrichment::lookup(&catalog, "kimi-code-plan-cn", "k3").is_some());
}
}
@@ -12,8 +12,7 @@ use std::sync::Arc;
use serde::Serialize;
/// Where non-subscription users (no OAuth session) submit feedback instead.
pub const FEEDBACK_ISSUES_URL: &str =
"https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/issues";
pub const FEEDBACK_ISSUES_URL: &str = "https://github.com/ZacharyZhang-NY/Kigi-CLI/issues";
/// HTTP error from the feedback endpoint with a preserved status code, so
/// callers can distinguish auth failures (401) without string matching.
@@ -210,9 +210,9 @@ pub(crate) async fn apply(
auto_compact_threshold_percent: new_threshold,
responds_to: tx,
});
let updated_model = rx
let (updated_model, context_window) = rx
.await
.map_err(|_| acp::Error::internal_error().data("failed to set session model"))?;
.map_err(|_| acp::Error::internal_error().data("failed to set session model"))??;
if let Some(handle) = agent.sessions.borrow_mut().get_mut(&session_id) {
handle.model_id = model_id.clone();
handle.reasoning_effort = applied_effort;
@@ -224,6 +224,7 @@ pub(crate) async fn apply(
&session_id,
model_id.0.as_ref(),
applied_effort.map(|eff| eff.to_string()),
context_window.get(),
);
if agent.cfg.borrow().mode != config::AgentMode::Leader {
agent.models_manager.set_current_model_id(model_id);
@@ -232,9 +233,12 @@ pub(crate) async fn apply(
.set_current_reasoning_effort(applied_effort);
}
Ok(acp::SetSessionModelResponse::new().meta(
serde_json::json!({ "model" : updated_model, })
.as_object()
.cloned(),
serde_json::json!({
"model": updated_model,
"totalContextTokens": context_window.get(),
})
.as_object()
.cloned(),
))
}
/// Broadcast a `ModelChanged` to every client subscribed to this session so
@@ -245,12 +249,14 @@ fn broadcast_model_changed(
session_id: &acp::SessionId,
model_id: &str,
reasoning_effort: Option<String>,
context_window: u64,
) {
let notification = crate::extensions::notification::SessionNotification {
session_id: session_id.clone(),
update: crate::extensions::notification::SessionUpdate::ModelChanged {
model_id: model_id.to_owned(),
reasoning_effort,
context_window: Some(context_window),
},
meta: None,
};
@@ -4,6 +4,7 @@ pub mod auth_method;
pub mod chat_modes;
pub mod config;
pub mod config_model_override_parse;
pub mod custom_providers;
pub(crate) mod enrichment_fetch;
mod ext_parsers;
pub(crate) mod feedback_client;
+83 -44
View File
@@ -73,6 +73,7 @@ enum CacheAuthMethod {
#[derive(Clone, Default)]
pub(crate) struct PlatformApiKeys {
keys: std::collections::BTreeMap<kigi_models::PlatformId, String>,
custom: Vec<super::custom_providers::CredentialedProvider>,
}
impl std::fmt::Debug for PlatformApiKeys {
@@ -83,6 +84,7 @@ impl std::fmt::Debug for PlatformApiKeys {
s.field(platform.as_str(), &self.keys.contains_key(&platform));
}
}
s.field("custom", &self.custom);
s.finish()
}
}
@@ -114,7 +116,13 @@ impl PlatformApiKeys {
keys.insert(platform, key);
}
}
Self { keys }
let custom = super::custom_providers::credentialed(platforms, |name| {
stored
.as_ref()
.and_then(|m| m.get(name))
.map(|a| a.key.clone())
});
Self { keys, custom }
}
/// Resolve from the effective on-disk config (startup paths that have no
@@ -135,7 +143,11 @@ impl PlatformApiKeys {
/// Any API-key platform credentialed? Drives "should we prefetch without
/// a session" and the F2 acceptance path (platform key only, no login).
pub(crate) fn any(&self) -> bool {
!self.keys.is_empty()
!self.keys.is_empty() || !self.custom.is_empty()
}
pub(crate) fn custom(&self) -> &[super::custom_providers::CredentialedProvider] {
&self.custom
}
/// Test-only constructor (fields are private to this module).
@@ -148,7 +160,18 @@ impl PlatformApiKeys {
if let Some(k) = ai {
keys.insert(kigi_models::PlatformId::MoonshotAi, k.to_owned());
}
Self { keys }
Self {
keys,
custom: Vec::new(),
}
}
#[cfg(test)]
pub(crate) fn test_custom(custom: Vec<super::custom_providers::CredentialedProvider>) -> Self {
Self {
keys: Default::default(),
custom,
}
}
/// Test-only constructor for a single API-key platform.
@@ -156,6 +179,7 @@ impl PlatformApiKeys {
pub(crate) fn test_single(platform: kigi_models::PlatformId, key: &str) -> Self {
Self {
keys: std::collections::BTreeMap::from([(platform, key.to_owned())]),
custom: Vec::new(),
}
}
}
@@ -1938,7 +1962,7 @@ fn resolve_prefetch_env(kimi_code_config: Option<KimiCodeConfig>) -> Option<Pref
/// credentials from disk.
pub fn start_early_prefetch_with_auth(auth: Option<KimiAuth>) -> Option<EarlyPrefetchHandle> {
let env = resolve_prefetch_env_with_auth(auth)?;
Some(spawn_prefetch_thread(env))
spawn_prefetch_thread(env)
}
/// Start the model-catalog prefetch on a background thread.
@@ -1949,51 +1973,64 @@ pub fn start_early_prefetch(
kimi_code_config: Option<KimiCodeConfig>,
) -> Option<EarlyPrefetchHandle> {
let env = resolve_prefetch_env(kimi_code_config)?;
Some(spawn_prefetch_thread(env))
spawn_prefetch_thread(env)
}
fn spawn_prefetch_thread(env: PrefetchEnv) -> EarlyPrefetchHandle {
std::thread::spawn(move || {
let mut timer = crate::instrumentation_timer!("startup.early_prefetch");
let proxy_endpoint = env.endpoints.proxy_url();
timer.with_field("endpoint", proxy_endpoint.as_str());
// Resolve each stored subscription-OAuth session's bearer (refreshed
// on expiry) so those platforms are part of the STARTUP fetch plan —
// otherwise a claude-pro-max-only user boots onto the bundled Kimi
// table until some later async refresh happens to run, and the cache
// origin (which encodes enabled platforms) never matches the
// async-path's claude-inclusive origin.
let oauth_tokens = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map(|rt| {
rt.block_on(crate::agent::models_fetch::resolve_generic_oauth_tokens(
&crate::auth::oauth_registry::pool_home(),
))
})
.unwrap_or_default();
let models = prefetch_models_blocking(
&env.endpoints,
env.auth.as_ref(),
&oauth_tokens,
env.model_fetch_auth,
&env.platform_keys,
);
if (env.endpoints.deployment_key.is_some() || crate::managed_config::has_active_team_auth())
&& crate::config::is_managed_config_stale_for(
&crate::managed_config::current_serving_identity(),
)
&& crate::managed_config::is_fetch_enabled()
&& let Ok(rt) = tokio::runtime::Builder::new_current_thread()
/// `None` when the OS refuses a thread: the prefetch is an optimization and
/// runs before the runtime builds, so a spawn panic here would preempt the
/// runtime's own out-of-threads diagnostic.
fn spawn_prefetch_thread(env: PrefetchEnv) -> Option<EarlyPrefetchHandle> {
let spawned = std::thread::Builder::new()
.name("early-prefetch".into())
.spawn(move || {
let mut timer = crate::instrumentation_timer!("startup.early_prefetch");
let proxy_endpoint = env.endpoints.proxy_url();
timer.with_field("endpoint", proxy_endpoint.as_str());
// Resolve each stored subscription-OAuth session's bearer (refreshed
// on expiry) so those platforms are part of the STARTUP fetch plan —
// otherwise a claude-pro-max-only user boots onto the bundled Kimi
// table until some later async refresh happens to run, and the cache
// origin (which encodes enabled platforms) never matches the
// async-path's claude-inclusive origin.
let oauth_tokens = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
crate::managed_config::clear_orphan();
let _ = rt.block_on(crate::managed_config::sync());
}
.map(|rt| {
rt.block_on(crate::agent::models_fetch::resolve_generic_oauth_tokens(
&crate::auth::oauth_registry::pool_home(),
))
})
.unwrap_or_default();
let models = prefetch_models_blocking(
&env.endpoints,
env.auth.as_ref(),
&oauth_tokens,
env.model_fetch_auth,
&env.platform_keys,
);
if (env.endpoints.deployment_key.is_some()
|| crate::managed_config::has_active_team_auth())
&& crate::config::is_managed_config_stale_for(
&crate::managed_config::current_serving_identity(),
)
&& crate::managed_config::is_fetch_enabled()
&& let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
crate::managed_config::clear_orphan();
let _ = rt.block_on(crate::managed_config::sync());
}
EarlyPrefetchResult { models }
})
EarlyPrefetchResult { models }
});
match spawned {
Ok(handle) => Some(handle),
Err(e) => {
tracing::error!(error = %e, "early prefetch thread spawn failed; skipping prefetch");
None
}
}
}
/// Map a model id (catalog key or routing slug) to its catalog key.
@@ -3685,12 +3722,14 @@ mod tests {
"moonshot-cn".into(),
config::PlatformCredentialConfig {
api_key: Some("cfg-cn".into()),
..Default::default()
},
);
platforms.entries.insert(
"moonshot-ai".into(),
config::PlatformCredentialConfig {
api_key: Some("cfg-ai".into()),
..Default::default()
},
);
@@ -114,6 +114,12 @@ pub(crate) fn models_fetch_origin(
let parts: Vec<String> = enabled_platforms(has_oauth, oauth_tokens, platform_keys)
.into_iter()
.map(|p| format!("{}={}", p.as_str(), platform_models_url(p, endpoints)))
.chain(
platform_keys
.custom()
.iter()
.map(|c| format!("{}={}", c.provider.name, c.provider.models_url())),
)
.collect();
format!("platforms[{}]", parts.join(";"))
}
@@ -265,7 +271,7 @@ fn fetch_platform_models_blocking(
platform_keys: &crate::agent::models::PlatformApiKeys,
) -> Result<FetchModelsResult, BackendError> {
let enabled = enabled_platforms(auth.is_some(), oauth_tokens, platform_keys);
if enabled.is_empty() {
if enabled.is_empty() && platform_keys.custom().is_empty() {
return Err(BackendError::Auth(
"No platform credentials: log in with `kigi login`, paste a platform API key in \
the login screen (stored in ~/.kigi/auth.json), or set a platform env var such \
@@ -330,6 +336,28 @@ fn fetch_platform_models_blocking(
}
}
for credentialed in platform_keys.custom() {
match crate::agent::custom_providers::fetch_models_blocking(credentialed) {
Ok(custom_models) => {
tracing::info!(
provider = %credentialed.provider.name,
count = custom_models.len(),
"custom provider models fetch succeeded"
);
successes += 1;
models.extend(custom_models);
}
Err(e) => {
tracing::warn!(
provider = %credentialed.provider.name,
error = %e,
"custom provider models fetch failed"
);
last_error = Some(e);
}
}
}
if successes == 0 {
// All enabled platforms failed. When the failure includes an OAuth
// 401, return `Ok` with the flag set (and no models) so the async
@@ -613,6 +641,37 @@ pub(crate) fn platform_wire_model_to_entry(
platform: kigi_models::PlatformId,
wire: kigi_models::WireModel,
base_url: &str,
) -> crate::agent::config::ModelEntryConfig {
let env_key = (!platform.uses_oauth())
.then(|| crate::agent::config::EnvKeys::new(platform.api_key_env_names().iter().copied()));
// `supported_in_api: false` hides a model unless the PRIMARY session is
// an OAuth session (`ModelInfo::visible_for_auth`). Only `kimi-code` rides
// that primary session, so only it may be gated on it. Every other OAuth
// platform (claude-pro-max, openai-codex, github-copilot, xai-grok) carries
// its OWN pooled credential and its models only enter the catalog once THAT
// provider is signed in — gating them on the Kimi session would hide every
// model from a user who signed in with only a Claude/ChatGPT/Copilot/Grok
// subscription.
wire_model_to_entry(
platform.managed_model_key(&wire.id),
platform.wire_api(),
platform.key_header(),
env_key,
platform != kigi_models::PlatformId::KimiCode,
wire,
base_url,
)
}
/// Wire-to-catalog mapping shared by platforms and custom providers.
pub(crate) fn wire_model_to_entry(
id: String,
wire_api: kigi_models::PlatformWireApi,
key_header: kigi_models::PlatformKeyHeader,
env_key: Option<crate::agent::config::EnvKeys>,
supported_in_api: bool,
wire: kigi_models::WireModel,
base_url: &str,
) -> crate::agent::config::ModelEntryConfig {
let capabilities = wire.capabilities();
// Selectable thinking levels (live wire `think_efforts`, e.g. K3's
@@ -626,21 +685,19 @@ pub(crate) fn platform_wire_model_to_entry(
);
std::num::NonZeroU64::new(DEFAULT_CONTEXT_WINDOW).expect("non-zero")
});
let env_key = (!platform.uses_oauth())
.then(|| crate::agent::config::EnvKeys::new(platform.api_key_env_names().iter().copied()));
let api_backend = match platform.wire_api() {
let api_backend = match wire_api {
kigi_models::PlatformWireApi::ChatCompletions => {
crate::sampling::ApiBackend::ChatCompletions
}
kigi_models::PlatformWireApi::Responses => crate::sampling::ApiBackend::Responses,
kigi_models::PlatformWireApi::Messages => crate::sampling::ApiBackend::Messages,
};
let auth_scheme = match platform.key_header() {
let auth_scheme = match key_header {
kigi_models::PlatformKeyHeader::Bearer => None,
kigi_models::PlatformKeyHeader::XApiKey => Some(kigi_sampler::AuthScheme::XApiKey),
};
crate::agent::config::ModelEntryConfig {
id: Some(platform.managed_model_key(&wire.id)),
id: Some(id),
name: Some(wire.display_name.clone().unwrap_or_else(|| wire.id.clone())),
model: wire.id,
base_url: base_url.to_owned(),
@@ -674,15 +731,7 @@ pub(crate) fn platform_wire_model_to_entry(
inference_idle_timeout_secs: None,
max_retries: None,
hidden: false,
// `supported_in_api: false` hides a model unless the PRIMARY session is
// an OAuth session (`ModelInfo::visible_for_auth`). Only `kimi-code`
// rides that primary session, so only it may be gated on it. Every
// other OAuth platform (claude-pro-max, openai-codex, github-copilot,
// xai-grok) carries its OWN pooled credential, and its models only
// enter the catalog once THAT provider is signed in — gating them on
// the Kimi session would hide every model from a user who signed in
// with only a Claude/ChatGPT/Copilot/Grok subscription.
supported_in_api: platform != kigi_models::PlatformId::KimiCode,
supported_in_api,
supports_backend_search: false,
compactions_remaining: None,
compaction_at_tokens: None,
@@ -1473,7 +1522,7 @@ mod tests {
/// openai-codex fetch: the catalog is HARDCODED, so the fetch path
/// short-circuits BEFORE any HTTP — there is NO mock `/models` server, yet
/// the fetch returns exactly the 4 compiled-in models keyed
/// the fetch returns exactly the 7 compiled-in models keyed
/// `openai-codex/<slug>` on the Responses backend, ctx 272000, each exposing
/// its exact reasoning efforts (incl. the codex-only `xhigh`/`max`).
/// A BOGUS base URL confirms no live `/models` request is attempted (it would
@@ -1510,20 +1559,19 @@ mod tests {
.map(|m| m.id.as_deref().unwrap_or_default())
.collect::<Vec<_>>(),
vec![
"openai-codex/gpt-6.1-sol",
"openai-codex/gpt-6-astra",
"openai-codex/gpt-6-sol",
"openai-codex/gpt-6-luna",
"openai-codex/gpt-5.6-sol",
"openai-codex/gpt-5.6-terra",
"openai-codex/gpt-5.6-luna",
"openai-codex/gpt-5.5",
],
"exactly the 4 hardcoded models, keyed openai-codex/<slug>"
"exactly the 8 hardcoded models, keyed openai-codex/<slug>"
);
// Excluded models never appear.
for absent in [
"openai-codex/gpt-5.3-codex-spark",
"openai-codex/gpt-5.4",
"openai-codex/gpt-5.4-mini",
"openai-codex/codex-auto-review",
] {
for absent in ["openai-codex/gpt-reserve", "openai-codex/codex-auto-review"] {
assert!(
!result
.models
@@ -1532,7 +1580,14 @@ mod tests {
"{absent} must be absent from the hardcoded catalog"
);
}
let sol = &result.models[0];
let first = &result.models[0];
assert_eq!(first.name.as_deref(), Some("GPT-6.1-Sol"));
assert_eq!(first.context_window.get(), 272_000);
let sol = result
.models
.iter()
.find(|m| m.id.as_deref() == Some("openai-codex/gpt-5.6-sol"))
.unwrap();
assert_eq!(
sol.api_backend,
crate::sampling::ApiBackend::Responses,
@@ -557,6 +557,11 @@ impl acp::Agent for MvpAgent {
.oauth_platform()
{
self.authenticate_oauth_platform(platform, arguments).await
} else if let Some(name) = crate::agent::custom_providers::login_provider_name(
arguments.method_id.0.as_ref(),
) {
self.authenticate_custom_provider(name, arguments.method_id.clone())
.await
} else if let Some(platform) =
auth_method::platform_for_method_id(&arguments.method_id)
{
@@ -1725,8 +1730,8 @@ impl acp::Agent for MvpAgent {
return Ok(acp::PromptResponse::new(acp::StopReason::EndTurn));
}
}
let intake_lock = self.prompt_intake_lock(&arguments.session_id);
let intake_guard = intake_lock.lock().await;
let dispatch_lock = self.dispatch_lock(&arguments.session_id);
let dispatch_guard = dispatch_lock.lock().await;
let meta_prompt_mode = arguments
.meta
.as_ref()
@@ -1830,7 +1835,7 @@ impl acp::Agent for MvpAgent {
acp::Error::internal_error()
.data(format!("failed to dispatch prompt to session: {e}"))
})?;
drop(intake_guard);
drop(dispatch_guard);
self.push_roster_activity_delta(
&arguments.session_id,
crate::agent::roster::RosterActivity::Working,
@@ -2025,6 +2030,11 @@ impl acp::Agent for MvpAgent {
.and_then(|m| m.get("rewindIfPristine"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Ordered behind any in-flight prompt intake: a cancel that
// overtakes the prompt it targets lands on an idle actor, and the
// prompt then starts a turn nothing will ever cancel.
let dispatch_lock = self.dispatch_lock(&args.session_id);
let _dispatch_guard = dispatch_lock.lock().await;
let _ = handle
.cmd_tx
.send(SessionCommand::Cancel {
@@ -526,6 +526,43 @@ impl MvpAgent {
Some("platform_key_invalid_or_missing"),
);
})?;
Ok(self.finish_api_key_login(method_id).await)
}
/// `authenticate(custom:<name>)`: validate the saved provider's key.
pub(super) async fn authenticate_custom_provider(
&self,
name: &str,
method_id: acp::AuthMethodId,
) -> Result<AuthenticateResponse, acp::Error> {
let keys =
crate::agent::models::PlatformApiKeys::resolve_from_effective_config();
let auth_err = |message: String| {
let mut err = acp::Error::auth_required();
err.message = message;
err
};
let outcome = match keys.custom().iter().find(|c| c.provider.name == name) {
Some(credentialed) => {
crate::agent::custom_providers::validate_key(credentialed).await
}
None => Err(format!(
"No custom provider named {name} with an API key in config.toml or auth.json"
)),
};
if let Err(message) = outcome {
tracing::warn!(provider = name, %message, "custom provider login failed");
emit_login_span(
false,
method_id.0.as_ref(),
None,
Some("custom_provider_invalid_or_missing"),
);
return Err(auth_err(message));
}
Ok(self.finish_api_key_login(method_id).await)
}
/// Shared tail of an API-key login.
async fn finish_api_key_login(&self, method_id: acp::AuthMethodId) -> AuthenticateResponse {
// Rebuild the catalog from the on-disk config: the rebuild freshly
// resolves platform keys (env > auth.json > config), so the key just
// persisted to auth.json is stamped onto the platform's entries; a
@@ -557,7 +594,7 @@ impl MvpAgent {
let meta = serde_json::to_value(auth_meta)
.ok()
.and_then(|v| v.as_object().cloned());
Ok(AuthenticateResponse::new().meta(meta))
AuthenticateResponse::new().meta(meta)
}
/// `authenticate(<generic-oauth platform id>)`: interactive device-code
/// login for a `uses_oauth` platform carrying an `OAuthConfig` (xai-grok).
@@ -978,7 +1015,10 @@ impl MvpAgent {
sessions: RefCell::new(HashMap::new()),
activity,
loading_sessions: RefCell::new(HashMap::new()),
prompt_intake_locks: RefCell::new(HashMap::new()),
dispatch_locks: RefCell::new(HashMap::new()),
subagent_limits:
kigi_tools::implementations::kigi::task::admission::SubagentLimits::from_env(),
subagent_spawn_permits: RefCell::new(HashMap::new()),
session_threads: RefCell::new(HashMap::new()),
resident_roster_titles: RefCell::new(HashMap::new()),
initialize_request: OnceLock::new(),
@@ -1362,7 +1402,8 @@ impl MvpAgent {
/// `FlushAndAck` on the persistence actor — a true sync barrier that only
/// resolves after all queued writes (chat messages, updates) hit disk.
///
/// Returns `Ok(())` on success, `Err(reason)` on timeout or channel failure.
/// Returns `Ok(())` once the writes are on disk, `Err(reason)` on a failed
/// flush, timeout or channel failure.
pub(crate) async fn flush_session(
&self,
session_id: &acp::SessionId,
@@ -1381,7 +1422,11 @@ impl MvpAgent {
return Err("send failed");
}
match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await {
Ok(Ok(_)) => Ok(()),
Ok(Ok(Ok(()))) => Ok(()),
Ok(Ok(Err(error))) => {
tracing::error!(session_id = %session_id.0, %error, "persistence flush failed");
Err("persistence flush failed")
}
Ok(Err(_)) => Err("channel closed"),
Err(_) => Err("timeout"),
}
@@ -2183,6 +2228,7 @@ impl MvpAgent {
.cfg
.borrow()
.resolve_compaction_verbatim_input();
let compaction_tool_choice = self.cfg.borrow().resolve_compaction_tool_choice();
let two_pass_enabled = self.cfg.borrow().is_two_pass_compaction_enabled();
let auto_update = self.cfg.borrow().cli.auto_update;
let client_type = *self.client_type.borrow();
@@ -2568,6 +2614,7 @@ impl MvpAgent {
system_prompt_label,
compaction_mode,
compaction_verbatim_input,
compaction_tool_choice,
two_pass_enabled,
buffering_settings,
origin_client.clone(),
@@ -462,21 +462,30 @@ pub struct MvpAgent {
loading_sessions: RefCell<
HashMap<acp::SessionId, tokio::sync::watch::Receiver<bool>>,
>,
/// Per-session prompt-intake serialization lock. LEADER-SAFE(per-session):
/// keyed by SessionId, mirrors `sessions` lifecycle.
///
/// Each incoming `session/prompt` RPC is dispatched as its own task by the
/// ACP message loop, and [`Self::prompt`] runs an async preamble (prompt-mode
/// query, trace context, model lookup) BEFORE it enqueues
/// `SessionCommand::Prompt` onto the actor's FIFO mailbox. Without
/// serialization those preambles interleave across tasks, so the mailbox —
/// and therefore the authoritative prompt queue — receives prompts out of
/// submission order. `prompt()` holds this lock across the preamble and
/// releases it immediately after the enqueue (the turn itself runs unlocked),
/// which makes intake order match arrival order.
prompt_intake_locks: RefCell<
/// Per-session lock ordering dispatch onto the actor's mailbox:
/// [`Self::prompt`] holds it across its intake preamble (prompt-mode
/// query, trace context, model lookup) and releases it right after the
/// enqueue, so prompts land in submission order; `cancel` holds it
/// around its `Cancel` send, so a cancel cannot overtake the prompt it
/// targets and wedge the turn slot (see
/// `cancel_never_overtakes_in_flight_prompt_intake`). Cancels wait out
/// preambles held ahead of them — keep preambles lean.
/// LEADER-SAFE(per-session): mirrors `sessions` lifecycle.
dispatch_locks: RefCell<
HashMap<acp::SessionId, std::rc::Rc<tokio::sync::Mutex<()>>>,
>,
/// Session-scoped subagent spawn limits, read once from env at
/// construction (composition root).
subagent_limits: kigi_tools::implementations::kigi::task::admission::SubagentLimits,
/// Per-parent-session spawn semaphores sized to `subagent_limits`; a
/// wide fan-out queues on the semaphore instead of exhausting file
/// descriptors. Keyed by session id string (the coordinator's
/// `parent_session_id`). Entries deliberately live for the whole
/// process: a removed-then-resumed session keeps its id, so sharing one
/// semaphore across generations keeps the limit correct while old
/// children still run. Growth is bounded by distinct sessions per
/// process and each entry is tiny. LEADER-SAFE(per-session).
subagent_spawn_permits: RefCell<HashMap<String, std::sync::Arc<tokio::sync::Semaphore>>>,
/// LEADER-SAFE(per-session): keyed by SessionId. Mirrors `sessions` lifecycle.
session_threads: RefCell<HashMap<acp::SessionId, SessionThread>>,
/// Title per resident session id, refreshed each `build_roster`. Lets the
@@ -717,10 +726,18 @@ pub struct MvpAgent {
/// sampling client is discarded, but the TLS root certificates it loads
/// are cached at the process level by `rustls-native-certs`.
pub fn warm_async_http_client() {
std::thread::spawn(|| {
let _timer = crate::instrumentation_timer!("startup.async_http_warmup");
let _ = crate::http::shared_client();
});
// Recoverable spawn: the warmup is an optimization that runs before the
// runtime builds — an out-of-threads host must reach the runtime's own
// diagnostic, not panic here.
let spawned = std::thread::Builder::new()
.name("http-warmup".into())
.spawn(|| {
let _timer = crate::instrumentation_timer!("startup.async_http_warmup");
let _ = crate::http::shared_client();
});
if let Err(e) = spawned {
tracing::warn!(error = %e, "async HTTP warmup thread spawn failed; first request warms instead");
}
}
pub(crate) fn resolve_required_agent_type(
model_agent_type: Option<&str>,
@@ -38,19 +38,16 @@ impl MvpAgent {
/// drops the `session_live_state` entry so that map stays bounded.
pub(crate) fn remove_session(&self, id: &acp::SessionId) {
self.sessions.borrow_mut().remove(id);
self.prompt_intake_locks.borrow_mut().remove(id);
self.dispatch_locks.borrow_mut().remove(id);
self.session_threads.borrow_mut().remove(id);
self.session_index_claims.borrow_mut().remove(id);
self.require_gateway_sessions.borrow_mut().remove(id);
self.session_live_state.borrow_mut().remove(id);
}
/// Get-or-create the per-session prompt-intake lock (see
/// [`Self::prompt_intake_locks`]).
pub(super) fn prompt_intake_lock(
&self,
id: &acp::SessionId,
) -> std::rc::Rc<tokio::sync::Mutex<()>> {
self.prompt_intake_locks
/// Get-or-create the per-session dispatch lock (see
/// [`Self::dispatch_locks`]). Cheap clone of the shared `Rc`.
pub(super) fn dispatch_lock(&self, id: &acp::SessionId) -> std::rc::Rc<tokio::sync::Mutex<()>> {
self.dispatch_locks
.borrow_mut()
.entry(id.clone())
.or_default()
@@ -272,6 +272,19 @@ impl MvpAgent {
self.try_build_subagent_spawn_context(parent_session_id)
.expect("parent session must exist when spawning subagents")
}
/// Get-or-create the parent session's spawn semaphore (see
/// [`Self::subagent_spawn_permits`]). Cheap clone of the shared `Arc`.
fn subagent_spawn_permit(&self, parent_session_id: &str) -> std::sync::Arc<tokio::sync::Semaphore> {
self.subagent_spawn_permits
.borrow_mut()
.entry(parent_session_id.to_string())
.or_insert_with(|| {
std::sync::Arc::new(tokio::sync::Semaphore::new(
self.subagent_limits.effective_max_concurrent(),
))
})
.clone()
}
/// Fallible variant of [`Self::build_subagent_spawn_context`]: returns
/// `None` when the parent `SessionHandle` is absent (evicted / torn down)
/// instead of panicking, so read-only paths that can race a teardown can
@@ -387,6 +400,8 @@ impl MvpAgent {
};
Some(crate::agent::subagent::SubagentSpawnContext {
lsp: parent_lsp,
spawn_limits: self.subagent_limits,
spawn_permits: self.subagent_spawn_permit(parent_session_id),
gateway: self.gateway.clone(),
client_hooks: Default::default(),
sampling_config: self.sampling_config.borrow().clone(),
@@ -2152,6 +2152,59 @@ fn cancel_does_not_forward_to_bridge_in_local_mode() {
);
});
}
/// Regression (post-cancel slot hang): a cancel racing the prompt it targets
/// must land on the actor mailbox AFTER that prompt, or it hits an idle
/// actor as a no-op and the prompt then starts a turn nothing cancels.
/// The dispatch lock (`dispatch_locks`) provides that ordering.
#[test]
fn cancel_never_overtakes_in_flight_prompt_intake() {
use crate::session::SessionCommand;
use acp::Agent as _;
run_local_for_bridge_test(|| async {
let agent = build_minimal_agent_for_tests();
let sid = acp::SessionId::new("sess-cancel-intake-race");
let (handle, _tx, mut cmd_rx) = make_live_session_handle(&sid, None);
agent.sessions.borrow_mut().insert(sid.clone(), handle);
let order: std::rc::Rc<std::cell::RefCell<Vec<&'static str>>> =
std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let (intake_parked_tx, intake_parked_rx) = tokio::sync::oneshot::channel::<()>();
let driver_order = order.clone();
tokio::task::spawn_local(async move {
let mut intake_parked_tx = Some(intake_parked_tx);
while let Some(cmd) = cmd_rx.recv().await {
match cmd {
SessionCommand::GetCurrentPromptMode { .. } => {
if let Some(tx) = intake_parked_tx.take() {
let _ = tx.send(());
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
SessionCommand::Prompt { .. } => driver_order.borrow_mut().push("prompt"),
SessionCommand::Cancel { .. } => driver_order.borrow_mut().push("cancel"),
_ => {}
}
}
});
let prompt_fut = agent.prompt(acp::PromptRequest::new(
sid.clone(),
vec![acp::ContentBlock::from("hi")],
));
let cancel_fut = async {
intake_parked_rx
.await
.expect("prompt intake reaches the fake actor");
let _ = agent
.cancel(acp::CancelNotification::new(sid.clone()))
.await;
};
let _ = futures::join!(prompt_fut, cancel_fut);
assert_eq!(
order.borrow().as_slice(),
["prompt", "cancel"],
"cancel must land on the actor mailbox after the prompt it targets"
);
});
}
use crate::session::SessionCommand as TestSessionCommand;
/// Build a session handle wired to a *live* command channel. Returns the
/// handle (move into `sessions`) plus a probe `cmd_tx`/`cmd_rx` so a test
@@ -27,7 +27,7 @@ async fn subagent_spawn_context_inherits_parent_permission_handle() {
kigi_workspace::permission::spawn_permission_manager(
sid.clone(),
gateway,
cwd,
cwd.clone(),
kigi_workspace::permission::types::ClientType::Generic,
Some(PermissionConfig::new(vec![PermissionRule {
action: RuleAction::Deny,
@@ -64,6 +64,8 @@ async fn subagent_spawn_context_inherits_parent_permission_handle() {
Some("child-session".to_owned()),
Some("general-purpose".to_owned()),
Some("permission inheritance regression".to_owned()),
cwd.clone(),
None,
)
.await;
assert!(
@@ -19,6 +19,99 @@ use kigi_tools::implementations::kigi::task::types::*;
use kigi_workspace::file_system::AsyncFileSystem;
use kigi_hunk_tracker::HunkTrackerHandle;
use super::*;
/// Bounds each parent-side await in the child completion path so a starved parent `cmd_rx` cannot park a completed child forever.
pub(super) const PARENT_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// Bounds reads of the child's own actors, which freeze with a tool that blocks the child thread; teardown must still reach `Shutdown`.
pub(super) const CHILD_ACTOR_ACK_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(10);
/// One bounded child-actor query, degrading to `fallback` when the actor never answers.
pub(super) async fn child_actor_query<T>(
what: &'static str,
query: impl std::future::Future<Output = T>,
fallback: T,
) -> T {
match tokio::time::timeout(CHILD_ACTOR_ACK_TIMEOUT, query).await {
Ok(value) => value,
Err(_) => {
tracing::warn!(query = what, "child actor did not answer in time; using fallback");
fallback
}
}
}
/// Bounds the post-Shutdown worktree snapshot (a `git add` behind a stuck clean filter must not park completion).
const WORKTREE_SNAPSHOT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
/// Snapshot the child worktree to `ref_name`, persist the ref, then remove the worktree; returns `(snapshot_ref, removed)`.
async fn dispose_subagent_worktree(
worktree: &Path,
source_repo: &Path,
ref_name: &str,
meta_dir: &Path,
final_status: &str,
subagent_id: &str,
) -> (Option<String>, bool) {
let snapshot = tokio::time::timeout(
WORKTREE_SNAPSHOT_TIMEOUT,
crate::session::worktree::snapshot_subagent_worktree(worktree, source_repo, ref_name),
)
.await;
let snapshot_ref = match snapshot {
Ok(Ok(snapshot_ref)) => snapshot_ref,
Ok(Err(e)) => {
tracing::warn!(
subagent_id = % subagent_id, worktree_path = % worktree.display(), error = % e,
"Failed to snapshot subagent worktree; preserving for review"
);
return (None, false);
}
Err(_) => {
tracing::warn!(
subagent_id = % subagent_id, worktree_path = % worktree.display(),
"worktree snapshot did not finish in time; preserving worktree for resume"
);
return (None, false);
}
};
if !update_subagent_meta_snapshot_ref(meta_dir, &snapshot_ref, final_status) {
tracing::warn!(
subagent_id = % subagent_id, worktree_path = % worktree.display(),
"snapshot_ref not persisted; preserving worktree for resume"
);
return (None, false);
}
// Removal is awaited in full: a detached deletion could race a resume that rehydrates into this path.
match crate::session::worktree::remove_subagent_worktree(worktree).await {
Ok(()) => {
tracing::info!(
subagent_id = % subagent_id, worktree_path = % worktree.display(),
"snapshotted and removed subagent worktree"
);
(Some(snapshot_ref), true)
}
Err(e) => {
tracing::warn!(
subagent_id = % subagent_id, worktree_path = % worktree.display(), error = % e,
"snapshotted subagent worktree but removal failed; ref persisted for resume"
);
(Some(snapshot_ref), false)
}
}
}
/// Await a parent ack for at most `PARENT_ACK_TIMEOUT`; `false` when it never answers or drops the sender.
pub(super) async fn parent_ack(what: &'static str, ack: oneshot::Receiver<()>) -> bool {
match tokio::time::timeout(PARENT_ACK_TIMEOUT, ack).await {
Ok(Ok(())) => true,
Ok(Err(_)) => false,
Err(_) => {
tracing::warn!(ack = what, "parent did not ack in time; proceeding to teardown");
false
}
}
}
pub(super) fn task_model_override_error(
requested: Option<&str>,
provenance: ModelOverrideProvenance,
@@ -81,6 +174,64 @@ pub(crate) async fn handle_subagent_request(
}
_ => {}
}
// Session-scoped concurrency admission: hold one permit for the whole
// child run so a wide fan-out queues (default) or fails (configured)
// instead of exhausting file descriptors. Queued spawns are not yet
// visible in the coordinator; they admit in FIFO order.
use kigi_tools::implementations::kigi::task::admission::LimitBehavior;
let _spawn_permit = match ctx.spawn_limits.behavior {
LimitBehavior::Fail => match ctx.spawn_permits.clone().try_acquire_owned() {
Ok(permit) => permit,
Err(_) => {
let limit = ctx.spawn_limits.effective_max_concurrent();
kigi_log::unified_log::warn(
"subagent.spawn.rejected_at_concurrent_limit",
Some(&ctx.parent_session_id),
Some(serde_json::json!({ "limit": limit })),
);
let msg = format!(
"Session subagent limit reached ({limit} running); \
retry after one finishes, or raise KIGI_MAX_CONCURRENT_SUBAGENTS"
);
send_pre_spawn_failure(request, &msg, coordinator, &ctx, gateway);
return;
}
},
LimitBehavior::Queue => {
if ctx.spawn_permits.available_permits() == 0 {
kigi_log::unified_log::warn(
"subagent.spawn.queued_at_concurrent_limit",
Some(&ctx.parent_session_id),
Some(serde_json::json!({
"limit": ctx.spawn_limits.effective_max_concurrent(),
"subagent_id": request.id,
})),
);
}
ctx.spawn_permits
.clone()
.acquire_owned()
.await
.expect("subagent spawn semaphore is never closed")
}
};
// The parent can end while a spawn is queued on the semaphore (a queued
// request is not yet in the coordinator, so the teardown cancel sweep
// cannot see it). A closed parent command channel is the teardown
// signal. The channel closes when the actor finishes shutting down, so a
// permit granted during that brief drain can still slip through — the
// same in-flight window an ordinary spawn racing teardown already has;
// closing it fully would require queueing inside the coordinator.
if ctx.parent_cmd_tx.as_ref().is_some_and(|tx| tx.is_closed()) {
send_pre_spawn_failure(
request,
"Parent session ended while the spawn was queued at the subagent limit.",
coordinator,
&ctx,
gateway,
);
return;
}
let run_in_background = request.run_in_background
|| definition.background.unwrap_or(false);
let cancel_token = CancellationToken::new();
@@ -837,13 +988,22 @@ pub(crate) async fn handle_subagent_request(
}
}
}
let agent_mcp_servers: Vec<_> = if is_plugin_agent {
if !definition.mcp_servers.is_empty() {
tracing::warn!(
agent = % definition.name, plugin = ? definition.plugin_name,
"ignoring mcpServers on plugin agent (not supported for security)"
);
}
let agent_mcp_servers: Vec<_> = if definition.mcp_servers.is_empty() {
vec![]
} else if is_plugin_agent {
tracing::warn!(
agent = % definition.name, plugin = ? definition.plugin_name,
"ignoring mcpServers on plugin agent (not supported for security)"
);
vec![]
} else if !crate::agent::folder_trust::agent_inline_hooks_allowed(definition.scope, || {
crate::agent::folder_trust::project_scope_allowed(&ctx.parent_cwd)
}) {
// An untrusted repo's inline stdio MCP command would run like an inline hook.
tracing::warn!(
agent = % definition.name,
"ignoring mcpServers on untrusted project agent (folder not trusted; re-run with --trust)"
);
vec![]
} else {
definition
@@ -1006,6 +1166,7 @@ pub(crate) async fn handle_subagent_request(
kigi_agent::DEFAULT_SYSTEM_PROMPT_LABEL.to_string(),
kigi_chat_state::CompactionMode::Summary,
ctx.resolve_compaction_verbatim_input(),
ctx.resolve_compaction_tool_choice(),
false,
None,
None,
@@ -1274,7 +1435,12 @@ pub(crate) async fn handle_subagent_request(
let duration_ms = start.elapsed().as_millis() as u64;
let mut result = match wait_outcome {
SubagentWaitOutcome::Cancelled => {
let (tool_calls, turns) = signals_snapshot_counts(&child_handle).await;
let (tool_calls, turns) = child_actor_query(
"signals_snapshot_counts",
signals_snapshot_counts(&child_handle),
(0, 0),
)
.await;
SubagentResult {
success: false,
cancelled: true,
@@ -1301,14 +1467,28 @@ pub(crate) async fn handle_subagent_request(
},
),
) => (snapshot.current.tool_call_count, snapshot.current.turn_count),
_ => signals_snapshot_counts(&child_handle).await,
_ => {
child_actor_query(
"signals_snapshot_counts",
signals_snapshot_counts(&child_handle),
(0, 0),
)
.await
}
};
let final_text = child_handle
.chat_state_handle
.get_last_assistant_text()
.await
.unwrap_or_default();
let result_tokens = child_handle.chat_state_handle.get_total_tokens().await;
let final_text = child_actor_query(
"get_last_assistant_text",
child_handle.chat_state_handle.get_last_assistant_text(),
None,
)
.await
.unwrap_or_default();
let result_tokens = child_actor_query(
"get_total_tokens",
child_handle.chat_state_handle.get_total_tokens(),
0,
)
.await;
match *turn_result {
Ok(
Ok(
@@ -1468,14 +1648,21 @@ pub(crate) async fn handle_subagent_request(
let final_status = result.status().to_string();
let snapshot_dispose_enabled = ctx.resolve_subagent_worktree_snapshot_enabled();
let telemetry_tokens = if result.tool_calls > 0 || result.success {
child_handle.chat_state_handle.get_total_tokens().await
child_actor_query(
"get_total_tokens",
child_handle.chat_state_handle.get_total_tokens(),
0,
)
.await
} else {
0
};
let (subagent_usage_by_model, subagent_usage_incomplete) = match child_handle
.chat_state_handle
.try_get_session_usage()
.await
let (subagent_usage_by_model, subagent_usage_incomplete) = match child_actor_query(
"try_get_session_usage",
child_handle.chat_state_handle.try_get_session_usage(),
Err(()),
)
.await
{
Ok(u) => (Some(u.by_model.into_iter().collect::<Vec<_>>()), u.incomplete),
Err(()) => (None, true),
@@ -1494,7 +1681,7 @@ pub(crate) async fn handle_subagent_request(
respond_to,
})
{
Ok(()) => ack.await.is_ok(),
Ok(()) => parent_ack("RecordSubagentUsage", ack).await,
Err(_) => false,
}
} else {
@@ -1511,53 +1698,71 @@ pub(crate) async fn handle_subagent_request(
.parent_prompt_id
.clone()
.or_else(|| coordinator.borrow().parent_prompt_id_for(&request.id));
if let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref() {
// A slow parent may apply both the fold and this mark; both are idempotent.
let marked_by_parent = if let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref() {
let (respond_to, ack) = tokio::sync::oneshot::channel();
if cmd_tx
cmd_tx
.send(crate::session::commands::SessionCommand::MarkSubagentUsageNotApplied {
parent_prompt_id: sticky_prompt,
parent_prompt_id: sticky_prompt.clone(),
respond_to,
})
.is_ok()
{
let _ = ack.await;
}
} else if let Some(ref pid) = sticky_prompt {
&& parent_ack("MarkSubagentUsageNotApplied", ack).await
} else {
false
};
if !marked_by_parent && let Some(ref pid) = sticky_prompt {
coordinator.borrow_mut().mark_subagent_usage_not_applied(pid);
}
}
match (&ctx.parent_terminal_backend, &ctx.parent_notification_handle) {
(Some(parent_tb), Some(parent_notif_handle)) => {
if !request.surface_completion {
let reparented_task_ids: Vec<String> = parent_tb
.list_tasks()
.await
.into_iter()
.filter(|t| {
!t.completed
&& t.owner_session_id.as_deref()
== Some(&*child_session_id.0)
})
.map(|t| t.task_id)
.collect();
if !reparented_task_ids.is_empty()
&& let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref()
{
let _ = cmd_tx
.send(SessionCommand::RecordGoalTurnTaskIds {
task_ids: reparented_task_ids,
});
match tokio::time::timeout(PARENT_ACK_TIMEOUT, parent_tb.list_tasks()).await {
Ok(tasks) => {
let reparented_task_ids: Vec<String> = tasks
.into_iter()
.filter(|t| {
!t.completed
&& t.owner_session_id.as_deref()
== Some(&*child_session_id.0)
})
.map(|t| t.task_id)
.collect();
if !reparented_task_ids.is_empty()
&& let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref()
{
let _ = cmd_tx.send(SessionCommand::RecordGoalTurnTaskIds {
task_ids: reparented_task_ids,
});
}
}
Err(_) => tracing::warn!(
child_session_id = % child_session_id.0,
parent_session_id = % ctx.parent_session_id,
"list_tasks not answered in time; skipping goal-turn task-id recording"
),
}
}
let parent_backend_weak = std::sync::Arc::downgrade(parent_tb);
parent_tb
.reparent_notifications(
if tokio::time::timeout(
PARENT_ACK_TIMEOUT,
parent_tb.reparent_notifications(
&child_session_id.0,
&ctx.parent_session_id,
parent_notif_handle.clone(),
parent_backend_weak,
)
.await;
),
)
.await
.is_err()
{
tracing::warn!(
child_session_id = % child_session_id.0,
parent_session_id = % ctx.parent_session_id,
"reparent_notifications not acked in time; proceeding to child shutdown"
);
}
}
(Some(_), None) | (None, Some(_)) => {
tracing::warn!(
@@ -1578,55 +1783,17 @@ pub(crate) async fn handle_subagent_request(
if snapshot_dispose_enabled {
let ref_name = format!("refs/kigi/subagents/{}", request.id);
let source_repo = resolve_subagent_source_repo(&ctx);
match crate::session::worktree::snapshot_subagent_worktree(
wt_path,
&source_repo,
&ref_name,
)
.await
{
Ok(snapshot_ref) => {
let persisted = update_subagent_meta_snapshot_ref(
&subagent_meta_dir,
&snapshot_ref,
&final_status,
);
if persisted {
disposed_snapshot_ref = Some(snapshot_ref);
match crate::session::worktree::remove_subagent_worktree(wt_path)
.await
{
Ok(()) => {
worktree_removed = true;
tracing::info!(
subagent_id = % request.id, worktree_path = % wt_path
.display(), "snapshotted and removed subagent worktree"
);
}
Err(e) => {
tracing::warn!(
subagent_id = % request.id, worktree_path = % wt_path
.display(), error = % e,
"snapshotted subagent worktree but removal failed; ref persisted for resume"
)
}
}
} else {
tracing::warn!(
subagent_id = % request.id, worktree_path = % wt_path
.display(),
"snapshot_ref not persisted; preserving worktree for resume"
);
}
}
Err(e) => {
tracing::warn!(
subagent_id = % request.id, worktree_path = % wt_path.display(),
error = % e,
"Failed to snapshot subagent worktree; preserving for review"
);
}
}
let (snapshot_ref, removed) = dispose_subagent_worktree(
wt_path,
&source_repo,
&ref_name,
&subagent_meta_dir,
&final_status,
&request.id,
)
.await;
disposed_snapshot_ref = snapshot_ref;
worktree_removed = removed;
} else {
tracing::info!(
subagent_id = % request.id, worktree_path = % wt_path.display(),
@@ -133,6 +133,13 @@ impl AutoCompactThresholdTiers {
pub(crate) struct SubagentSpawnContext {
/// Parent's LSP runtime — inherited via ToolContext, same as fs/terminal.
pub lsp: Option<std::sync::Arc<dyn kigi_tools::implementations::lsp::LspBackend>>,
/// Session-scoped spawn limits (env-resolved once at the composition root).
pub spawn_limits: kigi_tools::implementations::kigi::task::admission::SubagentLimits,
/// Parent session's spawn semaphore, sized to
/// `spawn_limits.effective_max_concurrent()`. A permit is held for the
/// whole child run, so a wide fan-out queues (or fails, per behavior)
/// instead of exhausting file descriptors.
pub spawn_permits: std::sync::Arc<tokio::sync::Semaphore>,
#[expect(
dead_code,
reason = "unused in production; remove expect when wired or delete the item"
@@ -397,6 +404,21 @@ impl SubagentSpawnContext {
.resolve()
.value
}
/// Subagent summarizer `tool_choice`, mirroring
/// `Config::resolve_compaction_tool_choice` (env > config > remote
/// settings > default `auto`).
pub fn resolve_compaction_tool_choice(&self) -> crate::util::config::CompactionToolChoice {
crate::util::config::resolve_compaction_tool_choice_from(
crate::agent::config::env_string(crate::util::config::ENV_COMPACTION_TOOL_CHOICE)
.as_deref(),
self.agent_config
.as_ref()
.and_then(|c| c.features.compaction_tool_choice.as_deref()),
self.remote_settings
.as_ref()
.and_then(|r| r.compaction_tool_choice.as_deref()),
)
}
/// Whether a completed subagent's worktree is snapshotted into a durable ref
/// and its directory deleted. Resolution mirrors the other subagent gates
/// (env > config > remote settings > default). Default `false` so it ships dark;
@@ -857,6 +879,20 @@ fn log_subagent_model_resolution(
)),
);
}
/// The parent model's auth scheme from the live catalog.
fn parent_auth_scheme(
available: &indexmap::IndexMap<String, crate::agent::config::ModelEntry>,
catalog_id: &acp::ModelId,
slug: &str,
) -> kigi_sampler::AuthScheme {
crate::agent::models::entry_for_slug(available, Some(catalog_id.0.as_ref()), slug)
.map(|entry| crate::agent::config::auth_facts_of(entry).auth_scheme)
.or_else(|| {
crate::agent::config::try_resolve_model_credentials(slug, None).map(|r| r.auth_scheme)
})
.unwrap_or_default()
}
/// Read the parent session's actual current sampling config.
///
/// Prefers the live state from `ChatStateHandle` (authoritative). Falls back
@@ -875,9 +911,7 @@ async fn read_parent_sampling_config(
creds.alpha_test_key.as_deref(),
&cfg.base_url,
);
let auth_scheme = crate::agent::config::try_resolve_model_credentials(&cfg.model, None)
.map(|r| r.auth_scheme)
.unwrap_or_default();
let auth_scheme = parent_auth_scheme(&ctx.available_models, &ctx.model_id, &cfg.model);
// Claude Pro/Max OAuth Messages adaptation inherits from the parent
// model's platform (claude-pro-max → true); every other platform,
// and BYOK, → false, so the API-key paths stay byte-identical.
@@ -3298,3 +3298,48 @@ fn spawn_test_parent_chat_state(model_slug: &str) -> kigi_chat_state::ChatStateH
)
}
mod rest;
/// A blocked child actor must not park teardown: the read degrades to the fallback in bounded time.
#[tokio::test(start_paused = true)]
async fn child_actor_query_is_bounded_when_the_actor_never_answers() {
use super::handle_request::{CHILD_ACTOR_ACK_TIMEOUT, child_actor_query};
let tokens = tokio::time::timeout(
20 * CHILD_ACTOR_ACK_TIMEOUT,
child_actor_query("test_query", std::future::pending::<u64>(), 7),
)
.await
.expect("child-actor queries must complete in bounded time");
assert_eq!(tokens, 7, "a starved query must degrade to the fallback");
}
/// A parent that never services `cmd_rx` must not park a completed child.
#[tokio::test(start_paused = true)]
async fn parent_ack_is_bounded_when_the_parent_never_answers() {
use super::handle_request::{PARENT_ACK_TIMEOUT, parent_ack};
let (_never_answered, ack) = tokio::sync::oneshot::channel::<()>();
let acked = tokio::time::timeout(20 * PARENT_ACK_TIMEOUT, parent_ack("test_ack", ack))
.await
.expect("parent acks must complete in bounded time");
assert!(!acked, "an unanswered ack must report a miss");
}
/// A fetched Messages model exists only in the live catalog.
#[test]
fn a_fetched_entry_gives_its_own_scheme_to_the_child() {
let wire: kigi_models::WireModel =
serde_json::from_value(serde_json::json!({ "id": "claude-x" })).unwrap();
let cfg = crate::agent::models_fetch::wire_model_to_entry(
"gw/claude-x".into(),
kigi_models::PlatformWireApi::Messages,
kigi_models::PlatformKeyHeader::XApiKey,
None,
true,
wire,
"https://gw.example/v1",
);
let available = indexmap::IndexMap::from([(
"gw/claude-x".to_owned(),
crate::agent::config::ModelEntry::from_config_entry(&cfg),
)]);
let scheme = parent_auth_scheme(&available, &acp::ModelId::new("gw/claude-x"), "claude-x");
assert_eq!(scheme, kigi_sampler::AuthScheme::XApiKey);
}
@@ -202,6 +202,15 @@ impl CredentialAuthority {
/// Deliberately NOT [`crate::util::is_first_party_url`], which is
/// production-only and would break every custom deployment.
fn is_session_coding_endpoint(&self, base_url: &str) -> bool {
// Declared custom providers are never the session's endpoint.
if self
.endpoints
.custom_provider_bases
.iter()
.any(|base| crate::util::matches_trusted_base_url(base_url, base))
{
return false;
}
if crate::util::is_effective_coding_endpoint_url(base_url) {
return true;
}
@@ -422,6 +431,38 @@ mod tests {
}
}
/// LEAK guard: a declared loopback custom provider never gets the primary.
#[test]
fn declared_custom_provider_hosts_never_receive_the_primary() {
let (_d, kimi) = primary("kimi-tok");
let endpoints = EndpointsConfig {
custom_provider_bases: vec![
"http://localhost:11434/v1".to_owned(),
"https://gw.example/v1".to_owned(),
],
..EndpointsConfig::default()
};
let auth = authority(endpoints, kimi);
for url in [
"http://localhost:11434/v1",
"http://localhost:11434/v1/chat/completions",
"https://gw.example/v1",
] {
assert_eq!(
auth.credential_class(None, url),
CredentialClass::None,
"{url}"
);
assert!(auth.credential_for(None, url).is_none(), "{url}");
assert!(auth.manager_for(None, url).is_none(), "{url}");
}
assert_eq!(
auth.credential_class(None, "http://localhost:3000/v1"),
CredentialClass::Primary,
"an undeclared loopback proxy keeps the session bearer"
);
}
/// LEAK guard: every API-key registry platform, and any platform-less model
/// on a third-party host, gets NO session credential and NO manager.
#[test]
@@ -32,6 +32,7 @@ pub use manager::{AuthManager, shared_api_key_provider};
pub use meta::AuthMeta;
pub use model::{AuthMode, KimiAuth, lookup_auth};
pub(crate) use model::{TOKEN_TTL, is_expired, token_suffix};
pub(crate) use storage::store_scoped_api_key;
pub use storage::{
clear_api_key, read_api_key, read_auth_json, read_platform_api_key, read_token_by_scope,
store_api_key, store_platform_api_key,
+164 -13
View File
@@ -224,6 +224,18 @@ impl AuthFileLock {
pub fn read_auth_json(auth_file: &Path) -> std::io::Result<AuthStore> {
let mut file = File::open(auth_file)?;
// Tighten world-readable copies (hand-restored, umask edge cases, etc.)
// right after open, so even an unreadable/invalid file gets fixed.
// Best-effort: a chmod failure must not block login/read paths.
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(auth_file) {
tracing::warn!(
path = %auth_file.display(),
error = %e,
"auth: failed to enforce owner-only permissions on auth.json"
);
}
let mut contents = String::new();
file.read_to_string(&mut contents)?;
@@ -289,6 +301,14 @@ pub(crate) fn backup_corrupt_auth_file(path: &Path) -> Option<PathBuf> {
match std::fs::rename(path, &backup) {
Ok(()) => {
// Corrupt backups still hold token material — keep them owner-only.
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(&backup) {
tracing::warn!(
path = %backup.display(),
error = %e,
"auth: failed to tighten permissions on corrupt-auth backup"
);
}
tracing::warn!(
original = %path.display(),
backup = %backup.display(),
@@ -421,18 +441,59 @@ fn write_store_to(path: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
.into_inner()
.map_err(|e| e.into_error())?
.sync_all()?;
#[cfg(windows)]
{
crate::util::secure_file::set_windows_secure_permissions(path)?;
// `open_secure_file` mode bits apply only on create; tighten existing
// paths. Best-effort after durable content: a chmod-only failure must not
// look like a failed write — the in-place fallback restores the prior
// snapshot on any `write_store_to` Err, which would discard freshly
// written tokens. The load path re-tightens on the next read.
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(path) {
tracing::warn!(
error = %e,
path = %path.display(),
"auth: failed to ensure owner-only permissions after write"
);
}
Ok(())
}
/// Atomic write: tmp + Windows-safe replace (see `util::fs::replace_file`).
fn write_auth_json_atomic(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
let tmp = auth_file.with_extension(format!("json.{}.tmp", std::process::id()));
write_store_to(&tmp, auth_store)?;
crate::util::fs::replace_file(&tmp, auth_file)
write_auth_json_atomic_with(auth_file, auth_store, write_store_to)
}
/// Inner of [`write_auth_json_atomic`] with the tmp-file writer injectable so
/// the torn-tmp-write path is unit-testable without real I/O faults.
fn write_auth_json_atomic_with(
auth_file: &Path,
auth_store: &AuthStore,
write: fn(&Path, &AuthStore) -> std::io::Result<()>,
) -> std::io::Result<()> {
// Unique per write (pid + monotonic seq): two concurrent in-process
// writers (e.g. background mint + proactive refresher) must not share
// one tmp path, or each can tear the other's half-written bytes.
static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let tmp = auth_file.with_extension(format!(
"json.{}.{}.tmp",
std::process::id(),
TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
if let Err(e) = write(&tmp, auth_store) {
// Reclaim the partial tmp; the unique name otherwise accumulates
// one orphan per failed write. `replace_file` cleans its own leg.
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
crate::util::fs::replace_file(&tmp, auth_file)?;
// Re-assert on the final path (covers rename edge cases / FS quirks).
// Best-effort: the replace already published the new tokens.
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(auth_file) {
tracing::warn!(
error = %e,
path = %auth_file.display(),
"auth: failed to ensure owner-only permissions after replace"
);
}
Ok(())
}
/// Non-atomic fallback: truncate and rewrite `auth.json` in place.
@@ -486,10 +547,7 @@ fn restore_prior_bytes(auth_file: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut file = open_secure_file(auth_file)?;
file.write_all(bytes)?;
file.sync_all()?;
#[cfg(windows)]
{
crate::util::secure_file::set_windows_secure_permissions(auth_file)?;
}
crate::util::secure_file::ensure_owner_only_permissions(auth_file)?;
Ok(())
}
@@ -561,6 +619,15 @@ pub fn store_platform_api_key(
),
));
}
store_scoped_api_key(kigi_home, platform.as_str(), api_key)
}
/// Stores a key under `scope`; callers own the name.
pub(crate) fn store_scoped_api_key(
kigi_home: &Path,
scope: &str,
api_key: &str,
) -> std::io::Result<()> {
let path = kigi_home.join("auth.json");
// Serialize with the manager's cross-process auth.json writers (token
// refresh holds the same flock): an unlocked read-modify-write here
@@ -583,7 +650,7 @@ pub fn store_platform_api_key(
};
let mut map = read_auth_json_or_empty_recovering_corrupt(&path)?;
map.insert(
platform.as_str().to_owned(),
scope.to_owned(),
KimiAuth {
key: api_key.to_owned(),
auth_mode: AuthMode::ApiKey,
@@ -804,7 +871,87 @@ mod write_fallback_tests {
);
}
/// Rollback after a failed write must keep the file owner-only (0o600).
/// An in-place rewrite over a pre-existing world-readable auth.json must
/// tighten it to 0o600 — `open_secure_file` mode bits apply only on
/// create, so truncating an existing loose file keeps its old mode. The
/// in-place path is the one that reuses the inode (the atomic path
/// replaces it with a fresh 0o600 temp file).
#[cfg(unix)]
#[test]
fn in_place_write_tightens_preexisting_world_readable_auth_json() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
std::fs::write(&path, b"{}").unwrap();
let mut loose = std::fs::metadata(&path).unwrap().permissions();
loose.set_mode(0o644);
std::fs::set_permissions(&path, loose).unwrap();
write_auth_json_in_place(&path, &sample_store()).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(
mode & 0o777,
0o600,
"in-place rewrite must tighten preexisting open perms"
);
}
/// Simulates a torn tmp write: partial bytes land at the target path,
/// then the write fails — the temp file must not be left behind.
fn fake_partial_write_then_fail(path: &Path, _: &AuthStore) -> std::io::Result<()> {
std::fs::write(path, b"{ torn")?;
Err(std::io::Error::from(std::io::ErrorKind::StorageFull))
}
/// A tmp write that fails partway must reclaim the temp file; the unique
/// tmp name otherwise accumulates one orphan per failed write.
#[test]
fn atomic_write_reclaims_tmp_when_store_write_fails() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
let err = write_auth_json_atomic_with(&path, &sample_store(), fake_partial_write_then_fail)
.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::StorageFull);
let orphans: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
.collect();
assert!(
orphans.is_empty(),
"failed tmp write must not leave temp files behind: {orphans:?}"
);
}
/// The replace step failing (target path occupied by a directory) must
/// also leave no temp file — `replace_file` reclaims its tmp on failure.
#[test]
fn atomic_write_reclaims_tmp_when_replace_fails() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
std::fs::create_dir(&path).unwrap();
assert!(
write_auth_json_atomic(&path, &sample_store()).is_err(),
"replacing a directory must fail"
);
let orphans: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
.collect();
assert!(
orphans.is_empty(),
"failed replace must not leave temp files behind: {orphans:?}"
);
}
/// Rollback after a failed write must leave the file owner-only (0o600)
/// even when the file had loose permissions going in — the restore path
/// truncates the existing inode, which keeps its old mode unless
/// explicitly tightened.
#[cfg(unix)]
#[test]
fn in_place_restore_is_owner_only() {
@@ -812,9 +959,13 @@ mod write_fallback_tests {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
write_auth_json_in_place(&path, &sample_store()).unwrap();
let mut loose = std::fs::metadata(&path).unwrap().permissions();
loose.set_mode(0o644);
std::fs::set_permissions(&path, loose).unwrap();
let _ = write_auth_json_in_place_with(&path, &sample_store(), fake_truncate_then_fail);
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "restored file must stay 0o600");
assert_eq!(mode & 0o777, 0o600, "restored file must be 0o600");
}
}
@@ -638,6 +638,7 @@ fn write_import_marker(config_path: &Path) -> anyhow::Result<()> {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let config_path = &crate::util::config::config_write_dest(config_path)?;
let tmp = config_path.with_extension("toml.tmp");
// Best-effort cleanup of the .tmp file if either write or rename fails so
// a failed marker write doesn't leave a stale artefact next to the real
@@ -820,6 +821,7 @@ fn apply_items_to_config(config_path: &Path, items: &[ImportableItem]) -> anyhow
if count > 0 {
let toml_str = toml::to_string_pretty(&root)?;
let config_path = &crate::util::config::config_write_dest(config_path)?;
let tmp = config_path.with_extension("toml.tmp");
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
+18 -7
View File
@@ -1034,16 +1034,27 @@ pub fn apply_sandbox(
.and_then(|p| dunce::canonicalize(p).ok())
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| std::path::PathBuf::from("."));
if kigi_sandbox::restricts_home_writes(&sandbox_profile, &workspace) {
// The profile leaves ~/.kigi read-only outside its state dirs: mint
// what first use would otherwise write after enforcement.
if let Err(e) = crate::auth::device::device_id() {
tracing::warn!(error = %e, "sandbox: device id not created before enforcement");
}
kigi_tools::implementations::kigi::grep::ripgrep::rg_path();
crate::builtin::extract_bundled_files(&crate::util::kigi_home::kigi_home());
}
// The devbox `/data` write-deny is a bwrap mount; Landlock would grant the workspace under `/data`.
#[cfg(target_os = "linux")]
let requires_read_deny = kigi_sandbox::requires_read_deny(&sandbox_profile, &workspace);
let bwrap_required = kigi_sandbox::requires_read_deny(&sandbox_profile, &workspace)
|| kigi_sandbox::requires_data_write_deny(&sandbox_profile, &workspace);
#[cfg(target_os = "linux")]
{
let refuse_unprotected = |detail: &str| {
eprintln!(
"error: this sandbox could not enforce its read-deny set on Linux \
(bubblewrap missing/unusable, or a deny glob exceeded its expansion \
limit — see any message above). Install bubblewrap with \
`apt install -y bubblewrap` if needed. Refusing to start with denied \
"error: this sandbox could not enforce its read-deny set or the devbox \
`/data` write-deny on Linux (bubblewrap missing/unusable, or a deny glob \
exceeded its expansion limit — see any message above). Install bubblewrap \
with `apt install -y bubblewrap` if needed. Refusing to start with denied \
paths unprotected.{detail}"
);
};
@@ -1051,7 +1062,7 @@ pub fn apply_sandbox(
Some(mut cmd) => {
use std::os::unix::process::CommandExt;
let err = cmd.exec();
if requires_read_deny {
if bwrap_required {
refuse_unprotected(&format!(" (bwrap exec failed: {err})"));
std::process::exit(1);
}
@@ -1061,7 +1072,7 @@ pub fn apply_sandbox(
Install bubblewrap: apt install -y bubblewrap"
);
}
None if requires_read_deny && !kigi_sandbox::is_inside_bwrap() => {
None if bwrap_required && !kigi_sandbox::is_inside_bwrap() => {
refuse_unprotected("");
std::process::exit(1);
}
@@ -37,9 +37,9 @@ async fn handle_compact(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
respond_to: tx,
});
}
// The session's own ACP error goes through unchanged: a Debug wrapper would escape its detail.
rx.await
.map_err(|_| acp::Error::internal_error().data("session failed to respond"))?
.map_err(|e| acp::Error::internal_error().data(format!("Internal error: {:?}", e)))?;
.map_err(|_| acp::Error::internal_error().data("session failed to respond"))??;
to_raw_response(&CompactConversationResponse {})
}
@@ -691,6 +691,11 @@ pub enum SessionUpdate {
/// does not support reasoning effort or no effort override was applied.
#[serde(default, skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
/// The session's effective context window after the switch, so a
/// follower repaints its header without waiting for the next turn.
/// `None` from an agent that predates the field.
#[serde(default, skip_serializing_if = "Option::is_none")]
context_window: Option<u64>,
},
/// Streaming chunk of a tool call's arguments.
///
@@ -1970,15 +1975,18 @@ mod tests {
let with_effort = SessionUpdate::ModelChanged {
model_id: "kigi-4".into(),
reasoning_effort: Some("high".into()),
context_window: Some(262_144),
};
let json = serde_json::to_value(&with_effort).unwrap();
assert_eq!(json["sessionUpdate"], "model_changed");
assert_eq!(json["model_id"], "kigi-4");
assert_eq!(json["reasoning_effort"], "high");
assert_eq!(json["context_window"], 262_144);
let without_effort = SessionUpdate::ModelChanged {
model_id: "kigi-3".into(),
reasoning_effort: None,
context_window: None,
};
let json = serde_json::to_value(&without_effort).unwrap();
assert_eq!(json["sessionUpdate"], "model_changed");
@@ -1988,6 +1996,7 @@ mod tests {
"reasoning_effort: None must be skipped on the wire so old pagers \
and third-party ACP clients see a smaller, no-extra-keys payload"
);
assert!(json.get("context_window").is_none());
}
/// `ModelChanged` round-trips through JSON: a follower client deserializes
@@ -2000,6 +2009,7 @@ mod tests {
let original = SessionUpdate::ModelChanged {
model_id: "kigi-4".into(),
reasoning_effort: Some("medium".into()),
context_window: Some(1_000_000),
};
let json_str = serde_json::to_string(&original).unwrap();
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
@@ -2021,6 +2031,7 @@ mod tests {
update: SessionUpdate::ModelChanged {
model_id: "kigi-4".into(),
reasoning_effort: None,
context_window: None,
},
meta: None,
};
@@ -1605,6 +1605,7 @@ mod tests {
}
#[test]
#[serial_test::serial]
fn describe_requirements_file_flags_invalid_version_overrides_as_parse_error() {
// Valid TOML but invalid `[[version_overrides]]` is rejected by the real
// loader, so it must read "parse error", not "empty".
@@ -513,6 +513,7 @@ pub fn apply_at(plan: &KimiImportPlan, kigi_home: &Path) -> anyhow::Result<KimiA
if applied.total_added() > 0 {
// Atomic write: tmp + rename (same pattern as save_mcp_server_config).
let toml_str = toml::to_string_pretty(&root)?;
let config_path = crate::util::config::config_write_dest(&config_path)?;
let tmp = config_path.with_extension("toml.tmp");
if let Err(e) = std::fs::write(&tmp, &toml_str) {
let _ = std::fs::remove_file(&tmp);
+13 -4
View File
@@ -52,10 +52,16 @@ pub fn default_lock_path_in(root: &Path) -> PathBuf {
root.join("leader.lock")
}
/// Runtime files live under `~/.kigi/run`: the one home subtree a strict
/// sandbox leaves writable for them.
pub(super) fn runtime_root() -> PathBuf {
kigi_home().join("run")
}
/// Effective leader lock path: the [`LEADER_SOCKET_ENV`] override's sibling
/// `.lock` when set, else the default under kigi home.
/// `.lock` when set, else the default under `~/.kigi/run`.
pub fn default_lock_path() -> PathBuf {
resolve_lock_path(leader_socket_override(), &kigi_home())
resolve_lock_path(leader_socket_override(), &runtime_root())
}
pub fn default_socket_path_in(root: &Path) -> PathBuf {
@@ -63,9 +69,9 @@ pub fn default_socket_path_in(root: &Path) -> PathBuf {
}
/// Effective leader socket path: the [`LEADER_SOCKET_ENV`] override when set,
/// else the default under kigi home.
/// else the default under `~/.kigi/run`.
pub fn default_socket_path() -> PathBuf {
resolve_socket_path(leader_socket_override(), &kigi_home())
resolve_socket_path(leader_socket_override(), &runtime_root())
}
/// The instance suffix encoded in a lock/socket file-name pair
@@ -145,6 +151,9 @@ impl LeaderLock {
}
fn open_lock_file(&self) -> Result<File, LockError> {
if let Some(parent) = self.lock_path.parent() {
std::fs::create_dir_all(parent)?;
}
Ok(OpenOptions::new()
.read(true)
.write(true)
+68 -5
View File
@@ -23,7 +23,7 @@
//! │ │ - Tracks session ownership for routing ││
//! │ └────────────────────────┬────────────────────────────────┘│
//! └───────────────────────────┼──────────────────────────────────┘
//! │ IPC (Unix socket at ~/.kigi/leader.sock)
//! │ IPC (Unix socket at ~/.kigi/run/leader.sock)
//! ┌───────────────────┼───────────────────┐
//! ▼ ▼ ▼
//! ┌───────────────┐ ┌───────────────┐ ┌───────────────┐
@@ -481,7 +481,10 @@ async fn discover_leaders_in(root: &Path) -> Vec<LeaderDescriptor> {
entries
}
pub async fn discover_leaders() -> Vec<LeaderDescriptor> {
discover_leaders_in(&crate::util::kigi_home::kigi_home()).await
let mut entries = discover_leaders_in(&lock::runtime_root()).await;
// Pre-0.1.15 leaders and socket overrides keep their files at the home root.
entries.extend(discover_leaders_in(&crate::util::kigi_home::kigi_home()).await);
entries
}
/// (pid, leader_binary_version) of socket-verified (Reachable) leaders; a
/// stale-lock-only descriptor is skipped (its `pid_from_lock` may be recycled).
@@ -610,13 +613,28 @@ fn resolve_matching_descriptors(
fn resolve_target_from_descriptors(
target: LeaderTarget,
leaders: Vec<LeaderDescriptor>,
current_default_socket: &Path,
) -> Result<LeaderTargetSelection, LeaderTargetError> {
match target {
LeaderTarget::Default => {
let matching: Vec<_> = leaders
let defaults: Vec<_> = leaders
.into_iter()
.filter(|descriptor| descriptor.is_default)
.collect();
// An upgrade can leave a pre-0.1.15 default at the home root; the
// current default socket wins while it exists.
let current: Vec<_> = defaults
.iter()
.filter(|descriptor| {
descriptor.socket_path.as_deref() == Some(current_default_socket)
})
.cloned()
.collect();
let matching = if current.is_empty() {
defaults
} else {
current
};
resolve_matching_descriptors("default", matching)
}
LeaderTarget::Socket(socket_path) => {
@@ -685,7 +703,7 @@ pub async fn resolve_leader_target(
target: LeaderTarget,
) -> Result<LeaderTargetSelection, LeaderTargetError> {
let leaders = discover_leaders().await;
resolve_target_from_descriptors(target, leaders)
resolve_target_from_descriptors(target, leaders, &lock::default_socket_path())
}
#[derive(Debug, thiserror::Error)]
pub enum ConnectionError {
@@ -1315,7 +1333,12 @@ fn spawn_leader_subprocess() -> Result<u32, ConnectionError> {
}
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null());
let log_path = crate::util::kigi_home::kigi_home().join("leader.log");
let log_path = crate::util::kigi_home::kigi_home()
.join("logs")
.join("leader.log");
if let Some(parent) = log_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::File::create(&log_path) {
Ok(log_file) => {
info!("Leader stderr → log file");
@@ -1444,6 +1467,46 @@ mod tests {
vec![(222, "0.2.52".to_string())]
);
}
fn reachable_default(socket: &str) -> LeaderDescriptor {
let socket_path = PathBuf::from(socket);
LeaderDescriptor {
pid_from_lock: Some(1),
lock_path: Some(socket_path.with_extension("lock")),
socket_path: Some(socket_path.clone()),
socket_suffix: String::new(),
classification: LeaderDiscoveryState::Reachable,
is_default: true,
live_info: Some(LiveLeaderInfo {
pid: 1,
socket_path,
lock_path: PathBuf::new(),
socket_suffix: String::new(),
leader_protocol_version: 1,
leader_binary_version: "0.1.15".to_string(),
}),
target_error: None,
}
}
#[test]
fn default_target_prefers_the_current_socket_over_a_legacy_default() {
let legacy = reachable_default("/h/.kigi/leader.sock");
let current = reachable_default("/h/.kigi/run/leader.sock");
let current_socket = PathBuf::from("/h/.kigi/run/leader.sock");
let picked = resolve_target_from_descriptors(
LeaderTarget::Default,
vec![current.clone(), legacy.clone()],
&current_socket,
)
.expect("current default resolves");
assert_eq!(picked.descriptor.socket_path, current.socket_path);
let fallback = resolve_target_from_descriptors(
LeaderTarget::Default,
vec![legacy.clone()],
&current_socket,
)
.expect("legacy default still resolves alone");
assert_eq!(fallback.descriptor.socket_path, legacy.socket_path);
}
#[test]
fn leader_is_older_than_directional() {
assert!(leader_is_older_than("0.1.0", "0.2.0"));
@@ -1146,6 +1146,9 @@ pub async fn run_leader_server(
control_state: LeaderServerControlState,
) -> Result<(), ServerError> {
let _ = std::fs::remove_file(&socket_path);
if let Some(parent) = socket_path.parent() {
std::fs::create_dir_all(parent)?;
}
let shutdown_reason_rx = shutdown_tx.subscribe();
let listener = LeaderListener::bind(&socket_path)?;
info!("Leader server listening");
@@ -171,6 +171,70 @@ pub fn error_detail_from_data(data: &serde_json::Value) -> Option<String> {
.map(str::to_owned)
}
/// The human-readable text of an ACP error: its `data` detail when present (Display would JSON-escape it), else the message.
pub fn acp_error_message(err: &acp::Error) -> String {
err.data
.as_ref()
.and_then(error_detail_from_data)
.unwrap_or_else(|| err.message.clone())
}
/// First line of a compaction error without internal `compact failed:` prefixes, capped for a banner; diagnostic lines (request URLs) are dropped.
pub fn user_facing_compact_error(raw: &str) -> String {
const INTERNAL_PREFIX: &str = "compact failed:";
const MAX_CHARS: usize = 200;
// Redact before line selection: a line break inside a URL must not hide its `@`.
let raw = redact_urls_in_text(raw);
let mut rest = raw.trim();
while rest
.get(..INTERNAL_PREFIX.len())
.is_some_and(|head| head.eq_ignore_ascii_case(INTERNAL_PREFIX))
{
rest = rest[INTERNAL_PREFIX.len()..].trim_start();
}
rest.lines()
.next()
.unwrap_or_default()
.trim()
.chars()
.take(MAX_CHARS)
.collect()
}
/// Every canonical `scheme://` URL in `text` loses userinfo, sensitive query values and fragment; one that fails to parse is not echoed.
fn redact_urls_in_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(sep) = rest.find("://") {
// The delimiter before the scheme may be multi-byte (`“`, `:`): step past its UTF-8 length.
let scheme_start = rest[..sep]
.char_indices()
.rev()
.find(|(_, c)| !(c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')))
.map_or(0, |(at, c)| at + c.len_utf8());
out.push_str(&rest[..scheme_start]);
// The authority may hold whitespace (a configured password); the token ends at whitespace after it.
let authority = &rest[sep + 3..];
let authority_end = authority.find(['/', '?', '#']).unwrap_or(authority.len());
let token_end = authority[authority_end..]
.find(char::is_whitespace)
.map_or(rest.len(), |at| sep + 3 + authority_end + at);
let token = &rest[scheme_start..token_end];
let url_text = token.trim_end_matches([')', ']', '}', ',', '.', ';', '\'', '"', '>']);
match url::Url::parse(url_text) {
Ok(mut url) => {
kigi_secrets::redact_url(&mut url);
out.push_str(url.as_str());
}
Err(_) => out.push_str("<unparseable url>"),
}
out.push_str(&token[url_text.len()..]);
rest = &rest[token_end..];
}
out.push_str(rest);
out
}
pub fn http_status_from_error(err: &acp::Error) -> Option<u16> {
err.data
.as_ref()?
@@ -254,6 +318,77 @@ pub fn prompt_complete_fields(
#[cfg(test)]
mod tests {
#[test]
fn compact_error_text_keeps_the_first_decoded_line_only() {
let err = super::acp::Error::internal_error().data(
"compact failed: API error (status 500): overloaded\n\nRequest URL: http://u:TEST_PASSWORD@127.0.0.1/v1",
);
let detail = super::user_facing_compact_error(&super::acp_error_message(&err));
assert_eq!(detail, "API error (status 500): overloaded");
assert!(!detail.contains("TEST_PASSWORD"));
assert_eq!(
super::user_facing_compact_error(&"x".repeat(500))
.chars()
.count(),
200
);
assert_eq!(
super::acp_error_message(&super::acp::Error::internal_error()),
"Internal error"
);
}
/// A sampler 401 names the endpoint on the retained line; its userinfo must not survive.
#[test]
fn compact_error_text_redacts_url_credentials_on_the_first_line() {
for password in [
"TEST_PASSWORD",
"TEST PASSWORD",
"TEST%20PASSWORD",
"TEST_PASSWORD\n",
"TEST_PASSWORD\r\n",
] {
let raw = format!(
"compact failed: Unauthorized (401) from http://u:{password}@127.0.0.1:33263/401/v1/chat/completions: bad auth"
);
assert_eq!(
super::user_facing_compact_error(&raw),
"Unauthorized (401) from http://127.0.0.1:33263/401/v1/chat/completions: bad auth",
"{password:?}"
);
}
assert_eq!(
super::redact_urls_in_text("see https://a:b@h.example/x and http://h2/"),
"see https://h.example/x and http://h2/"
);
}
/// A credential carried as a query value survives no failure shape: 401 message or transport error.
#[test]
fn compact_error_text_redacts_query_credentials_in_every_failure_shape() {
let unauthorized = "compact failed: Unauthorized (401) from http://127.0.0.1:1/?access_token=TEST_QUERY_TOKEN&route=/v1/chat/completions: bad auth";
let transport = "compact failed: request error: error sending request for url (http://127.0.0.1:1/?access_token=TEST_QUERY_TOKEN&route=/500/v1/chat/completions)";
for raw in [unauthorized, transport] {
let detail = super::user_facing_compact_error(raw);
assert!(!detail.contains("TEST_QUERY_TOKEN"), "{detail}");
assert!(detail.contains("route="), "{detail}");
}
assert!(super::user_facing_compact_error("see http://:://").contains("<unparseable url>"));
}
/// A multi-byte delimiter right before the URL must not break the scan.
#[test]
fn compact_error_text_handles_unicode_before_a_url() {
for raw in [
"compact failed: Service unavailable. See “https://u:TEST_PASSWORD@status.example/”.",
"compact failed: 请参阅:https://u:TEST_PASSWORD@status.example/",
] {
let detail = super::user_facing_compact_error(raw);
assert!(!detail.contains("TEST_PASSWORD"), "{detail}");
assert!(detail.contains("https://status.example/"), "{detail}");
}
}
use super::*;
use reqwest::StatusCode;
@@ -1518,6 +1518,9 @@ mod load_user_prompts_tests;
#[path = "acp_session_tests/memory_config_tests.rs"]
mod memory_config_tests;
#[cfg(test)]
#[path = "acp_session_tests/model_switch_window_tests.rs"]
mod model_switch_window_tests;
#[cfg(test)]
#[path = "acp_session_tests/parallel_dispatch_tests.rs"]
mod parallel_dispatch_tests;
#[cfg(test)]
@@ -10,7 +10,7 @@ impl SessionActor {
apply_prompt_override: bool,
skip_prompt_rewrite: bool,
auto_compact_threshold_percent: u8,
) -> Result<acp::ModelId, acp::Error> {
) -> Result<(acp::ModelId, std::num::NonZeroU64), acp::Error> {
let model_id = acp::ModelId::new(sampling_config.model.clone());
// H4: record the picker's catalog KEY as this SESSION's own selection.
// `sampling_config.model` is the ambiguous bare slug; the key is what
@@ -120,7 +120,7 @@ impl SessionActor {
agent_name: Some(agent_name),
reasoning_effort: Some(sampling_config.reasoning_effort),
});
Ok(model_id)
Ok((model_id, new_context_window))
}
/// Handle [`SessionCommand::RebuildAgentForDefinition`].
///
@@ -548,17 +548,10 @@ impl SessionActor {
if report.changed() && !dry_run {
// Flush barrier: success must mean the rewrite is on disk.
let (flush_tx, flush_rx) = tokio::sync::oneshot::channel();
if self
.notifications
.persistence_tx
.send(PersistenceMsg::FlushAndAck {
respond_to: flush_tx,
})
.is_err()
|| flush_rx.await.is_err()
{
anyhow::bail!("history repaired in memory but the persistence flush failed");
if let Err(error) = self.persistence_barrier().await {
anyhow::bail!(
"history repaired in memory but the persistence flush failed: {error}"
);
}
tracing::warn!(
session_id = %self.session_info.id.0,
@@ -264,6 +264,16 @@ impl SessionActor {
fn aux_model_auth_facts(&self, model_id: &str) -> crate::agent::config::ModelAuthFacts {
self.resolve_auth_facts(model_id, false)
}
/// Facts from the live catalog entry the session samples against.
fn live_auth_facts(&self, model_id: &str) -> Option<crate::agent::config::ModelAuthFacts> {
let models = self.models_manager.models();
crate::agent::models::entry_for_slug(
&models,
self.selected_catalog_key().as_deref(),
model_id,
)
.map(crate::agent::config::auth_facts_of)
}
/// Shared body of [`Self::model_auth_facts`] / [`Self::aux_model_auth_facts`].
/// `memoize` is the ONLY difference, so the two can never resolve differently.
fn resolve_auth_facts(
@@ -278,7 +288,9 @@ impl SessionActor {
{
return *facts;
}
let fresh = crate::agent::config::resolve_model_auth_facts(model_id);
let fresh = self
.live_auth_facts(model_id)
.unwrap_or_else(|| crate::agent::config::resolve_model_auth_facts(model_id));
if fresh.byok == ModelByok::Unknown {
if let Some((cached_id, facts)) = self.model_auth_facts.borrow().as_ref()
&& cached_id == model_id
@@ -112,6 +112,7 @@ pub(crate) async fn spawn_session_actor(
system_prompt_label: String,
compaction_mode: kigi_chat_state::CompactionMode,
compaction_verbatim_input: bool,
compaction_tool_choice: crate::util::config::CompactionToolChoice,
two_pass_enabled: bool,
buffering_settings: Option<BufferingSettings>,
origin_client: Option<crate::http::OriginClientInfo>,
@@ -1032,6 +1033,7 @@ pub(crate) async fn spawn_session_actor(
previous_model: std::cell::Cell::new(None),
compaction_mode,
verbatim_input: compaction_verbatim_input,
tool_choice: compaction_tool_choice,
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -1535,6 +1537,7 @@ pub(crate) async fn spawn_session_on_thread(
system_prompt_label: String,
compaction_mode: kigi_chat_state::CompactionMode,
compaction_verbatim_input: bool,
compaction_tool_choice: crate::util::config::CompactionToolChoice,
two_pass_enabled: bool,
buffering_settings: Option<BufferingSettings>,
origin_client: Option<crate::http::OriginClientInfo>,
@@ -1636,10 +1639,23 @@ pub(crate) async fn spawn_session_on_thread(
};
(initial_last_compaction, initial_prompt_texts)
};
let rt = tokio::runtime::Builder::new_current_thread()
// Runtime construction acquires fds (epoll/kqueue, waker) and
// fails with EMFILE/EAGAIN under resource pressure — report
// through the init channel instead of panicking the thread.
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("session runtime");
{
Ok(rt) => rt,
Err(e) => {
tracing::error!(
error = %e,
"failed to build session runtime (resource exhaustion?)"
);
let _ = init_tx.send(Err(kigi_agent::AgentBuildError::RuntimeBuild(e)));
return;
}
};
let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move {
let _trace_span = parent_traceparent.as_ref().map(|tp| {
@@ -1679,6 +1695,7 @@ pub(crate) async fn spawn_session_on_thread(
system_prompt_label,
compaction_mode,
compaction_verbatim_input,
compaction_tool_choice,
two_pass_enabled,
buffering_settings,
origin_client,
@@ -1759,8 +1776,19 @@ pub(crate) async fn spawn_session_on_thread(
}));
let _ = session_done_rx.await;
});
})
.expect("spawn session thread");
});
let join_handle = match join_handle {
Ok(handle) => handle,
Err(e) => {
tracing::error!(
error = %e,
"failed to spawn session thread (thread/PID limit or memory pressure?)"
);
return Err(
acp::Error::internal_error().data(format!("failed to spawn session thread: {e}"))
);
}
};
let init = init_rx
.await
.map_err(|_| {
@@ -957,6 +957,8 @@ impl SessionActor {
Some(self.session_info.id.0.to_string()),
None,
None,
self.tool_context.cwd.clone(),
self.display_cwd.get().map(std::path::PathBuf::from),
)
.await
};
Loaded 100 of 268 files, more files were not shown because too many files have changed in this diff. Show more