Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fb971e9aa | ||
|
|
a2ad08fb52 | ||
|
|
b9ca80e801 | ||
|
|
5f249ba6ca | ||
|
|
d331b0b2a3 | ||
|
|
09867915ef | ||
|
|
edce333620 | ||
|
|
d1c29a576c | ||
|
|
17d4ba08f9 | ||
|
|
901203c2b2 | ||
|
|
d8609267a1 | ||
|
|
a7b8438881 | ||
|
|
584d0fb77e | ||
|
|
4512df79e6 | ||
|
|
91f14112bf | ||
|
|
fc7113ba3c | ||
|
|
00a5e318bb | ||
|
|
017a570d7d | ||
|
|
63d26d4a3e | ||
|
|
5f7416f3ef | ||
|
|
fb3fe89d33 | ||
|
|
aa9a5dbe72 | ||
|
|
7a60cd42ac | ||
|
|
97c934a647 | ||
|
|
cadc46f5dc | ||
|
|
b957e12370 | ||
|
|
ff92a00fea | ||
|
|
f7a4cb64ab | ||
|
|
39cb1ca1bc | ||
|
|
e83610db6a | ||
|
|
fd5cff2de6 | ||
|
|
427e8b6fea | ||
|
|
50669bf302 | ||
|
|
25d1a4f980 | ||
|
|
a441b23a6d | ||
|
|
574122884a | ||
|
|
97de661554 | ||
|
|
bf93091416 | ||
|
|
26d26e15c9 | ||
|
|
1ae803519e | ||
|
|
6a4bff0d45 | ||
|
|
6e0464fa74 | ||
|
|
257caed650 | ||
|
|
3e1652ca98 | ||
|
|
479fb8cbbd | ||
|
|
9fb82291b2 | ||
|
|
8bdfb551b6 | ||
|
|
8864b9ecc5 | ||
|
|
553ec1c620 | ||
|
|
f656627c10 | ||
|
|
11c3ca9803 | ||
|
|
bac8470c80 | ||
|
|
74402f3078 | ||
|
|
649c5f5641 | ||
|
|
ad3840f9ec | ||
|
|
ac23ebc9a1 | ||
|
|
a07d889ad9 | ||
|
|
16328e55f9 | ||
|
|
9edb8729ef | ||
|
|
ed8049cf77 | ||
|
|
867b3e110b |
No files matched your search
@@ -1,3 +1,10 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
.DS_Store
|
||||
|
||||
# frontend/ (kigicli.dev worker)
|
||||
.wrangler/
|
||||
node_modules/
|
||||
|
||||
# Runner-setup prompt; holds a Gitea registration token once filled in.
|
||||
/prompt.txt
|
||||
@@ -14,16 +14,32 @@ 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 and through `3794978` (2026-09-09, ≈v1.0.24) in the
|
||||
0.1.15 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.
|
||||
|
||||
## Hard constraints
|
||||
|
||||
- **Zero egress**: outbound connections are limited to
|
||||
`auth.kimi.com`, `api.kimi.com`, `api.moonshot.cn`, `api.moonshot.ai`,
|
||||
GitHub Releases domains, user-configured MCP servers, the endpoints of
|
||||
provider platforms the user has credentialed, and `models.dev` (model
|
||||
metadata refresh — reached ONLY when an enabled platform's `/models` wire
|
||||
lacks metadata, `wire_serves_metadata=false`; Kimi/Moonshot never trigger
|
||||
it; `KIGI_MODELS_DEV_URL=0` disables). No telemetry, no analytics, ever.
|
||||
`crates/codegen/kigi-env` is the single home of first-party endpoints.
|
||||
- **Zero egress**: only `auth.kimi.com`, `api.kimi.com`, `api.moonshot.cn`,
|
||||
`api.moonshot.ai`, GitHub Releases, user-configured MCP servers, credentialed
|
||||
provider endpoints, and `models.dev` (metadata refresh, ONLY when an enabled
|
||||
platform's `/models` lacks it — `wire_serves_metadata=false`, never
|
||||
Kimi/Moonshot, `KIGI_MODELS_DEV_URL=0` disables). No telemetry, no analytics,
|
||||
ever. `crates/codegen/kigi-env` is the single home of first-party endpoints.
|
||||
- **One SSRF policy**: `ssrf::is_blocked_for_host` gates every model/settings
|
||||
URL (`web_fetch` + hooks), per redirect hop. Never write a second copy.
|
||||
- **Toolchain**: Rust 1.97.0 (rust-toolchain.toml), edition 2024.
|
||||
- **Gates** (all must stay green):
|
||||
`cargo check --workspace --all-targets`,
|
||||
@@ -164,6 +180,63 @@ edges stay deterministic Rust. The harness appends a terminal
|
||||
the replan cap; `{"ops": []}` is a respected free no-op; failures
|
||||
degrade.
|
||||
|
||||
## Swarm (`agent_swarm` tool + `/swarm` mode)
|
||||
|
||||
Cheap, unstructured fan-out — the one thing `/graph` does not offer. One
|
||||
`prompt_template` containing `{{item}}` is expanded over an `items` list
|
||||
into up to `MAX_AGENT_SWARM_MEMBERS` (128) ordinary subagents, run to
|
||||
completion, and returned as ONE aggregate. Ported from kimi-code; entirely
|
||||
client-side, no backend surface.
|
||||
|
||||
- Engine (`kigi-tools/.../kigi/agent_swarm/`): `plan.rs` validates and
|
||||
expands (every fault is reported BEFORE a member starts; expanded
|
||||
prompts must be pairwise distinct), `schedule.rs` is the launch ramp as
|
||||
pure arithmetic (5 immediate, then 1 per 700ms; capacity shrinks on a
|
||||
provider rate limit and recovers after a quiet window;
|
||||
`KIGI_AGENT_SWARM_MAX_CONCURRENCY` caps it and a malformed value is a
|
||||
hard error), `run.rs` drives it against the EXISTING single-spawn
|
||||
`SubagentBackend`, `tool.rs` is the tool.
|
||||
- `SwarmMemberOutcome::Backgrounded` is load-bearing: a member that
|
||||
outlives the 600s foreground budget is detached by the coordinator and
|
||||
KEEPS RUNNING. It is never reported `Failed` and never offered for
|
||||
resume — relaunching its item would put a second agent on the same
|
||||
files. Members share the caller's tree with NO isolation; the
|
||||
distinct-prompt rule is the only thing keeping them apart.
|
||||
- `InFlightGuard` (run.rs) cancels live members when the runner's future
|
||||
is dropped. Send-now cancels the turn WITHOUT cancelling subagents and
|
||||
aborts the task; the dropped receivers read as "parent gone" and each
|
||||
child re-attaches itself. There is no cooperative path to use instead —
|
||||
`kigi_tool_runtime::Cancellation` is constructed nowhere.
|
||||
- Both retries (`MAX_RATE_LIMIT_RETRIES`) and wall clock
|
||||
(`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.
|
||||
It gates exactly as `Task` does (`capability.rs`), and `builder.rs`
|
||||
strips it wherever `task` is stripped. `MAX_SUBAGENT_DEPTH` stays 1:
|
||||
upstream's unlimited nesting is a hazard, not a feature.
|
||||
- `/swarm | /swarm on|off | /swarm <task>` (`BuiltinGate::Swarm` = the
|
||||
tool is in the toolset) arms a doctrine reminder via the existing
|
||||
`push_system_reminder`. Two triggers only: `Manual` persists until
|
||||
switched off, `Task` auto-expires at turn end via `SwarmTurnGuard` — a
|
||||
DROP guard, because a user interrupt aborts the turn future and never
|
||||
reaches post-loop code. `enter` is a total no-op while armed so the
|
||||
`/swarm <task>` shorthand cannot downgrade a standing `/swarm on`.
|
||||
- The mode is deliberately NOT persisted (unlike `/goal` and `/graph`,
|
||||
which strand real work if lost): it is a prompt hint whose recovery is
|
||||
retyping one command. The injected doctrine IS durable, so an explicit
|
||||
`/swarm off` retracts UNCONDITIONALLY — a resumed or compacted session
|
||||
can read as "off" with the instruction still in context.
|
||||
|
||||
## Provider registry & API-key auth (post-0.1.3 expansion)
|
||||
|
||||
- The platform registry is compiled-in spec rows in `kigi-models`
|
||||
|
||||
Generated
+88
-85
@@ -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]]
|
||||
@@ -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.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"async-trait",
|
||||
@@ -5456,7 +5456,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-agent"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -5486,7 +5486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-agent-lifecycle"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"tokio",
|
||||
@@ -5495,7 +5495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-auth"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"http 1.4.2",
|
||||
@@ -5508,7 +5508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-bin"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -5543,7 +5543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-chat-state"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"kigi-compaction",
|
||||
@@ -5560,7 +5560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-codebase-graph"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"clap",
|
||||
@@ -5596,7 +5596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-compaction"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -5609,7 +5609,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-config"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"blake3",
|
||||
@@ -5632,7 +5632,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-config-types"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"indexmap",
|
||||
@@ -5646,7 +5646,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-crash-handler"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"libc",
|
||||
@@ -5657,7 +5657,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-env"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -5665,7 +5665,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-fast-worktree"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -5697,7 +5697,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-file-utils"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-config",
|
||||
@@ -5721,7 +5721,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-fsnotify"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"dunce",
|
||||
@@ -5742,7 +5742,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-gix-status"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"gix",
|
||||
"kigi-test-utils",
|
||||
@@ -5752,7 +5752,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hooks"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"kigi-config",
|
||||
@@ -5771,7 +5771,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hooks-plugins-types"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5779,7 +5779,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-http"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"kigi-auth",
|
||||
"kigi-log",
|
||||
@@ -5794,7 +5794,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hunk-tracker"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -5815,14 +5815,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-interjection-core"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-log"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -5840,7 +5840,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-markdown"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-lossy",
|
||||
@@ -5864,14 +5864,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-markdown-core"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"pulldown-cmark",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-mcp"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"async-trait",
|
||||
@@ -5908,7 +5908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-memory"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -5942,7 +5942,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-mermaid"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"fontdb",
|
||||
"image",
|
||||
@@ -5960,7 +5960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-models"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"kigi-env",
|
||||
"serde",
|
||||
@@ -5970,7 +5970,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-minimal"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"crossterm",
|
||||
@@ -5987,7 +5987,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-pty-harness"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -6012,7 +6012,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-render"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anstyle",
|
||||
@@ -6064,7 +6064,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-paths"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"camino",
|
||||
"serde",
|
||||
@@ -6074,7 +6074,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-prompt-queue"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6082,7 +6082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-proto-build"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pbjson-build",
|
||||
@@ -6093,7 +6093,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-ratatui-inline"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"ansi-width",
|
||||
"anstyle-parse 0.2.7",
|
||||
@@ -6110,7 +6110,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-ratatui-textarea"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6131,7 +6131,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sampler"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
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.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"assert_matches",
|
||||
"async-openai",
|
||||
@@ -6171,7 +6172,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sandbox"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -6186,13 +6187,14 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"tempfile",
|
||||
"toml",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-secrets"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde_json",
|
||||
@@ -6230,7 +6232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-shell"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
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.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -6392,7 +6395,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sqlite-journal"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rusqlite",
|
||||
@@ -6403,7 +6406,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-subagent-resolution"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"kigi-sampling-types",
|
||||
"kigi-tool-types",
|
||||
@@ -6418,7 +6421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-system-power"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"zbus",
|
||||
@@ -6426,7 +6429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-test-support"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
@@ -6448,7 +6451,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-test-utils"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"runfiles",
|
||||
"tracing",
|
||||
@@ -6457,11 +6460,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-token-estimation"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-protocol"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"kigi-tool-types",
|
||||
"serde",
|
||||
@@ -6472,7 +6475,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-runtime"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -6490,7 +6493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-types"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"minijinja",
|
||||
"schemars 1.2.1",
|
||||
@@ -6500,7 +6503,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tools"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -6577,7 +6580,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tools-api"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"kigi-proto-build",
|
||||
"kigi-tool-protocol",
|
||||
@@ -6590,11 +6593,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tracing-macros"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tty-utils"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"nix 0.30.1",
|
||||
@@ -6604,7 +6607,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tui"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"ansi-to-tui",
|
||||
@@ -6691,7 +6694,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-update"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
@@ -6720,14 +6723,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-version"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-workspace"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
@@ -6806,7 +6809,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-workspace-types"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
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.9"
|
||||
version = "0.1.15"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -8858,7 +8861,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ptyctl-cli"
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
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]]
|
||||
@@ -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]]
|
||||
@@ -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
@@ -76,7 +76,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.9"
|
||||
version = "0.1.15"
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
|
||||
|
||||
@@ -36,12 +36,12 @@ key? OpenAI, Anthropic, Google, DeepSeek, Groq, Moonshot and
|
||||
|
||||
```sh
|
||||
# macOS / Linux
|
||||
curl -fsSL https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.sh | bash
|
||||
curl -fsSL https://kigicli.dev/install.sh | bash
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows PowerShell
|
||||
irm https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.ps1 | iex
|
||||
irm https://kigicli.dev/install.ps1 | iex
|
||||
```
|
||||
|
||||
```sh
|
||||
@@ -176,8 +176,8 @@ keyring entry, and its own `KIGI_*` env vars, and never touches what the
|
||||
official `kimi` CLI installed. `kigi import-kimi` copies your old config over
|
||||
once, read-only.
|
||||
|
||||
**Zero telemetry.** It talks to the APIs you configured, GitHub Releases, and
|
||||
your own MCP servers. Nothing else.
|
||||
**Zero telemetry.** It talks to the APIs you configured, the Kigi release
|
||||
server, and your own MCP servers. Nothing else.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+5
-3
@@ -1,8 +1,10 @@
|
||||
# Security Policy
|
||||
|
||||
Kigi is a community build; report vulnerabilities via
|
||||
[GitHub private vulnerability reporting](https://github.com/ZacharyZhang-NY/Kigi-CLI/security/advisories/new)
|
||||
on this repository. Please do not open public issues for security reports.
|
||||
Kigi is a community build; report vulnerabilities on the
|
||||
[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.
|
||||
|
||||
Dependency advisories are gated in CI with `cargo deny check advisories`;
|
||||
documented exceptions live in `deny.toml`.
|
||||
+1511
-81
File diff suppressed because it is too large.
Load diff
@@ -711,9 +711,19 @@ impl AgentBuilder {
|
||||
kigi_tools::types::tool::ToolNamespace::Kigi,
|
||||
"task"
|
||||
);
|
||||
// The swarm is a fan-out of subagent spawns, so it lives and dies with
|
||||
// the task tool: any condition that leaves no subagent to spawn leaves
|
||||
// the swarm with nothing to fan out to.
|
||||
let swarm_tool_id = format!(
|
||||
"{}:{}",
|
||||
kigi_tools::types::tool::ToolNamespace::Kigi,
|
||||
"agent_swarm"
|
||||
);
|
||||
let mut task_stripped = false;
|
||||
if !self.subagents_enabled {
|
||||
tool_config.tools.retain(|tc| tc.id != task_tool_id);
|
||||
tool_config
|
||||
.tools
|
||||
.retain(|tc| tc.id != task_tool_id && tc.id != swarm_tool_id);
|
||||
task_stripped = true;
|
||||
} else {
|
||||
let subagents = crate::discovery::all_subagents_with_plugins(
|
||||
@@ -722,7 +732,9 @@ impl AgentBuilder {
|
||||
self.plugin_registry.as_deref(),
|
||||
);
|
||||
if subagents.is_empty() {
|
||||
tool_config.tools.retain(|tc| tc.id != task_tool_id);
|
||||
tool_config
|
||||
.tools
|
||||
.retain(|tc| tc.id != task_tool_id && tc.id != swarm_tool_id);
|
||||
task_stripped = true;
|
||||
} else if self.prompt_audience == crate::prompt::context::PromptAudience::Subagent {
|
||||
if let Some(task_tc) = tool_config
|
||||
@@ -810,7 +822,13 @@ impl AgentBuilder {
|
||||
.tools
|
||||
.iter()
|
||||
.any(|t| AGENT_TASK_CLASSIFIER_RE.is_match(t));
|
||||
let task_deps = ["task", "get_task_output", "kill_task", "wait_tasks"];
|
||||
let task_deps = [
|
||||
"task",
|
||||
"agent_swarm",
|
||||
"get_task_output",
|
||||
"kill_task",
|
||||
"wait_tasks",
|
||||
];
|
||||
let registered_tool_ids = tool_bridge_builder.known_tool_ids();
|
||||
let present_kinds: std::collections::HashSet<ToolKind> =
|
||||
tool_config.tools.iter().filter_map(|tc| tc.kind).collect();
|
||||
@@ -925,7 +943,13 @@ impl AgentBuilder {
|
||||
}
|
||||
}
|
||||
if definition.allowed_subagent_types.as_deref() == Some(&[]) {
|
||||
let task_deps = ["task", "get_task_output", "kill_task", "wait_tasks"];
|
||||
let task_deps = [
|
||||
"task",
|
||||
"agent_swarm",
|
||||
"get_task_output",
|
||||
"kill_task",
|
||||
"wait_tasks",
|
||||
];
|
||||
tool_config
|
||||
.tools
|
||||
.retain(|tc| !task_deps.contains(&short_tool_name(&tc.id)));
|
||||
@@ -1601,6 +1625,11 @@ mod tests {
|
||||
has_task, *subagents,
|
||||
"[{label}] spawn_subagent presence should match subagents_enabled={subagents}; got tools: {names:?}"
|
||||
);
|
||||
let has_swarm = names.contains(&"agent_swarm");
|
||||
assert_eq!(
|
||||
has_swarm, *subagents,
|
||||
"[{label}] agent_swarm fans out to subagents, so it must follow the same gate as spawn_subagent; got tools: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"enter_plan_mode"),
|
||||
"[{label}] enter_plan_mode must always be present (TUI plan-mode keybind needs it); got tools: {names:?}"
|
||||
|
||||
@@ -154,6 +154,11 @@ fn task_tool_config() -> ToolConfig {
|
||||
.with_name("spawn_subagent")
|
||||
.with_param_rename("run_in_background", "background")
|
||||
}
|
||||
/// Swarm tool. Keeps its registry name: unlike `task` it has no CLI-specific
|
||||
/// alias, and the name is what the model is told to reuse for `resume_agent_ids`.
|
||||
fn agent_swarm_tool_config() -> ToolConfig {
|
||||
ToolConfig::from(&kigi::AgentSwarmTool)
|
||||
}
|
||||
/// Task output tool renamed for clarity:
|
||||
/// `get_task_output` → `get_command_or_subagent_output`.
|
||||
fn task_output_tool_config() -> ToolConfig {
|
||||
@@ -270,6 +275,7 @@ fn default_kigi_toolset() -> ToolServerConfig {
|
||||
task_output_tool_config(),
|
||||
wait_tasks_tool_config(),
|
||||
task_tool_config(),
|
||||
agent_swarm_tool_config(),
|
||||
(&kigi::SchedulerCreateTool).into(),
|
||||
(&kigi::SchedulerDeleteTool).into(),
|
||||
(&kigi::SchedulerListTool).into(),
|
||||
@@ -318,6 +324,7 @@ pub fn kigi_hashline_toolset(
|
||||
task_output_tool_config(),
|
||||
wait_tasks_tool_config(),
|
||||
task_tool_config(),
|
||||
agent_swarm_tool_config(),
|
||||
(&kigi::WebSearchTool).into(),
|
||||
(&kigi::SchedulerCreateTool).into(),
|
||||
(&kigi::SchedulerDeleteTool).into(),
|
||||
@@ -400,6 +407,7 @@ fn kigi_plan_toolset() -> ToolServerConfig {
|
||||
(&kigi::TodoWriteTool).into(),
|
||||
task_output_tool_config(),
|
||||
task_tool_config(),
|
||||
agent_swarm_tool_config(),
|
||||
(&kigi::SchedulerCreateTool).into(),
|
||||
(&kigi::SchedulerDeleteTool).into(),
|
||||
(&kigi::SchedulerListTool).into(),
|
||||
@@ -428,6 +436,7 @@ fn orchestrator_toolset() -> ToolServerConfig {
|
||||
(&kigi::ListDirTool).into(),
|
||||
(&kigi::GrepTool).into(),
|
||||
task_tool_config(),
|
||||
agent_swarm_tool_config(),
|
||||
task_output_tool_config(),
|
||||
wait_tasks_tool_config(),
|
||||
kill_task_tool_config(),
|
||||
@@ -497,6 +506,7 @@ fn kigi_ask_user_toolset() -> ToolServerConfig {
|
||||
task_output_tool_config(),
|
||||
wait_tasks_tool_config(),
|
||||
task_tool_config(),
|
||||
agent_swarm_tool_config(),
|
||||
(&kigi::SchedulerCreateTool).into(),
|
||||
(&kigi::SchedulerDeleteTool).into(),
|
||||
(&kigi::SchedulerListTool).into(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")))
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,8 @@ pub struct KigiEndpoints {
|
||||
pub coding_api_base_url: &'static str,
|
||||
/// OAuth device-flow host for Kimi Code subscription login.
|
||||
pub oauth_host: &'static str,
|
||||
/// GitHub Releases API endpoint the self-updater polls.
|
||||
/// Releases API endpoint the self-updater polls (GitHub-Releases-shaped;
|
||||
/// Gitea's `/api/v1` serves the same wire format).
|
||||
pub update_base_url: &'static str,
|
||||
/// Human-facing page for subscription upgrade guidance.
|
||||
pub upgrade_page_url: &'static str,
|
||||
@@ -29,7 +30,7 @@ pub const CODE_BASE_URL_ENV: &str = "KIGI_CODE_BASE_URL";
|
||||
/// Env var overriding [`oauth_host`] (PRD F1).
|
||||
pub const OAUTH_HOST_ENV: &str = "KIGI_OAUTH_HOST";
|
||||
/// Env var overriding [`update_base_url`] (PRD F8). Points the self-updater
|
||||
/// at an alternate GitHub-Releases-shaped API (mirrors, tests).
|
||||
/// at an alternate GitHub/Gitea-Releases-shaped API (mirrors, tests).
|
||||
pub const UPDATE_BASE_URL_ENV: &str = "KIGI_UPDATE_BASE_URL";
|
||||
|
||||
fn resolve(var: &str, compiled: &'static str) -> String {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -26,68 +26,14 @@ struct HttpHookOutput {
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
/// CWE-918: Returns `true` if an IP address is in a private, link-local,
|
||||
/// or cloud metadata range that should be blocked to prevent SSRF attacks.
|
||||
/// CWE-918: whether this address is blocked for a hook.
|
||||
///
|
||||
/// Loopback (`127.x` / `::1`) is allowed for local development servers.
|
||||
fn is_blocked_ip(ip: &IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
let octets = v4.octets();
|
||||
if octets[0] == 127 {
|
||||
// loopback — allowed for local dev
|
||||
return false;
|
||||
}
|
||||
if octets[0] == 10 {
|
||||
// RFC 1918: 10.0.0.0/8
|
||||
return true;
|
||||
}
|
||||
if octets[0] == 172 && (16..=31).contains(&octets[1]) {
|
||||
// RFC 1918: 172.16.0.0/12
|
||||
return true;
|
||||
}
|
||||
if octets[0] == 192 && octets[1] == 168 {
|
||||
// RFC 1918: 192.168.0.0/16
|
||||
return true;
|
||||
}
|
||||
if octets[0] == 169 && octets[1] == 254 {
|
||||
// RFC 3927: 169.254.0.0/16 (link-local, cloud metadata)
|
||||
return true;
|
||||
}
|
||||
if octets[0] == 100 && (64..=127).contains(&octets[1]) {
|
||||
// RFC 6598: 100.64.0.0/10 (CGNAT)
|
||||
return true;
|
||||
}
|
||||
if v4.is_unspecified() {
|
||||
// 0.0.0.0
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
if v6.is_loopback() {
|
||||
// ::1 — allowed for local dev
|
||||
return false;
|
||||
}
|
||||
if v6.is_unspecified() {
|
||||
// ::
|
||||
return true;
|
||||
}
|
||||
if let Some(v4) = v6.to_ipv4_mapped() {
|
||||
return is_blocked_ip(&IpAddr::V4(v4));
|
||||
}
|
||||
let segments = v6.segments();
|
||||
if segments[0] & 0xffc0 == 0xfe80 {
|
||||
// fe80::/10 — link-local
|
||||
return true;
|
||||
}
|
||||
if segments[0] & 0xfe00 == 0xfc00 {
|
||||
// fc00::/7 — unique local (ULA)
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
/// Delegates to the `web_fetch` policy, so one implementation
|
||||
/// governs every outbound URL. `allow_local` is on: a loopback hook
|
||||
/// receiver is legitimate, but only when named literally, so
|
||||
/// rebinding through a public name stays blocked.
|
||||
fn is_blocked_ip(ip: &IpAddr, host: &str) -> bool {
|
||||
kigi_tools::implementations::kigi::web_fetch::ssrf::is_blocked_for_host(ip, host, true)
|
||||
}
|
||||
|
||||
/// CWE-918: Validate a hook URL to prevent SSRF.
|
||||
@@ -112,7 +58,7 @@ async fn validate_hook_url(url: &str) -> Result<(), String> {
|
||||
|
||||
// If host is a literal IP, check it directly.
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
if is_blocked_ip(&ip) {
|
||||
if is_blocked_ip(&ip, host) {
|
||||
return Err(format!("URL resolves to blocked private/internal IP: {ip}"));
|
||||
}
|
||||
return Ok(());
|
||||
@@ -131,7 +77,7 @@ async fn validate_hook_url(url: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
for addr in &addrs {
|
||||
if is_blocked_ip(&addr.ip()) {
|
||||
if is_blocked_ip(&addr.ip(), host) {
|
||||
return Err(format!(
|
||||
"URL host {host} resolves to blocked private/internal IP: {}",
|
||||
addr.ip()
|
||||
@@ -571,76 +517,53 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// SSRF protection: is_blocked_ip tests
|
||||
// SSRF protection: hook-side policy tests
|
||||
//
|
||||
// Range coverage lives with the shared predicate in `web_fetch::ssrf`;
|
||||
// these pin what the runner adds on top.
|
||||
|
||||
/// Every range the shared policy knows is refused here.
|
||||
#[test]
|
||||
fn ssrf_blocks_rfc1918_10x() {
|
||||
assert!(is_blocked_ip(&"10.0.0.1".parse().unwrap()));
|
||||
assert!(is_blocked_ip(&"10.255.255.255".parse().unwrap()));
|
||||
fn ssrf_delegates_to_the_shared_policy() {
|
||||
for ip in [
|
||||
"10.0.0.1",
|
||||
"172.16.0.1",
|
||||
"192.168.0.1",
|
||||
"169.254.169.254",
|
||||
"100.64.0.1",
|
||||
"0.0.0.0",
|
||||
"::",
|
||||
"fe80::1",
|
||||
"fc00::1",
|
||||
"::ffff:10.0.0.1",
|
||||
// Ranges the old hand-rolled copy missed entirely.
|
||||
"198.18.0.1",
|
||||
"192.0.2.1",
|
||||
"203.0.113.1",
|
||||
"240.0.0.1",
|
||||
"64:ff9b::a9fe:a9fe",
|
||||
] {
|
||||
let ip: IpAddr = ip.parse().unwrap();
|
||||
assert!(is_blocked_ip(&ip, &ip.to_string()), "{ip}");
|
||||
}
|
||||
for ip in ["1.1.1.1", "8.8.8.8", "172.32.0.1", "100.63.0.1"] {
|
||||
let ip: IpAddr = ip.parse().unwrap();
|
||||
assert!(!is_blocked_ip(&ip, &ip.to_string()), "{ip}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A local hook receiver stays reachable when named literally.
|
||||
#[test]
|
||||
fn ssrf_blocks_rfc1918_172x() {
|
||||
assert!(is_blocked_ip(&"172.16.0.1".parse().unwrap()));
|
||||
assert!(is_blocked_ip(&"172.31.255.255".parse().unwrap()));
|
||||
assert!(!is_blocked_ip(&"172.15.0.1".parse().unwrap()));
|
||||
assert!(!is_blocked_ip(&"172.32.0.1".parse().unwrap()));
|
||||
fn ssrf_allows_a_literal_loopback_hook_target() {
|
||||
assert!(!is_blocked_ip(&"127.0.0.1".parse().unwrap(), "127.0.0.1"));
|
||||
assert!(!is_blocked_ip(&"::1".parse().unwrap(), "localhost"));
|
||||
}
|
||||
|
||||
/// A public name resolving to loopback is rebinding.
|
||||
#[test]
|
||||
fn ssrf_blocks_rfc1918_192168() {
|
||||
assert!(is_blocked_ip(&"192.168.0.1".parse().unwrap()));
|
||||
assert!(is_blocked_ip(&"192.168.255.255".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_blocks_link_local_metadata() {
|
||||
assert!(is_blocked_ip(&"169.254.0.1".parse().unwrap()));
|
||||
assert!(is_blocked_ip(&"169.254.169.254".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_blocks_cgnat() {
|
||||
assert!(is_blocked_ip(&"100.64.0.1".parse().unwrap()));
|
||||
assert!(is_blocked_ip(&"100.127.255.255".parse().unwrap()));
|
||||
assert!(!is_blocked_ip(&"100.63.0.1".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_blocks_unspecified() {
|
||||
assert!(is_blocked_ip(&"0.0.0.0".parse().unwrap()));
|
||||
assert!(is_blocked_ip(&"::".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_allows_loopback() {
|
||||
assert!(!is_blocked_ip(&"127.0.0.1".parse().unwrap()));
|
||||
assert!(!is_blocked_ip(&"::1".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_allows_public_ips() {
|
||||
assert!(!is_blocked_ip(&"1.1.1.1".parse().unwrap()));
|
||||
assert!(!is_blocked_ip(&"8.8.8.8".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_blocks_ipv6_link_local() {
|
||||
assert!(is_blocked_ip(&"fe80::1".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_blocks_ipv6_unique_local() {
|
||||
assert!(is_blocked_ip(&"fc00::1".parse().unwrap()));
|
||||
assert!(is_blocked_ip(&"fd00::1".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_blocks_ipv4_mapped_ipv6_private() {
|
||||
assert!(is_blocked_ip(&"::ffff:10.0.0.1".parse::<IpAddr>().unwrap()));
|
||||
assert!(is_blocked_ip(
|
||||
&"::ffff:192.168.1.1".parse::<IpAddr>().unwrap()
|
||||
));
|
||||
fn ssrf_blocks_a_public_name_that_resolves_to_loopback() {
|
||||
let ip: IpAddr = "127.0.0.1".parse().unwrap();
|
||||
assert!(is_blocked_ip(&ip, "evil.example.com"));
|
||||
}
|
||||
|
||||
// SSRF protection: validate_hook_url tests
|
||||
@@ -839,14 +762,15 @@ mod tests {
|
||||
/// the secret does NOT appear in the returned error message.
|
||||
#[tokio::test]
|
||||
async fn run_http_hook_scrubs_url_from_reqwest_error() {
|
||||
// Use a TEST-NET-1 host (RFC 5737, "MUST NOT be used in
|
||||
// public networks"). It is not RFC1918 so SSRF validation
|
||||
// will let it through, but no real DNS or connection will
|
||||
// succeed -- reqwest will surface a connection error whose
|
||||
// default Display includes the URL.
|
||||
// A closed loopback port: allowed as a literal local host,
|
||||
// and refused at once. TEST-NET-1 is now blocked by policy.
|
||||
let dead = {
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
l.local_addr().unwrap()
|
||||
};
|
||||
let secret = "ghp_VERY_REAL_SECRET_TOKEN_42";
|
||||
let mut extra_env = std::collections::HashMap::new();
|
||||
extra_env.insert("RUNTIME_HOST".to_string(), "192.0.2.1".to_string());
|
||||
extra_env.insert("RUNTIME_HOST".to_string(), dead.to_string());
|
||||
extra_env.insert("MY_TOKEN".to_string(), secret.to_string());
|
||||
|
||||
let raw = "https://${RUNTIME_HOST}/check?token=${MY_TOKEN}";
|
||||
@@ -928,10 +852,7 @@ mod tests {
|
||||
// debugging). The wire-DTO consumer must prefer raw_url for
|
||||
// display -- documented in the HttpInfo rustdoc.
|
||||
let info = info.expect("HttpInfo should be present for connection failures too");
|
||||
assert_eq!(
|
||||
info.url,
|
||||
"https://192.0.2.1/check?token=ghp_VERY_REAL_SECRET_TOKEN_42"
|
||||
);
|
||||
assert_eq!(info.url, format!("https://{dead}/check?token={secret}"));
|
||||
assert_eq!(info.raw_url.as_deref(), Some(raw));
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -18,6 +18,76 @@ use super::embedding::EmbeddingProvider as _;
|
||||
use super::storage::MemoryStorage;
|
||||
use super::watcher::MemoryFileWatcher;
|
||||
|
||||
/// The session's embedding credentials, bound to the one endpoint they may
|
||||
/// reach. Only [`Self::for_endpoint`] retains a live handle; the default fails
|
||||
/// closed.
|
||||
///
|
||||
/// `embed_base_url` is the CURRENT MODEL's endpoint, so a session on a BYOK or
|
||||
/// subscription-OAuth model aims memory embeddings at that provider's host —
|
||||
/// and [`kigi_auth::AuthRetryMiddleware`] stamps `Authorization` on every
|
||||
/// request it wraps, with no idea where the request is going. The caller that
|
||||
/// owns the credential rule decides `trusted` once, here; a platform's own
|
||||
/// `embed_api_key` is unaffected and keeps serving its own endpoint.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct EndpointScopedCredentials {
|
||||
endpoint: Option<reqwest::Url>,
|
||||
auth_credentials: Option<Arc<dyn kigi_auth::AuthCredentialProvider>>,
|
||||
}
|
||||
|
||||
// Redacts the credential handles; only their presence is printable.
|
||||
impl std::fmt::Debug for EndpointScopedCredentials {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("EndpointScopedCredentials")
|
||||
.field("endpoint", &self.endpoint)
|
||||
.field("has_auth_credentials", &self.auth_credentials.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl EndpointScopedCredentials {
|
||||
/// No session credential may ride — the state every non-session caller wants.
|
||||
pub fn none() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Retains the handles only for a `trusted`, parsable `endpoint`.
|
||||
pub fn for_endpoint(
|
||||
endpoint: &str,
|
||||
trusted: bool,
|
||||
auth_credentials: Option<Arc<dyn kigi_auth::AuthCredentialProvider>>,
|
||||
) -> Self {
|
||||
if trusted && let Ok(url) = reqwest::Url::parse(endpoint) {
|
||||
return Self {
|
||||
endpoint: Some(url),
|
||||
auth_credentials,
|
||||
};
|
||||
}
|
||||
if auth_credentials.is_some() {
|
||||
tracing::info!(
|
||||
target: kigi_log::memory_log::TARGET,
|
||||
endpoint,
|
||||
"memory embeddings: session credentials withheld from this endpoint; \
|
||||
its own key, if any, still applies"
|
||||
);
|
||||
}
|
||||
Self::none()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.auth_credentials.is_none()
|
||||
}
|
||||
|
||||
/// Enforced at request-build time, in release too: [`MemoryBackendParams`]
|
||||
/// is `Clone` and callers rewrite fields on the copy, so construction-time
|
||||
/// scoping alone would not survive a rewritten `embed_base_url`.
|
||||
fn approved_for(&self, base_url: &str) -> bool {
|
||||
match &self.endpoint {
|
||||
None => self.is_empty(),
|
||||
Some(endpoint) => reqwest::Url::parse(base_url).is_ok_and(|url| &url == endpoint),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All configuration needed to build a fully-wired [`MemoryBackendImpl`] for a live session.
|
||||
///
|
||||
/// Grouping these in one struct ensures every call site — ToolBridge, first-turn
|
||||
@@ -30,7 +100,8 @@ pub struct MemoryBackendParams {
|
||||
pub session_id: String,
|
||||
/// Embedding provider config — `None` forces FTS-only fallback everywhere.
|
||||
pub embed_config: Option<kigi_config_types::MemoryEmbeddingConfig>,
|
||||
/// Base URL for embedding API calls (CLI proxy).
|
||||
/// Base URL for embedding API calls (CLI proxy). Must match the endpoint
|
||||
/// `embedding_credentials` was scoped to; a mismatch fails closed.
|
||||
pub embed_base_url: String,
|
||||
/// API key for embedding API calls.
|
||||
pub embed_api_key: Option<String>,
|
||||
@@ -47,10 +118,8 @@ pub struct MemoryBackendParams {
|
||||
/// - `"injection"` — first-turn memory context injection
|
||||
/// - `"compaction_recovery"` — post-compaction context re-injection
|
||||
pub search_source: &'static str,
|
||||
/// Dynamic API key provider — when set, `make_embedding_provider()` resolves
|
||||
/// the key per-call instead of using the static `embed_api_key`.
|
||||
pub api_key_provider: Option<kigi_tools::types::SharedApiKeyProvider>,
|
||||
pub auth_credentials: Option<Arc<dyn kigi_auth::AuthCredentialProvider>>,
|
||||
/// The session credentials, and the single endpoint they may reach.
|
||||
pub embedding_credentials: EndpointScopedCredentials,
|
||||
}
|
||||
|
||||
impl MemoryBackendParams {
|
||||
@@ -59,8 +128,7 @@ impl MemoryBackendParams {
|
||||
pub async fn make_embedding_provider(&self) -> Option<super::embedding::ApiEmbeddingProvider> {
|
||||
build_embedding_provider(
|
||||
self.embed_config.as_ref(),
|
||||
self.auth_credentials.as_ref(),
|
||||
self.api_key_provider.as_ref(),
|
||||
&self.embedding_credentials,
|
||||
self.embed_api_key.as_deref(),
|
||||
&self.embed_base_url,
|
||||
)
|
||||
@@ -70,8 +138,7 @@ impl MemoryBackendParams {
|
||||
|
||||
async fn build_embedding_provider(
|
||||
config: Option<&kigi_config_types::MemoryEmbeddingConfig>,
|
||||
auth_credentials: Option<&Arc<dyn kigi_auth::AuthCredentialProvider>>,
|
||||
api_key_provider: Option<&kigi_tools::types::SharedApiKeyProvider>,
|
||||
credentials: &EndpointScopedCredentials,
|
||||
static_api_key: Option<&str>,
|
||||
base_url: &str,
|
||||
) -> Option<super::embedding::ApiEmbeddingProvider> {
|
||||
@@ -80,9 +147,19 @@ async fn build_embedding_provider(
|
||||
return None;
|
||||
}
|
||||
|
||||
let approved = credentials.approved_for(base_url);
|
||||
if !approved {
|
||||
tracing::error!(
|
||||
target: kigi_log::memory_log::TARGET,
|
||||
base_url,
|
||||
approved_endpoint = ?credentials.endpoint,
|
||||
"memory embeddings: scoped credentials do not match the request URL; dropping them"
|
||||
);
|
||||
}
|
||||
|
||||
// Prefer the refresh-capable credential provider — the middleware gives
|
||||
// 401 retry for free without any per-call key resolution.
|
||||
if let Some(creds) = auth_credentials {
|
||||
if approved && let Some(creds) = credentials.auth_credentials.as_ref() {
|
||||
let client = super::embedding::build_middleware_client(creds.clone());
|
||||
return super::embedding::ApiEmbeddingProvider::from_config(
|
||||
config,
|
||||
@@ -91,14 +168,13 @@ async fn build_embedding_provider(
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: resolve API key per-call, wrap in a static middleware client
|
||||
// (no 401 refresh, but auth header is still stamped by middleware).
|
||||
let api_key = match api_key_provider {
|
||||
Some(p) => p.current_api_key_async().await,
|
||||
None => None,
|
||||
}
|
||||
.or_else(|| static_api_key.map(|s| s.to_owned()))?;
|
||||
super::embedding::ApiEmbeddingProvider::from_session(config, base_url.to_owned(), api_key)
|
||||
// The platform's own configured key, wrapped in a static middleware client
|
||||
// (no 401 refresh, but the auth header is still stamped by middleware).
|
||||
super::embedding::ApiEmbeddingProvider::from_session(
|
||||
config,
|
||||
base_url.to_owned(),
|
||||
static_api_key?.to_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
/// `MemoryBackend` implementation backed by hybrid search (FTS5 + vector KNN).
|
||||
@@ -129,10 +205,8 @@ pub struct MemoryBackendImpl {
|
||||
/// Only the ToolBridge backend's counter is shared back to the session actor;
|
||||
/// injection and compaction-recovery backends use their own local counters.
|
||||
pub search_counter: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
/// Dynamic API key provider for embedding requests.
|
||||
api_key_provider: Option<kigi_tools::types::SharedApiKeyProvider>,
|
||||
/// Refresh-capable credential provider for embedding HTTP middleware.
|
||||
auth_credentials: Option<Arc<dyn kigi_auth::AuthCredentialProvider>>,
|
||||
/// The session credentials, and the single endpoint they may reach.
|
||||
embedding_credentials: EndpointScopedCredentials,
|
||||
}
|
||||
|
||||
impl MemoryBackendImpl {
|
||||
@@ -150,8 +224,7 @@ impl MemoryBackendImpl {
|
||||
stale_claim_secs: 60,
|
||||
session_id: String::new(),
|
||||
search_source: "tool",
|
||||
api_key_provider: None,
|
||||
auth_credentials: None,
|
||||
embedding_credentials: EndpointScopedCredentials::none(),
|
||||
search_counter: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
}
|
||||
}
|
||||
@@ -200,8 +273,7 @@ impl MemoryBackendImpl {
|
||||
async fn make_embedding_provider(&self) -> Option<super::embedding::ApiEmbeddingProvider> {
|
||||
build_embedding_provider(
|
||||
self.embed_config.as_ref(),
|
||||
self.auth_credentials.as_ref(),
|
||||
self.api_key_provider.as_ref(),
|
||||
&self.embedding_credentials,
|
||||
self.embed_api_key.as_deref(),
|
||||
&self.embed_base_url,
|
||||
)
|
||||
@@ -232,8 +304,7 @@ impl MemoryBackendImpl {
|
||||
if let Some(w) = ¶ms.watcher {
|
||||
backend = backend.with_watcher(w.clone(), params.stale_claim_secs);
|
||||
}
|
||||
backend.api_key_provider = params.api_key_provider.clone();
|
||||
backend.auth_credentials = params.auth_credentials.clone();
|
||||
backend.embedding_credentials = params.embedding_credentials.clone();
|
||||
backend
|
||||
}
|
||||
}
|
||||
@@ -479,8 +550,7 @@ mod factory_tests {
|
||||
watcher: None,
|
||||
stale_claim_secs: 60,
|
||||
search_source: "tool",
|
||||
api_key_provider: None,
|
||||
auth_credentials: None,
|
||||
embedding_credentials: EndpointScopedCredentials::none(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1038,72 +1108,128 @@ mod factory_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: provider build must use `current_api_key_async`,
|
||||
/// never sync. Prevents memory_search 401s on rotated tokens.
|
||||
#[tokio::test]
|
||||
async fn make_embedding_provider_uses_async_api_key_resolution() {
|
||||
use kigi_tools::types::ApiKeyProvider;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
struct AsyncProbe {
|
||||
sync_calls: Arc<AtomicU32>,
|
||||
async_calls: Arc<AtomicU32>,
|
||||
struct ProbeCredentials;
|
||||
impl kigi_auth::HttpAuth for ProbeCredentials {
|
||||
fn apply(
|
||||
&self,
|
||||
builder: reqwest::RequestBuilder,
|
||||
_base_url: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
builder.bearer_auth("session-bearer")
|
||||
}
|
||||
impl ApiKeyProvider for AsyncProbe {
|
||||
fn current_api_key(&self) -> Option<String> {
|
||||
self.sync_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Some("sync-stale".into())
|
||||
}
|
||||
fn current_api_key_async(
|
||||
&self,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send + '_>>
|
||||
{
|
||||
let counter = self.async_calls.clone();
|
||||
Box::pin(async move {
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
Some("async-fresh".into())
|
||||
})
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl kigi_auth::AuthCredentialProvider for ProbeCredentials {
|
||||
fn snapshot(&self) -> kigi_auth::CredentialSnapshot {
|
||||
kigi_auth::CredentialSnapshot {
|
||||
token: Some("session-bearer".into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
async fn refresh_after_unauthorized(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
let sync_calls = Arc::new(AtomicU32::new(0));
|
||||
let async_calls = Arc::new(AtomicU32::new(0));
|
||||
let probe: kigi_tools::types::SharedApiKeyProvider = Arc::new(AsyncProbe {
|
||||
sync_calls: sync_calls.clone(),
|
||||
async_calls: async_calls.clone(),
|
||||
});
|
||||
const SESSION_ENDPOINT: &str = "https://api.kimi.com/coding/v1";
|
||||
const FOREIGN_ENDPOINT: &str = "https://api.anthropic.com/v1";
|
||||
|
||||
let params = MemoryBackendParams {
|
||||
fn params_at(base_url: &str, credentials: EndpointScopedCredentials) -> MemoryBackendParams {
|
||||
MemoryBackendParams {
|
||||
session_id: "s1".into(),
|
||||
embed_config: Some(MemoryEmbeddingConfig {
|
||||
model: Some("test-embed-model".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
embed_base_url: "http://example/v1".into(),
|
||||
embed_api_key: Some("static-fallback".into()),
|
||||
embed_base_url: base_url.into(),
|
||||
embed_api_key: None,
|
||||
search_config: MemorySearchConfig::default(),
|
||||
watcher: None,
|
||||
stale_claim_secs: 60,
|
||||
search_source: "tool",
|
||||
api_key_provider: Some(probe),
|
||||
// No auth_credentials — forces the api_key_provider fallback path.
|
||||
auth_credentials: None,
|
||||
};
|
||||
embedding_credentials: credentials,
|
||||
}
|
||||
}
|
||||
|
||||
let provider = params.make_embedding_provider().await;
|
||||
/// The session bearer must never ride to a third-party embedding host.
|
||||
///
|
||||
/// `embed_base_url` is the CURRENT MODEL's endpoint, so a session on a BYOK
|
||||
/// or subscription-OAuth model aims memory embeddings at that provider —
|
||||
/// and the auth middleware stamps `Authorization` unconditionally.
|
||||
#[tokio::test]
|
||||
async fn session_credentials_are_withheld_from_a_foreign_endpoint() {
|
||||
let scoped = EndpointScopedCredentials::for_endpoint(
|
||||
FOREIGN_ENDPOINT,
|
||||
false,
|
||||
Some(Arc::new(ProbeCredentials)),
|
||||
);
|
||||
assert!(
|
||||
scoped.is_empty(),
|
||||
"an untrusted endpoint must drop both handles"
|
||||
);
|
||||
|
||||
let provider = params_at(FOREIGN_ENDPOINT, scoped)
|
||||
.make_embedding_provider()
|
||||
.await;
|
||||
assert!(
|
||||
provider.is_none(),
|
||||
"no credential may ride to a foreign endpoint, and there is no static key to fall back to"
|
||||
);
|
||||
}
|
||||
|
||||
/// The trusted-endpoint path still builds a credentialed provider.
|
||||
#[tokio::test]
|
||||
async fn session_credentials_ride_their_own_endpoint() {
|
||||
let scoped = EndpointScopedCredentials::for_endpoint(
|
||||
SESSION_ENDPOINT,
|
||||
true,
|
||||
Some(Arc::new(ProbeCredentials)),
|
||||
);
|
||||
assert!(
|
||||
!scoped.is_empty(),
|
||||
"a trusted endpoint must retain the handles"
|
||||
);
|
||||
|
||||
let provider = params_at(SESSION_ENDPOINT, scoped)
|
||||
.make_embedding_provider()
|
||||
.await;
|
||||
assert!(
|
||||
provider.is_some(),
|
||||
"provider must be built when model is set"
|
||||
"the session endpoint keeps its refresh-capable credential"
|
||||
);
|
||||
assert_eq!(
|
||||
async_calls.load(Ordering::SeqCst),
|
||||
1,
|
||||
"must call current_api_key_async exactly once per provider build"
|
||||
}
|
||||
|
||||
/// The runtime re-check, not construction alone, is what guards the wire:
|
||||
/// `MemoryBackendParams` is `Clone` and callers rewrite fields on the copy.
|
||||
#[tokio::test]
|
||||
async fn a_cloned_param_set_cannot_redirect_scoped_credentials() {
|
||||
let scoped = EndpointScopedCredentials::for_endpoint(
|
||||
SESSION_ENDPOINT,
|
||||
true,
|
||||
Some(Arc::new(ProbeCredentials)),
|
||||
);
|
||||
assert_eq!(
|
||||
sync_calls.load(Ordering::SeqCst),
|
||||
0,
|
||||
"sync current_api_key must NOT be called — the async path is the contract"
|
||||
let redirected = MemoryBackendParams {
|
||||
embed_base_url: FOREIGN_ENDPOINT.into(),
|
||||
..params_at(SESSION_ENDPOINT, scoped)
|
||||
};
|
||||
|
||||
assert!(
|
||||
redirected.make_embedding_provider().await.is_none(),
|
||||
"credentials scoped to one endpoint must not follow a rewritten base_url"
|
||||
);
|
||||
}
|
||||
|
||||
/// A platform's own API key is not a session credential: it is resolved for
|
||||
/// that platform and must keep serving that platform's endpoint.
|
||||
#[tokio::test]
|
||||
async fn a_platform_api_key_still_serves_its_own_endpoint() {
|
||||
let params = MemoryBackendParams {
|
||||
embed_api_key: Some("platform-key".into()),
|
||||
..params_at(FOREIGN_ENDPOINT, EndpointScopedCredentials::none())
|
||||
};
|
||||
assert!(
|
||||
params.make_embedding_provider().await.is_some(),
|
||||
"withholding the session credential must not disable BYOK embeddings"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ pub mod storage;
|
||||
pub mod text_utils;
|
||||
pub mod watcher;
|
||||
|
||||
pub use backend::{MemoryBackendImpl, MemoryBackendParams};
|
||||
pub use backend::{EndpointScopedCredentials, MemoryBackendImpl, MemoryBackendParams};
|
||||
pub use index::{MemoryIndex, init_sqlite_vec};
|
||||
pub use storage::{MemoryScope, MemoryStorage};
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -1574,7 +1574,7 @@ impl PlatformId {
|
||||
/// `models_cache.json`, 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`/`ultra`
|
||||
/// selectable reasoning efforts (incl. the codex-only `xhigh`/`max`
|
||||
/// tiers), so the fetch path maps them through the SAME
|
||||
/// `platform_wire_model_to_entry` output as a live listing — no new type.
|
||||
pub fn hardcoded_catalog(self) -> Option<Vec<WireModel>> {
|
||||
@@ -1617,13 +1617,13 @@ fn openai_codex_wire_models() -> Vec<WireModel> {
|
||||
codex_wire_model(
|
||||
"gpt-5.6-sol",
|
||||
"GPT-5.6-Sol",
|
||||
&["low", "medium", "high", "xhigh", "max", "ultra"],
|
||||
&["low", "medium", "high", "xhigh", "max"],
|
||||
"low",
|
||||
),
|
||||
codex_wire_model(
|
||||
"gpt-5.6-terra",
|
||||
"GPT-5.6-Terra",
|
||||
&["low", "medium", "high", "xhigh", "max", "ultra"],
|
||||
&["low", "medium", "high", "xhigh", "max"],
|
||||
"medium",
|
||||
),
|
||||
codex_wire_model(
|
||||
@@ -2655,7 +2655,7 @@ mod tests {
|
||||
|
||||
/// The HARDCODED openai-codex catalog is exactly the 4 supported+listed
|
||||
/// models, keyed by slug, ctx 272000, each exposing its exact supported
|
||||
/// efforts (incl. the codex-only `xhigh`/`max`/`ultra` tiers). The
|
||||
/// 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).
|
||||
#[test]
|
||||
@@ -2702,11 +2702,11 @@ mod tests {
|
||||
};
|
||||
assert_eq!(
|
||||
efforts("gpt-5.6-sol"),
|
||||
["low", "medium", "high", "xhigh", "max", "ultra"]
|
||||
["low", "medium", "high", "xhigh", "max"]
|
||||
);
|
||||
assert_eq!(
|
||||
efforts("gpt-5.6-terra"),
|
||||
["low", "medium", "high", "xhigh", "max", "ultra"]
|
||||
["low", "medium", "high", "xhigh", "max"]
|
||||
);
|
||||
assert_eq!(
|
||||
efforts("gpt-5.6-luna"),
|
||||
|
||||
@@ -1251,6 +1251,10 @@ mod tests {
|
||||
use ratatui::layout::Rect;
|
||||
use similar::ChangeTag;
|
||||
|
||||
// A sibling test engages the terminal-native lock, which flattens
|
||||
// every diff background process-wide. Hold the shared theme lock.
|
||||
let _theme = kigi_tui::theme::cache::pin_theme();
|
||||
|
||||
let hunk = vec![
|
||||
DiffLine {
|
||||
text: "let x = 1;\n".into(),
|
||||
@@ -1293,23 +1297,24 @@ mod tests {
|
||||
// The committed edit uses a flat background (terminal transparency), but
|
||||
// must still paint the per-line diff backgrounds — otherwise an added /
|
||||
// removed line is indistinguishable from context.
|
||||
let mut saw_insert = false;
|
||||
let mut saw_delete = false;
|
||||
//
|
||||
// Theme-agnostic: the line cache is keyed on the global theme,
|
||||
// so an exact-RGB match would race. Colors pinned in tool::edit.
|
||||
let mut bands = std::collections::BTreeSet::new();
|
||||
for y in 0..h {
|
||||
for x in 0..width {
|
||||
if let Some(cell) = buf.cell((x, y)) {
|
||||
saw_insert |= cell.bg == theme.diff_insert_bg;
|
||||
saw_delete |= cell.bg == theme.diff_delete_bg;
|
||||
if let Some(cell) = buf.cell((x, y))
|
||||
&& cell.bg != ratatui::style::Color::Reset
|
||||
{
|
||||
bands.insert(format!("{:?}", cell.bg));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_insert,
|
||||
"committed edit lost the insert (green) diff background"
|
||||
);
|
||||
assert!(
|
||||
saw_delete,
|
||||
"committed edit lost the delete (red) diff background"
|
||||
bands.len() >= 2,
|
||||
"committed edit lost its insert/delete diff bands: \
|
||||
theme={:?} h={h} bands={bands:?}",
|
||||
Theme::current_kind(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -55,6 +55,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.
|
||||
///
|
||||
@@ -454,11 +463,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
|
||||
|
||||
@@ -653,3 +653,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}");
|
||||
}
|
||||
|
||||
@@ -911,11 +911,6 @@ pub enum ReasoningEffort {
|
||||
/// Messages both accept `xhigh` AND `max` as separate levels in 2026;
|
||||
/// the Kimi wire spells its top tier `max` with no `xhigh`).
|
||||
Max,
|
||||
/// Codex-only top tier above `max` (the ChatGPT Codex backend exposes an
|
||||
/// `ultra` reasoning effort on its flagship models). Reachable ONLY via a
|
||||
/// model's server-declared effort menu (openai-codex); no built-in fallback
|
||||
/// menu offers it, so other providers never emit it.
|
||||
Ultra,
|
||||
}
|
||||
|
||||
impl ReasoningEffort {
|
||||
@@ -944,15 +939,12 @@ impl ReasoningEffort {
|
||||
Self::High => "high",
|
||||
Self::Xhigh => "xhigh",
|
||||
Self::Max => "max",
|
||||
Self::Ultra => "ultra",
|
||||
}
|
||||
}
|
||||
|
||||
/// Anthropic Messages API effort string; `None` for unsupported variants.
|
||||
/// `xhigh` and `max` are distinct levels on the 2026 Messages API (both
|
||||
/// appear in `GET /v1/models` `capabilities.effort`). `ultra` is codex-only
|
||||
/// and never selected on an Anthropic model, but maps to its own string for
|
||||
/// completeness (the Responses path writes effort via `as_str`, not this).
|
||||
/// appear in `GET /v1/models` `capabilities.effort`).
|
||||
pub fn to_messages_api(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::None | Self::Minimal => None,
|
||||
@@ -961,7 +953,6 @@ impl ReasoningEffort {
|
||||
Self::High => Some("high"),
|
||||
Self::Xhigh => Some("xhigh"),
|
||||
Self::Max => Some("max"),
|
||||
Self::Ultra => Some("ultra"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -984,9 +975,8 @@ impl std::str::FromStr for ReasoningEffort {
|
||||
"high" => Ok(Self::High),
|
||||
"xhigh" => Ok(Self::Xhigh),
|
||||
"max" => Ok(Self::Max),
|
||||
"ultra" => Ok(Self::Ultra),
|
||||
_ => Err(format!(
|
||||
"invalid reasoning effort: {s:?} (expected one of: none, minimal, low, medium, high, xhigh, max, ultra)"
|
||||
"invalid reasoning effort: {s:?} (expected one of: none, minimal, low, medium, high, xhigh, max)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -1878,30 +1868,16 @@ mod tests {
|
||||
assert_eq!(ReasoningEffort::Max.to_messages_api(), Some("max"));
|
||||
}
|
||||
|
||||
/// The codex-only `ultra` tier parses, serializes, and patches onto a
|
||||
/// Responses body as `reasoning.effort = "ultra"` (the crux of surfacing a
|
||||
/// codex model's full thinking menu). It is a DISTINCT level above `max`.
|
||||
/// `ultra` is not a tier any backend accepts.
|
||||
///
|
||||
/// The Codex Responses endpoint rejects it with a 400 listing its menu,
|
||||
/// which tops out at `max`. Parsing it would only send it again.
|
||||
#[test]
|
||||
fn reasoning_effort_ultra_is_a_distinct_codex_tier() {
|
||||
assert_eq!(
|
||||
"ultra".parse::<ReasoningEffort>().unwrap(),
|
||||
ReasoningEffort::Ultra
|
||||
);
|
||||
assert_eq!(
|
||||
"ULTRA".parse::<ReasoningEffort>().unwrap(),
|
||||
ReasoningEffort::Ultra
|
||||
);
|
||||
assert_ne!(ReasoningEffort::Ultra, ReasoningEffort::Max);
|
||||
assert_eq!(ReasoningEffort::Ultra.as_str(), "ultra");
|
||||
let json = serde_json::to_string(&ReasoningEffort::Ultra).unwrap();
|
||||
assert_eq!(json, "\"ultra\"");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ReasoningEffort>("\"ultra\"").unwrap(),
|
||||
ReasoningEffort::Ultra
|
||||
);
|
||||
let mut body = serde_json::json!({ "model": "gpt-5.6-sol" });
|
||||
patch_reasoning_effort(&mut body, Some(ReasoningEffort::Ultra));
|
||||
assert_eq!(body["reasoning"]["effort"], "ultra");
|
||||
fn reasoning_effort_ultra_is_not_a_tier() {
|
||||
assert!("ultra".parse::<ReasoningEffort>().is_err());
|
||||
assert!("ULTRA".parse::<ReasoningEffort>().is_err());
|
||||
assert!(serde_json::from_str::<ReasoningEffort>("\"ultra\"").is_err());
|
||||
assert_eq!(ReasoningEffort::Max.as_str(), "max");
|
||||
}
|
||||
|
||||
/// The account id is decoded STATELESSLY from the bearer JWT payload's
|
||||
@@ -2122,7 +2098,7 @@ mod tests {
|
||||
);
|
||||
let bad_type = as_map(serde_json::json!({"reasoningEffort": 3}));
|
||||
assert_eq!(parse_reasoning_effort_meta(Some(&bad_type)), None);
|
||||
// `ultra` is a real codex tier; a genuinely-unknown token still None.
|
||||
// A genuinely-unknown token is dropped, not guessed at.
|
||||
let unknown = as_map(serde_json::json!({"reasoningEffort": "MEGA"}));
|
||||
assert_eq!(parse_reasoning_effort_meta(Some(&unknown)), None);
|
||||
}
|
||||
|
||||
@@ -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(()) => {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1164,6 +1164,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 +1927,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 +2039,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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -1309,6 +1309,7 @@ output_byte_limit = 65536 # max output size (64KB)
|
||||
[toolset.web_fetch]
|
||||
proxy_endpoint = "https://proxy.example.com" # egress proxy URL (all requests routed through it)
|
||||
allowed_domains = ["docs.rs", "x.ai"] # override the built-in ~84-domain allowlist
|
||||
allow_local = false # true = reach an explicit localhost / 127.0.0.0/8 / ::1 URL
|
||||
|
||||
[shortcuts]
|
||||
send = ["Enter"]
|
||||
@@ -2100,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.
|
||||
@@ -2159,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.
|
||||
|
||||
---
|
||||
@@ -2384,6 +2385,7 @@ The agent persists all session updates automatically. Clients can reconnect and
|
||||
| `KIGI_AGENT` | Custom agent definition path or name (see [Agent Profiles](#agent-profiles)) |
|
||||
| `KIGI_WEB_FETCH` | Enable (`1`) or disable (`0`) the `web_fetch` tool |
|
||||
| `KIGI_WEB_FETCH_PROXY` | Egress proxy URL for `web_fetch` requests (overridden by `[toolset.web_fetch] proxy_endpoint`) |
|
||||
| `KIGI_WEB_FETCH_ALLOW_LOCAL` | `1` lets `web_fetch` reach an explicit loopback URL; private and metadata ranges stay blocked |
|
||||
| `KIGI_RESPECT_GITIGNORE` | Disable `.gitignore` filtering in tools when set to `0` |
|
||||
| `KIGI_FEEDBACK_ENABLED` | Enable (`1`) or disable (`0`) feedback system independently from telemetry |
|
||||
| `KIGI_DEPLOYMENT_KEY` | Management API key for enterprise deployments |
|
||||
|
||||
@@ -2171,6 +2171,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")
|
||||
@@ -2588,6 +2601,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 +2618,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 +2642,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 +2652,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);
|
||||
@@ -3696,6 +3715,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]`).
|
||||
@@ -9051,6 +9073,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() {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Port of kimi-cli's `/feedback` slash command (kimi-cli
|
||||
//! `src/kimi_cli/ui/shell/slash.py`, `feedback()`): subscription (OAuth)
|
||||
//! sessions POST the user's feedback text to `{coding_api_base_url}/feedback`
|
||||
//! with a Bearer token; everyone else is pointed at the GitHub issue tracker.
|
||||
//! with a Bearer token; everyone else is pointed at the project issue tracker.
|
||||
//! The request body carries exactly the fields kimi-cli sends:
|
||||
//! `session_id`, `content`, `version`, `os`, `model`.
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1938,7 +1938,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 +1949,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.
|
||||
|
||||
@@ -1475,7 +1475,7 @@ mod tests {
|
||||
/// short-circuits BEFORE any HTTP — there is NO mock `/models` server, yet
|
||||
/// the fetch returns exactly the 4 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`/`ultra`).
|
||||
/// its exact reasoning efforts (incl. the codex-only `xhigh`/`max`).
|
||||
/// A BOGUS base URL confirms no live `/models` request is attempted (it would
|
||||
/// otherwise fail against an unroutable host).
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -1556,10 +1556,10 @@ mod tests {
|
||||
.iter()
|
||||
.map(|o| o.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["low", "medium", "high", "xhigh", "max", "ultra"],
|
||||
"sol exposes the full codex effort menu incl. ultra"
|
||||
vec!["low", "medium", "high", "xhigh", "max"],
|
||||
"sol exposes the full codex effort menu"
|
||||
);
|
||||
// gpt-5.5 tops out at xhigh (no max/ultra).
|
||||
// gpt-5.5 tops out at xhigh (no max).
|
||||
let five_five = result
|
||||
.models
|
||||
.iter()
|
||||
|
||||
@@ -1725,8 +1725,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 +1830,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 +2025,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 {
|
||||
|
||||
@@ -357,6 +357,11 @@ impl MvpAgent {
|
||||
// time, so advertise pre-session; the in-session path
|
||||
// re-checks the live toolset.
|
||||
graph: goal && self.cfg.borrow().resolve_graph().value,
|
||||
// Tool-dependent, so fail closed like every other tool gate: the
|
||||
// builder strips `agent_swarm` whenever subagents are unavailable,
|
||||
// and advertising it then offers a menu entry that resolves to
|
||||
// literal prompt text.
|
||||
swarm: self.cfg.borrow().subagents_enabled,
|
||||
..crate::session::slash_commands::CommandAvailability::default()
|
||||
}
|
||||
}
|
||||
@@ -973,7 +978,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(),
|
||||
@@ -1357,7 +1365,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,
|
||||
@@ -1376,7 +1385,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"),
|
||||
}
|
||||
@@ -2178,6 +2191,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();
|
||||
@@ -2563,6 +2577,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!(
|
||||
|
||||
@@ -60,7 +60,6 @@ fn effort_label(effort: ReasoningEffort) -> String {
|
||||
ReasoningEffort::High => "High",
|
||||
ReasoningEffort::Xhigh => "X-High",
|
||||
ReasoningEffort::Max => "Max",
|
||||
ReasoningEffort::Ultra => "Ultra",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -396,11 +547,14 @@ pub(crate) async fn handle_subagent_request(
|
||||
let child_depth = ctx.parent_depth + 1;
|
||||
if child_depth >= MAX_SUBAGENT_DEPTH {
|
||||
let before = definition.tool_config.tools.len();
|
||||
definition.tool_config.tools.retain(|tc| tc.kind != Some(ToolKind::Task));
|
||||
definition
|
||||
.tool_config
|
||||
.tools
|
||||
.retain(|tc| !matches!(tc.kind, Some(ToolKind::Task | ToolKind::AgentSwarm)));
|
||||
if definition.tool_config.tools.len() < before {
|
||||
tracing::info!(
|
||||
subagent_id = % request.id, child_depth, max_depth =
|
||||
MAX_SUBAGENT_DEPTH, "Stripped task tool from child at max depth"
|
||||
MAX_SUBAGENT_DEPTH, "Stripped subagent-spawning tools from child at max depth"
|
||||
);
|
||||
}
|
||||
prune_orphaned_background_task_tools(&mut definition.tool_config);
|
||||
@@ -834,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
|
||||
@@ -1003,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,
|
||||
@@ -1271,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,
|
||||
@@ -1298,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(
|
||||
@@ -1322,6 +1505,7 @@ pub(crate) async fn handle_subagent_request(
|
||||
SubagentResult {
|
||||
success: false,
|
||||
cancelled: true,
|
||||
rate_limited: false,
|
||||
error: Some(reason),
|
||||
output: if final_text.is_empty() {
|
||||
std::sync::Arc::from(
|
||||
@@ -1359,6 +1543,7 @@ pub(crate) async fn handle_subagent_request(
|
||||
SubagentResult {
|
||||
success: false,
|
||||
cancelled: true,
|
||||
rate_limited: false,
|
||||
error: Some(format!("max turns reached (limit: {limit})")),
|
||||
output: if final_text.is_empty() {
|
||||
std::sync::Arc::from(
|
||||
@@ -1413,6 +1598,9 @@ pub(crate) async fn handle_subagent_request(
|
||||
SubagentResult {
|
||||
success: false,
|
||||
cancelled: was_cancelled,
|
||||
rate_limited: !was_cancelled
|
||||
&& i32::from(e.code)
|
||||
== crate::sampling::error::RATE_LIMITED_ERROR_CODE,
|
||||
error: Some(
|
||||
if was_cancelled {
|
||||
"Subagent was cancelled".to_string()
|
||||
@@ -1460,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),
|
||||
@@ -1486,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 {
|
||||
@@ -1503,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!(
|
||||
@@ -1570,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;
|
||||
|
||||
@@ -3298,3 +3298,27 @@ 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");
|
||||
}
|
||||
@@ -83,6 +83,35 @@ impl AuthCredentialProvider for ShellAuthCredentialProvider {
|
||||
self.auth_manager.try_recover_unauthorized().await
|
||||
}
|
||||
}
|
||||
/// The memory-embedding credentials for `embed_base_url`, decided by the ONE
|
||||
/// authority rather than re-derived here (C1).
|
||||
///
|
||||
/// `embed_base_url` is the session model's own endpoint, so on a BYOK or
|
||||
/// subscription-OAuth model it points at that provider's host — and
|
||||
/// [`kigi_auth::AuthRetryMiddleware`] stamps `Authorization` on every request
|
||||
/// it wraps. Asking [`CredentialAuthority::manager_for`] answers both halves at
|
||||
/// once: whether a session credential may ride there at all, and WHICH manager
|
||||
/// governs it (a subscription-OAuth platform's pooled manager at its own host,
|
||||
/// the primary at the session's coding endpoint). No manager means no session
|
||||
/// credential at all; the platform's own `embed_api_key` is untouched and keeps
|
||||
/// serving its own endpoint.
|
||||
///
|
||||
/// The session's `SharedApiKeyProvider` is deliberately NOT forwarded: it is
|
||||
/// hard-wired to the PRIMARY manager, so at a pooled platform's host — where a
|
||||
/// credential may ride, but only that platform's own — it would resolve the
|
||||
/// wrong bearer.
|
||||
pub(crate) fn embedding_session_credentials(
|
||||
embed_base_url: &str,
|
||||
platform: Option<kigi_models::PlatformId>,
|
||||
authority: &crate::auth::credential_authority::CredentialAuthority,
|
||||
) -> kigi_memory::EndpointScopedCredentials {
|
||||
let auth_credentials = authority.manager_for(platform, embed_base_url).map(|am| {
|
||||
Arc::new(ShellAuthCredentialProvider::new(am, None, None))
|
||||
as Arc<dyn AuthCredentialProvider>
|
||||
});
|
||||
let may_ride = auth_credentials.is_some();
|
||||
kigi_memory::EndpointScopedCredentials::for_endpoint(embed_base_url, may_ride, auth_credentials)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -253,6 +282,38 @@ mod tests {
|
||||
"snapshot must reflect refreshed token for subsequent apply() calls"
|
||||
);
|
||||
}
|
||||
/// C1: memory embeddings follow the credential authority, not the session.
|
||||
///
|
||||
/// A session whose model is a BYOK platform aims `embed_base_url` at that
|
||||
/// provider's host; the authority answers "no manager governs a credential
|
||||
/// there", so nothing rides. The session's own coding endpoint still does.
|
||||
#[test]
|
||||
fn embedding_credentials_follow_the_credential_authority() {
|
||||
let _guard = EarlyInvalidationGuard::pin_to_default();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = make_manager(
|
||||
&dir,
|
||||
Some(make_auth("session-bearer", ChronoDuration::hours(1))),
|
||||
);
|
||||
let endpoints = crate::agent::config::EndpointsConfig::default();
|
||||
let coding_endpoint = endpoints.proxy_url();
|
||||
let authority =
|
||||
crate::auth::credential_authority::CredentialAuthority::new(endpoints, Some(mgr));
|
||||
|
||||
assert!(
|
||||
!embedding_session_credentials(&coding_endpoint, None, &authority).is_empty(),
|
||||
"the session's own coding endpoint keeps its credential"
|
||||
);
|
||||
assert!(
|
||||
embedding_session_credentials(
|
||||
"https://api.anthropic.com/v1",
|
||||
kigi_models::PlatformId::parse("anthropic"),
|
||||
&authority,
|
||||
)
|
||||
.is_empty(),
|
||||
"an API-key platform's host must receive no session credential"
|
||||
);
|
||||
}
|
||||
/// Deployment-key path has no recovery (operator owns the bearer).
|
||||
#[tokio::test]
|
||||
async fn refresh_after_unauthorized_is_noop_for_deployment_key() {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -804,7 +862,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 +950,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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()],
|
||||
¤t_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()],
|
||||
¤t_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;
|
||||
|
||||
|
||||
@@ -121,6 +121,10 @@ mod model_switch;
|
||||
mod prompt_queue;
|
||||
#[path = "acp_session_impl/slash_exec.rs"]
|
||||
mod slash_exec;
|
||||
#[path = "acp_session_impl/stationarity_seam.rs"]
|
||||
mod stationarity_seam;
|
||||
#[path = "acp_session_impl/swarm.rs"]
|
||||
mod swarm;
|
||||
use super::PromptOrigin;
|
||||
use super::acp_types;
|
||||
use super::chat_persistence;
|
||||
@@ -616,6 +620,9 @@ pub(crate) struct SessionActor {
|
||||
/// layered over the goal engine. Modeled after `goal_tracker` above;
|
||||
/// all graph state logic lives in `graph_tracker.rs`.
|
||||
pub(crate) graph_tracker: Arc<parking_lot::Mutex<crate::session::graph_tracker::GraphTracker>>,
|
||||
/// Swarm mode: a standing instruction to fan work out, independent of the
|
||||
/// goal/graph engines — it steers tool choice, it does not drive turns.
|
||||
pub(crate) swarm_mode: std::cell::Cell<crate::session::swarm_mode::SwarmMode>,
|
||||
/// Max graph nodes running concurrently (1 = serial G0 behavior).
|
||||
/// Cached at actor construction from `resolve_graph_concurrency`.
|
||||
pub(crate) graph_concurrency: u32,
|
||||
@@ -1033,6 +1040,9 @@ impl SessionActor {
|
||||
// Graph rides the goal harness: nodes execute as goals, so
|
||||
// `/graph` is only real when `/goal` is.
|
||||
graph: self.graph_enabled && goal,
|
||||
swarm: tool_names
|
||||
.iter()
|
||||
.any(|n| n == kigi_tools::implementations::kigi::AGENT_SWARM_TOOL_NAME),
|
||||
}
|
||||
}
|
||||
/// Names of every tool registered with the session's tool bridge.
|
||||
@@ -1508,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)]
|
||||
|
||||
@@ -8,7 +8,12 @@ pub(super) fn turn_result_to_hook_outcome(
|
||||
) -> kigi_tool_protocol::turn_hook::TurnHookOutcome {
|
||||
use kigi_tool_protocol::turn_hook::TurnHookOutcome;
|
||||
match result {
|
||||
Ok(TurnOutcome::Completed { .. }) => TurnHookOutcome::Completed,
|
||||
// A stationarity halt is a completed turn for hook purposes: nobody
|
||||
// cancelled it, and a `Cancelled` outcome would tell every Stop hook
|
||||
// the user interrupted the model.
|
||||
Ok(TurnOutcome::Completed { .. }) | Ok(TurnOutcome::StationarityHalted { .. }) => {
|
||||
TurnHookOutcome::Completed
|
||||
}
|
||||
Ok(TurnOutcome::Cancelled { .. }) | Ok(TurnOutcome::MaxTurnsReached { .. }) => {
|
||||
TurnHookOutcome::Cancelled
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -709,6 +709,36 @@ impl SessionActor {
|
||||
BuiltinAction::GoalSet { .. } => {
|
||||
unreachable!("GoalSet is intercepted in handle_prompt")
|
||||
}
|
||||
BuiltinAction::SwarmSet { enabled } => {
|
||||
let msg = self.apply_swarm_mode(
|
||||
enabled.then_some(crate::session::swarm_mode::SwarmTrigger::Manual),
|
||||
);
|
||||
self.send_slash_command_output(&msg).await;
|
||||
ok_end_turn(0, None)
|
||||
}
|
||||
BuiltinAction::SwarmToggle => {
|
||||
let turning_on = !self.swarm_mode.get().is_active();
|
||||
let msg = self.apply_swarm_mode(
|
||||
turning_on.then_some(crate::session::swarm_mode::SwarmTrigger::Manual),
|
||||
);
|
||||
self.send_slash_command_output(&msg).await;
|
||||
ok_end_turn(0, None)
|
||||
}
|
||||
// `/swarm <task>` is handled before dispatch (it has to seed the
|
||||
// turn with the task text); reaching here means the interception
|
||||
// was bypassed, so report rather than silently dropping the task.
|
||||
BuiltinAction::SwarmTask { prompt } => {
|
||||
tracing::warn!(
|
||||
prompt_len = prompt.len(),
|
||||
"/swarm <task> reached the builtin executor; the turn seam did not intercept it"
|
||||
);
|
||||
self.send_slash_command_output(
|
||||
"Could not start a swarm turn for that task. Run `/swarm on` and send the \
|
||||
task as a normal message.",
|
||||
)
|
||||
.await;
|
||||
ok_end_turn(0, None)
|
||||
}
|
||||
BuiltinAction::GoalStatus => {
|
||||
let current_tokens = self.chat_state_handle.get_total_tokens().await as i64;
|
||||
let goal_tokens = self.goal_tokens_used(current_tokens);
|
||||
|
||||
@@ -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>,
|
||||
@@ -311,6 +312,18 @@ pub(crate) async fn spawn_session_actor(
|
||||
};
|
||||
let embed_base_url = sampling_config.base_url.clone();
|
||||
let embed_api_key = sampling_config.api_key.clone();
|
||||
// The platform behind the endpoint memory embeddings will call, resolved
|
||||
// through the SAME session-key disambiguation the actor is seeded with, so
|
||||
// a slug that collides across platforms cannot resolve to the twin (H-b).
|
||||
let embed_platform = {
|
||||
let models = models_manager.models();
|
||||
crate::agent::models::platform_for_slug(
|
||||
&models,
|
||||
crate::agent::models::selected_catalog_key_for_spawn(&models, &session_model_id)
|
||||
.as_deref(),
|
||||
&sampling_config.model,
|
||||
)
|
||||
};
|
||||
let session_pruning_config: crate::config::PruningConfig = memory_config.as_ref().map_or_else(
|
||||
|| crate::config::PruningConfig {
|
||||
enabled: false,
|
||||
@@ -618,16 +631,11 @@ pub(crate) async fn spawn_session_actor(
|
||||
watcher,
|
||||
stale_claim_secs: watcher_config.stale_claim_secs,
|
||||
search_source: "tool",
|
||||
api_key_provider: api_key_provider.clone(),
|
||||
auth_credentials: auth_manager.as_ref().map(|am| {
|
||||
std::sync::Arc::new(
|
||||
crate::auth::credential_provider::ShellAuthCredentialProvider::new(
|
||||
am.clone(),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
) as std::sync::Arc<dyn kigi_auth::AuthCredentialProvider>
|
||||
}),
|
||||
embedding_credentials: crate::auth::credential_provider::embedding_session_credentials(
|
||||
&embed_base_url,
|
||||
embed_platform,
|
||||
&models_manager.credential_authority(),
|
||||
),
|
||||
};
|
||||
let backend = crate::session::memory::MemoryBackendImpl::from_session_params(
|
||||
storage.clone(),
|
||||
@@ -1025,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),
|
||||
},
|
||||
@@ -1108,6 +1117,7 @@ pub(crate) async fn spawn_session_actor(
|
||||
goal_tracker,
|
||||
graph_enabled,
|
||||
graph_tracker,
|
||||
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||
graph_concurrency: effective_config.resolve_graph_concurrency(),
|
||||
graph_node_rounds: effective_config.resolve_graph_node_rounds(),
|
||||
graph_replan_cap: effective_config.resolve_graph_replan_cap(),
|
||||
@@ -1287,8 +1297,11 @@ pub(crate) async fn spawn_session_actor(
|
||||
.map(|mc| mc.embedding.clone())
|
||||
.unwrap_or_default();
|
||||
let embed_dims = embed_config.dimensions;
|
||||
let sampling_base_url = embed_base_url.clone();
|
||||
let sampling_api_key = embed_api_key.clone();
|
||||
// The session's own params, so the background reindex embeds through
|
||||
// the same endpoint-scoped credential as every foreground path — a
|
||||
// second locally-built provider would re-derive credentials outside
|
||||
// the chokepoint and would carry a static key with no 401 refresh.
|
||||
let reindex_params = session.memory.backend_params.clone();
|
||||
let session_id_for_reindex = session_info.id.to_string();
|
||||
let chunks_added_counter = session.memory.chunks_added.clone();
|
||||
tokio::task::spawn_local(async move {
|
||||
@@ -1311,13 +1324,8 @@ pub(crate) async fn spawn_session_actor(
|
||||
target : kigi_log::memory_log::TARGET, files = files.len(),
|
||||
"MEMORY_REINDEX: background reindex complete"
|
||||
);
|
||||
if let Some(api_key) = sampling_api_key
|
||||
&& let Some(provider) =
|
||||
crate::session::memory::embedding::ApiEmbeddingProvider::from_session(
|
||||
&embed_config,
|
||||
sampling_base_url,
|
||||
api_key,
|
||||
)
|
||||
if let Some(ref params) = reindex_params
|
||||
&& let Some(provider) = params.make_embedding_provider().await
|
||||
{
|
||||
crate::session::memory::embed_missing_chunks(&index, &provider).await;
|
||||
}
|
||||
@@ -1529,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>,
|
||||
@@ -1630,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| {
|
||||
@@ -1673,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,
|
||||
@@ -1753,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(|_| {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Turn-loop seam for the stationarity detector.
|
||||
|
||||
use super::*;
|
||||
use crate::session::stationarity::{
|
||||
IdenticalToolCallRun, NUDGE_AFTER_IDENTICAL_TOOL_CALLS, command_is_true, hash_batch,
|
||||
};
|
||||
|
||||
/// Sent once, naming the blocking wait a poller should use.
|
||||
const STATIONARITY_NUDGE: &str = "\
|
||||
You have called the same tool with the same arguments repeatedly and are in a \
|
||||
polling loop. Stop repeating that call. If you are waiting on a background \
|
||||
task, block on it with the wait-tasks tool instead of re-checking its output; \
|
||||
if you are waiting on anything else, sleep once and check once. If you cannot \
|
||||
make progress, stop and tell the user what you are waiting for. This turn will \
|
||||
be halted automatically if the identical call keeps repeating.";
|
||||
|
||||
impl SessionActor {
|
||||
/// Enforces the ceilings; `Some` halts the turn. Silent but logged.
|
||||
pub(crate) async fn observe_tool_call_stationarity(
|
||||
self: &Arc<Self>,
|
||||
run: &mut IdenticalToolCallRun,
|
||||
tool_calls: &[kigi_sampling_types::conversation::ToolCall],
|
||||
loop_index: usize,
|
||||
) -> Option<StationarityHalt> {
|
||||
let batch_hash = hash_batch(
|
||||
tool_calls
|
||||
.iter()
|
||||
.map(|tc| (tc.name.as_str(), tc.arguments.as_ref())),
|
||||
);
|
||||
let tool_name = tool_calls
|
||||
.first()
|
||||
.map(|tc| tc.name.clone())
|
||||
.unwrap_or_default();
|
||||
let is_true_noop = self.is_run_true_step(tool_calls).await;
|
||||
let run_len = run.observe(batch_hash, &tool_name, is_true_noop);
|
||||
|
||||
if run_len == NUDGE_AFTER_IDENTICAL_TOOL_CALLS {
|
||||
tracing::warn!(
|
||||
tool_name = %run.tool_name(),
|
||||
run_len,
|
||||
loop_index,
|
||||
"action stationarity: nudging a repeating tool call"
|
||||
);
|
||||
kigi_log::unified_log::warn(
|
||||
"shell.turn.action_stationarity_nudge",
|
||||
Some(self.session_info.id.0.as_ref()),
|
||||
Some(serde_json::json!({
|
||||
"tool_name": run.tool_name(),
|
||||
"run_len": run_len,
|
||||
"loop_index": loop_index,
|
||||
})),
|
||||
);
|
||||
self.push_system_reminder(STATIONARITY_NUDGE);
|
||||
}
|
||||
|
||||
if run_len < run.hard_stop_threshold() {
|
||||
return None;
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
tool_name = %run.tool_name(),
|
||||
run_len,
|
||||
loop_index,
|
||||
true_noop = run.is_true_noop_run(),
|
||||
"action stationarity: halting the turn"
|
||||
);
|
||||
kigi_log::unified_log::warn(
|
||||
"shell.turn.action_stationarity_stop",
|
||||
Some(self.session_info.id.0.as_ref()),
|
||||
Some(serde_json::json!({
|
||||
"tool_name": run.tool_name(),
|
||||
"run_len": run_len,
|
||||
"loop_index": loop_index,
|
||||
"true_noop": run.is_true_noop_run(),
|
||||
})),
|
||||
);
|
||||
Some(StationarityHalt {
|
||||
tool_name: run.tool_name().to_string(),
|
||||
run_len,
|
||||
true_noop: run.is_true_noop_run(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this batch is a single shell call that does nothing.
|
||||
///
|
||||
/// Size-gated, not name-gated: the shell tool is renamed
|
||||
/// `run_terminal_command`, so a name gate would silently disable this.
|
||||
/// Parse failure fails open; multi-call batches use the ordinary ceiling.
|
||||
async fn is_run_true_step(
|
||||
&self,
|
||||
tool_calls: &[kigi_sampling_types::conversation::ToolCall],
|
||||
) -> bool {
|
||||
/// Bound on `{"command":"true"}` plus sibling fields.
|
||||
const MAX_NOOP_ARGS_BYTES: usize = 512;
|
||||
|
||||
let [tc] = tool_calls else {
|
||||
return false;
|
||||
};
|
||||
if tc.arguments.as_ref().len() > MAX_NOOP_ARGS_BYTES {
|
||||
return false;
|
||||
}
|
||||
let Ok(args) = serde_json::from_str::<serde_json::Value>(tc.arguments.as_ref()) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(input) = self.tool_bridge_handle().try_parse(&tc.name, args).await else {
|
||||
return false;
|
||||
};
|
||||
matches!(
|
||||
input,
|
||||
kigi_tools::types::tool_io::ToolInput::Bash(ref b) if command_is_true(&b.command)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a turn was halted, carried to its outcome.
|
||||
pub(crate) struct StationarityHalt {
|
||||
pub(crate) tool_name: String,
|
||||
pub(crate) run_len: u32,
|
||||
pub(crate) true_noop: bool,
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Swarm-mode seam: arming/disarming the mode and the reminder it injects.
|
||||
|
||||
use super::*;
|
||||
use crate::session::swarm_mode::{SWARM_ENTER_REMINDER, SWARM_EXIT_REMINDER, SwarmTrigger};
|
||||
|
||||
impl SessionActor {
|
||||
/// Arms (`Some(trigger)`) or disarms (`None`) swarm mode, injecting or
|
||||
/// retracting the doctrine exactly once, and returns the line to show.
|
||||
pub(crate) fn apply_swarm_mode(self: &Arc<Self>, trigger: Option<SwarmTrigger>) -> String {
|
||||
let mut mode = self.swarm_mode.get();
|
||||
let message = match trigger {
|
||||
Some(trigger) => {
|
||||
if mode.enter(trigger) {
|
||||
self.inject_swarm_reminder(SWARM_ENTER_REMINDER);
|
||||
}
|
||||
"Swarm mode on: work will be split across a fleet of subagents. \
|
||||
`/swarm off` to stop."
|
||||
}
|
||||
None => {
|
||||
// Unconditional, unlike the automatic expiry: the doctrine
|
||||
// rides the conversation and therefore survives a resume, a
|
||||
// fork and a compaction that the in-memory flag does not. If
|
||||
// the user explicitly asks for it off, the retraction has to
|
||||
// reach the model even when this session never saw it armed.
|
||||
mode.exit();
|
||||
self.inject_swarm_reminder(SWARM_EXIT_REMINDER);
|
||||
"Swarm mode off."
|
||||
}
|
||||
};
|
||||
self.swarm_mode.set(mode);
|
||||
message.to_string()
|
||||
}
|
||||
|
||||
/// A turn-scoped guard that disarms a per-turn swarm mode however the turn
|
||||
/// ends.
|
||||
///
|
||||
/// The post-loop call site is not enough: a user interrupt ABORTS the turn
|
||||
/// future (`cancel_running_task` → `JoinHandle::abort`), dropping it at its
|
||||
/// current await point, and several `?` paths return before the loop's end.
|
||||
/// Each of those leaks a `/swarm <task>` mode into the user's next,
|
||||
/// unrelated prompt. The goal engine hit the same class and compensates
|
||||
/// inside the cancel path; a guard is the version that cannot be forgotten
|
||||
/// at a new exit.
|
||||
pub(crate) fn swarm_turn_guard(self: &Arc<Self>) -> SwarmTurnGuard {
|
||||
SwarmTurnGuard {
|
||||
session: self.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Disarms at a turn boundary when the trigger was per-turn.
|
||||
pub(crate) fn expire_swarm_mode_at_turn_end(self: &Arc<Self>) {
|
||||
if !self.swarm_mode.get().expires_at_turn_end() {
|
||||
return;
|
||||
}
|
||||
let mut mode = self.swarm_mode.get();
|
||||
if mode.exit() {
|
||||
self.inject_swarm_reminder(SWARM_EXIT_REMINDER);
|
||||
}
|
||||
self.swarm_mode.set(mode);
|
||||
}
|
||||
|
||||
/// The doctrine rides the session's existing `<system-reminder>` channel,
|
||||
/// so it is tagged the same way every other reminder is and needs no
|
||||
/// second injection path of its own.
|
||||
fn inject_swarm_reminder(self: &Arc<Self>, text: &str) {
|
||||
self.push_system_reminder(text);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs [`SessionActor::expire_swarm_mode_at_turn_end`] on every turn exit,
|
||||
/// including an aborted future.
|
||||
pub(crate) struct SwarmTurnGuard {
|
||||
session: Arc<SessionActor>,
|
||||
}
|
||||
|
||||
impl Drop for SwarmTurnGuard {
|
||||
fn drop(&mut self) {
|
||||
self.session.expire_swarm_mode_at_turn_end();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -1546,6 +1548,18 @@ impl SessionActor {
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
// Without an explicit arm the catch-all below titles this "Tool
|
||||
// call" — for an entry that can hold the turn for the whole swarm.
|
||||
ToolInput::AgentSwarm(swarm) => (
|
||||
format!(
|
||||
"{} ({} members)",
|
||||
swarm.description,
|
||||
swarm.items.len() + swarm.resume_agent_ids.len()
|
||||
),
|
||||
acp::ToolKind::Other,
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
ToolInput::EnterPlanMode(_) => (
|
||||
"Plan: Enter".to_string(),
|
||||
acp::ToolKind::Other,
|
||||
|
||||
@@ -217,6 +217,10 @@ impl SessionActor {
|
||||
persist_ack: Option<oneshot::Sender<()>>,
|
||||
) -> PromptTurnResult {
|
||||
let handle_prompt_start = std::time::Instant::now();
|
||||
// Armed before anything can arm swarm mode, so a `/swarm <task>` mode
|
||||
// is disarmed on EVERY exit from this turn — including the abort a
|
||||
// user interrupt performs, which never reaches post-loop code.
|
||||
let _swarm_turn_guard = self.swarm_turn_guard();
|
||||
let prompt_length: usize = prompt_blocks
|
||||
.iter()
|
||||
.map(|b| match b {
|
||||
@@ -301,6 +305,13 @@ impl SessionActor {
|
||||
span.record("command_source", "builtin");
|
||||
}
|
||||
match action {
|
||||
// `/swarm <task>` arms the mode for this turn only and
|
||||
// sends the task as the prompt, so the doctrine is in the
|
||||
// conversation before the model reads the work.
|
||||
BuiltinAction::SwarmTask { prompt } => {
|
||||
self.apply_swarm_mode(Some(crate::session::swarm_mode::SwarmTrigger::Task));
|
||||
vec![text_block(prompt)]
|
||||
}
|
||||
BuiltinAction::GoalSet {
|
||||
objective,
|
||||
token_budget,
|
||||
@@ -670,22 +681,14 @@ impl SessionActor {
|
||||
.await
|
||||
.is_some()
|
||||
{
|
||||
let (flush_tx, flush_rx) = oneshot::channel();
|
||||
if self
|
||||
.notifications
|
||||
.persistence_tx
|
||||
.send(PersistenceMsg::FlushAndAck {
|
||||
respond_to: flush_tx,
|
||||
})
|
||||
.is_ok()
|
||||
&& flush_rx.await.is_ok()
|
||||
{
|
||||
let _ = ack.send(());
|
||||
} else {
|
||||
tracing::error!(
|
||||
session_id = % self.session_info.id.0, prompt_id = %
|
||||
prompt_id, "persist_ack flush barrier failed"
|
||||
);
|
||||
match self.persistence_barrier().await {
|
||||
Ok(()) => {
|
||||
let _ = ack.send(());
|
||||
}
|
||||
Err(error) => tracing::error!(
|
||||
session_id = % self.session_info.id.0, prompt_id = % prompt_id,
|
||||
%error, "persist_ack flush barrier failed"
|
||||
),
|
||||
}
|
||||
} else {
|
||||
tracing::error!(
|
||||
@@ -779,7 +782,7 @@ impl SessionActor {
|
||||
let turn_tool_count = self.events.tool_count_this_turn();
|
||||
let bridge_outcome = turn_result_to_hook_outcome(&result);
|
||||
match &result {
|
||||
Ok(TurnOutcome::Completed { .. }) => {
|
||||
Ok(TurnOutcome::Completed { .. }) | Ok(TurnOutcome::StationarityHalted { .. }) => {
|
||||
self.emit_turn_ended(
|
||||
crate::session::events::TurnOutcomeLabel::Completed,
|
||||
None,
|
||||
@@ -869,7 +872,9 @@ impl SessionActor {
|
||||
let doom_tally = std::mem::take(&mut *self.doom_loop_turn_tally.lock());
|
||||
doom_tally.fired();
|
||||
let stop_reason_str = match &result {
|
||||
Ok(TurnOutcome::Completed { .. }) => "end_turn",
|
||||
Ok(TurnOutcome::Completed { .. }) | Ok(TurnOutcome::StationarityHalted { .. }) => {
|
||||
"end_turn"
|
||||
}
|
||||
Ok(TurnOutcome::Cancelled { .. }) | Ok(TurnOutcome::MaxTurnsReached { .. }) => {
|
||||
"cancelled"
|
||||
}
|
||||
@@ -885,7 +890,7 @@ impl SessionActor {
|
||||
)
|
||||
.await;
|
||||
match &result {
|
||||
Ok(TurnOutcome::Completed { .. }) => {
|
||||
Ok(TurnOutcome::Completed { .. }) | Ok(TurnOutcome::StationarityHalted { .. }) => {
|
||||
for contributor in self.extension_registry.turn_lifecycle_contributors() {
|
||||
contributor
|
||||
.on_turn_done(&kigi_agent_lifecycle::TurnDoneInput)
|
||||
@@ -970,6 +975,26 @@ impl SessionActor {
|
||||
PromptCompletionKind::MaxTurnsReached { limit },
|
||||
None,
|
||||
),
|
||||
// `EndTurn`, not `Cancelled`: the client must not render
|
||||
// an interrupted turn for something nobody interrupted.
|
||||
// The halt's detail rides `completion_kind`, which is
|
||||
// where a bug report can still read it.
|
||||
TurnOutcome::StationarityHalted {
|
||||
snapshot,
|
||||
tool_name,
|
||||
run_len,
|
||||
true_noop,
|
||||
..
|
||||
} => (
|
||||
acp::StopReason::EndTurn,
|
||||
*snapshot,
|
||||
PromptCompletionKind::StationarityHalted {
|
||||
tool_name,
|
||||
run_len,
|
||||
true_noop,
|
||||
},
|
||||
None,
|
||||
),
|
||||
};
|
||||
if let Some(snapshot) = snapshot.as_mut() {
|
||||
self.apply_prompt_modes_to_snapshot(snapshot);
|
||||
@@ -1271,7 +1296,11 @@ impl SessionActor {
|
||||
let mut result = self
|
||||
.process_conversation_turn(req_id, json_schema.clone())
|
||||
.await;
|
||||
if matches!(result, Ok(TurnOutcome::MaxTurnsReached { .. })) {
|
||||
// Harness stopped the turn; retrying re-enters the same wall.
|
||||
if matches!(
|
||||
result,
|
||||
Ok(TurnOutcome::MaxTurnsReached { .. }) | Ok(TurnOutcome::StationarityHalted { .. })
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
if let Ok(TurnOutcome::Completed {
|
||||
@@ -1588,6 +1617,10 @@ impl SessionActor {
|
||||
self.record_turn_model().await;
|
||||
let mut metrics_drop_guard = TurnMetrics::new();
|
||||
let mut turn_tools_called: Vec<String> = Vec::new();
|
||||
let mut identical_tool_calls =
|
||||
crate::session::stationarity::IdenticalToolCallRun::default();
|
||||
// Retained across execute: observed only after results land.
|
||||
let mut last_batch: Vec<kigi_sampling_types::conversation::ToolCall> = Vec::new();
|
||||
let mut tool_turn_count: usize = 1;
|
||||
let mut loop_index: u32 = 0;
|
||||
let mut todo_gate_fires: u32 = 0;
|
||||
@@ -2004,6 +2037,8 @@ impl SessionActor {
|
||||
}
|
||||
turn_tools_called.push(tc.name.clone());
|
||||
}
|
||||
last_batch.clear();
|
||||
last_batch.extend(tool_calls.iter().cloned());
|
||||
let tool_call_responses: Vec<ToolCallResponse> = tool_calls
|
||||
.into_iter()
|
||||
.map(|tc| ToolCallResponse {
|
||||
@@ -2046,6 +2081,34 @@ impl SessionActor {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// After execute: every call has a result, so nothing dangles
|
||||
// and the nudge cannot trigger the "cancelled" repair.
|
||||
// Ok-gated: one `?` inside can leave a call resultless.
|
||||
if execute_tool_calls_result.is_ok()
|
||||
&& let Some(halt) = self
|
||||
.observe_tool_call_stationarity(
|
||||
&mut identical_tool_calls,
|
||||
&last_batch,
|
||||
tool_turn_count,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let snapshot = self
|
||||
.finalize_turn_bookkeeping(
|
||||
req_id,
|
||||
conv_turn_start,
|
||||
&turn_span_totals,
|
||||
model_fingerprint.clone(),
|
||||
)
|
||||
.await;
|
||||
return Ok(TurnOutcome::StationarityHalted {
|
||||
snapshot: Box::new(snapshot),
|
||||
tools_called: std::mem::take(&mut turn_tools_called),
|
||||
tool_name: halt.tool_name,
|
||||
run_len: halt.run_len,
|
||||
true_noop: halt.true_noop,
|
||||
});
|
||||
}
|
||||
let next_turn = tool_turn_count + 1;
|
||||
if let Some(limit) = self.max_turns
|
||||
&& next_turn > limit
|
||||
|
||||
@@ -62,6 +62,19 @@ pub(crate) enum TurnOutcome {
|
||||
},
|
||||
/// The `--max-turns` limit was reached after a tool-execution cycle.
|
||||
MaxTurnsReached { limit: usize },
|
||||
/// One tool call repeated past its ceiling; the turn was halted.
|
||||
///
|
||||
/// Groups with [`Self::Completed`], NOT [`Self::Cancelled`]: nobody
|
||||
/// cancelled anything. As a cancellation it would report
|
||||
/// `StopReason::Cancelled`, fire the abort lifecycle, kill the turn's
|
||||
/// subagents, grow the goal back-off streak, and let recovery re-run it.
|
||||
StationarityHalted {
|
||||
snapshot: Box<Option<TurnDeltaSnapshot>>,
|
||||
tools_called: Vec<String>,
|
||||
tool_name: String,
|
||||
run_len: u32,
|
||||
true_noop: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -333,15 +333,24 @@ impl SessionActor {
|
||||
if let Err(e) = crate::session::replay_events::flush_replay_actor(&self.event_tx).await {
|
||||
tracing::warn!(?e, "flush_replay_actor failed");
|
||||
}
|
||||
if let Err(error) = self.persistence_barrier().await {
|
||||
tracing::error!(%error, "persistence flush failed");
|
||||
}
|
||||
}
|
||||
/// The `FlushAndAck` durability barrier: `Ok` only once every prior
|
||||
/// persistence write is on stable media.
|
||||
pub(super) async fn persistence_barrier(&self) -> std::io::Result<()> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
if self
|
||||
.notifications
|
||||
.persistence_tx
|
||||
.send(PersistenceMsg::FlushAndAck { respond_to: tx })
|
||||
.is_ok()
|
||||
.is_err()
|
||||
{
|
||||
let _ = rx.await;
|
||||
return Err(std::io::Error::other("persistence actor gone"));
|
||||
}
|
||||
rx.await
|
||||
.unwrap_or_else(|_| Err(std::io::Error::other("persistence actor gone")))
|
||||
}
|
||||
/// Extracts the update type name and relevant parameters for logging
|
||||
fn extract_update_info(
|
||||
|
||||
+12
-5
@@ -106,6 +106,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
|
||||
tokio_util::sync::CancellationToken::new(),
|
||||
);
|
||||
let actor = Arc::new(SessionActor {
|
||||
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||
session_info,
|
||||
auth_method_id: test_auth_method_id("test-auth"),
|
||||
model_auth_facts: std::cell::RefCell::new(None),
|
||||
@@ -156,6 +157,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
|
||||
previous_model: std::cell::Cell::new(None),
|
||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||
verbatim_input: true,
|
||||
tool_choice: Default::default(),
|
||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
||||
},
|
||||
@@ -433,7 +435,7 @@ async fn first_turn_memory_injection_persists_to_chat_history() {
|
||||
respond_to: flush_tx,
|
||||
})
|
||||
.unwrap();
|
||||
flush_rx.await.unwrap();
|
||||
flush_rx.await.unwrap().unwrap();
|
||||
let loaded = storage
|
||||
.load_session_without_updates(&session_info)
|
||||
.await
|
||||
@@ -555,11 +557,11 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
|
||||
watcher: None,
|
||||
stale_claim_secs: 60,
|
||||
search_source: "tool",
|
||||
api_key_provider: None,
|
||||
auth_credentials: None,
|
||||
embedding_credentials: kigi_memory::EndpointScopedCredentials::none(),
|
||||
};
|
||||
let (event_tx, _event_rx) = tokio::sync::mpsc::unbounded_channel::<SessionEvent>();
|
||||
let actor = Arc::new(SessionActor {
|
||||
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||
session_info: session_info.clone(),
|
||||
auth_method_id: test_auth_method_id("test-auth"),
|
||||
model_auth_facts: std::cell::RefCell::new(None),
|
||||
@@ -610,6 +612,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
|
||||
previous_model: std::cell::Cell::new(None),
|
||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||
verbatim_input: true,
|
||||
tool_choice: Default::default(),
|
||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
||||
},
|
||||
@@ -754,7 +757,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
|
||||
respond_to: flush_tx,
|
||||
})
|
||||
.unwrap();
|
||||
flush_rx.await.unwrap();
|
||||
flush_rx.await.unwrap().unwrap();
|
||||
let storage = crate::session::storage::JsonlStorageAdapter::with_explicit_session_dir(
|
||||
session_dir.path().to_path_buf(),
|
||||
);
|
||||
@@ -821,6 +824,7 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() {
|
||||
)
|
||||
.await;
|
||||
let actor = SessionActor {
|
||||
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||
session_info: SessionInfo {
|
||||
id: acp::SessionId::new("test-cancel"),
|
||||
cwd: cwd.as_str().to_string(),
|
||||
@@ -869,6 +873,7 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() {
|
||||
previous_model: std::cell::Cell::new(None),
|
||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||
verbatim_input: true,
|
||||
tool_choice: Default::default(),
|
||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
||||
},
|
||||
@@ -1305,7 +1310,7 @@ async fn actor_with_persistence_drain() -> std::sync::Arc<SessionActor> {
|
||||
tokio::task::spawn_local(async move {
|
||||
while let Some(msg) = persistence_rx.recv().await {
|
||||
if let PersistenceMsg::FlushAndAck { respond_to } = msg {
|
||||
let _ = respond_to.send(());
|
||||
let _ = respond_to.send(Ok(()));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1814,6 +1819,7 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
|
||||
)
|
||||
.await;
|
||||
let actor = SessionActor {
|
||||
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||
session_info: SessionInfo {
|
||||
id: acp::SessionId::new("test-cancel-sampler"),
|
||||
cwd: cwd.as_str().to_string(),
|
||||
@@ -1862,6 +1868,7 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
|
||||
previous_model: std::cell::Cell::new(None),
|
||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||
verbatim_input: true,
|
||||
tool_choice: Default::default(),
|
||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
||||
},
|
||||
|
||||
@@ -115,6 +115,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
|
||||
});
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
let actor = SessionActor {
|
||||
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||
session_info: SessionInfo {
|
||||
id: acp::SessionId::new("test-idle-resume"),
|
||||
cwd: cwd.as_str().to_string(),
|
||||
@@ -175,6 +176,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
|
||||
previous_model: std::cell::Cell::new(None),
|
||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||
verbatim_input: true,
|
||||
tool_choice: Default::default(),
|
||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
||||
},
|
||||
|
||||
+8
-4
@@ -64,6 +64,7 @@ async fn create_test_actor(
|
||||
);
|
||||
chat_state_handle.record_token_usage(total_tokens);
|
||||
SessionActor {
|
||||
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||
session_info: SessionInfo {
|
||||
id: acp::SessionId::new("test-auto-compact"),
|
||||
cwd: cwd.as_str().to_string(),
|
||||
@@ -111,6 +112,7 @@ async fn create_test_actor(
|
||||
previous_model: std::cell::Cell::new(None),
|
||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||
verbatim_input: true,
|
||||
tool_choice: Default::default(),
|
||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
||||
},
|
||||
@@ -393,8 +395,7 @@ fn initial_injection_backend_params_use_override_min_score() {
|
||||
watcher: None,
|
||||
stale_claim_secs: 60,
|
||||
search_source: "tool",
|
||||
api_key_provider: None,
|
||||
auth_credentials: None,
|
||||
embedding_credentials: kigi_memory::EndpointScopedCredentials::none(),
|
||||
};
|
||||
let initial_injection = crate::config::MemoryInitialInjectionConfig {
|
||||
enabled: true,
|
||||
@@ -422,8 +423,7 @@ fn initial_injection_backend_params_preserve_default_zero_min_score() {
|
||||
watcher: None,
|
||||
stale_claim_secs: 60,
|
||||
search_source: "tool",
|
||||
api_key_provider: None,
|
||||
auth_credentials: None,
|
||||
embedding_credentials: kigi_memory::EndpointScopedCredentials::none(),
|
||||
};
|
||||
let (adjusted, effective_min_score) = build_initial_injection_backend_params(
|
||||
¶ms,
|
||||
@@ -495,6 +495,7 @@ async fn create_test_actor_with_memory(
|
||||
.as_ref()
|
||||
.map_or_else(Default::default, |mc| mc.initial_injection.clone());
|
||||
SessionActor {
|
||||
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||
session_info: SessionInfo {
|
||||
id: acp::SessionId::new("test-memory"),
|
||||
cwd: cwd.as_str().to_string(),
|
||||
@@ -539,6 +540,7 @@ async fn create_test_actor_with_memory(
|
||||
previous_model: std::cell::Cell::new(None),
|
||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||
verbatim_input: true,
|
||||
tool_choice: Default::default(),
|
||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
||||
},
|
||||
@@ -1237,6 +1239,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
|
||||
});
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
let actor = SessionActor {
|
||||
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||
session_info: SessionInfo {
|
||||
id: acp::SessionId::new("test-idle-resume"),
|
||||
cwd: cwd.as_str().to_string(),
|
||||
@@ -1297,6 +1300,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
|
||||
previous_model: std::cell::Cell::new(None),
|
||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||
verbatim_input: true,
|
||||
tool_choice: Default::default(),
|
||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
||||
},
|
||||
|
||||
@@ -18,8 +18,7 @@ fn initial_injection_backend_params_use_override_min_score() {
|
||||
watcher: None,
|
||||
stale_claim_secs: 60,
|
||||
search_source: "tool",
|
||||
api_key_provider: None,
|
||||
auth_credentials: None,
|
||||
embedding_credentials: kigi_memory::EndpointScopedCredentials::none(),
|
||||
};
|
||||
let initial_injection = crate::config::MemoryInitialInjectionConfig {
|
||||
enabled: true,
|
||||
@@ -47,8 +46,7 @@ fn initial_injection_backend_params_preserve_default_zero_min_score() {
|
||||
watcher: None,
|
||||
stale_claim_secs: 60,
|
||||
search_source: "tool",
|
||||
api_key_provider: None,
|
||||
auth_credentials: None,
|
||||
embedding_credentials: kigi_memory::EndpointScopedCredentials::none(),
|
||||
};
|
||||
let (adjusted, effective_min_score) = build_initial_injection_backend_params(
|
||||
¶ms,
|
||||
@@ -121,6 +119,7 @@ async fn create_test_actor_with_memory(
|
||||
.as_ref()
|
||||
.map_or_else(Default::default, |mc| mc.initial_injection.clone());
|
||||
SessionActor {
|
||||
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||
session_info: SessionInfo {
|
||||
id: acp::SessionId::new("test-memory"),
|
||||
cwd: cwd.as_str().to_string(),
|
||||
@@ -164,6 +163,7 @@ async fn create_test_actor_with_memory(
|
||||
previous_model: std::cell::Cell::new(None),
|
||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||
verbatim_input: true,
|
||||
tool_choice: Default::default(),
|
||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
||||
},
|
||||
@@ -524,8 +524,7 @@ async fn create_injection_ready_actor(
|
||||
watcher: None,
|
||||
stale_claim_secs: 60,
|
||||
search_source: "tool",
|
||||
api_key_provider: None,
|
||||
auth_credentials: None,
|
||||
embedding_credentials: kigi_memory::EndpointScopedCredentials::none(),
|
||||
});
|
||||
actor
|
||||
.chat_state_handle
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
//! `handle_set_session_model` reports the session's effective context
|
||||
//! window so clients repaint their header without waiting for a turn.
|
||||
use super::support::*;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn set_session_model_returns_the_effective_context_window() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let (actor, _rx) = build_actor().await;
|
||||
let cfg = kigi_sampler::SamplerConfig {
|
||||
model: "narrow".to_string(),
|
||||
context_window: 128_000,
|
||||
..Default::default()
|
||||
};
|
||||
let (model_id, window) = actor
|
||||
.handle_set_session_model(cfg, None, false, false, true, 85)
|
||||
.await
|
||||
.expect("model switch");
|
||||
assert_eq!(model_id.0.as_ref(), "narrow");
|
||||
assert_eq!(window.get(), 128_000);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -82,6 +82,8 @@ async fn set_auto_mode_path_wires_live_side_query_via_session_actor() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
session.tool_context.cwd.clone(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
// cargo is heuristic-allow when sampling fails; must not be Prompt-only
|
||||
@@ -99,6 +101,8 @@ async fn set_auto_mode_path_wires_live_side_query_via_session_actor() {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
session.tool_context.cwd.clone(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
|
||||
+2
@@ -72,6 +72,7 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture
|
||||
});
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||
let actor = SessionActor {
|
||||
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||
session_info: SessionInfo {
|
||||
id: acp::SessionId::new("test-session"),
|
||||
cwd: cwd.as_str().to_string(),
|
||||
@@ -115,6 +116,7 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture
|
||||
previous_model: std::cell::Cell::new(None),
|
||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||
verbatim_input: true,
|
||||
tool_choice: Default::default(),
|
||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
||||
},
|
||||
|
||||
@@ -182,6 +182,7 @@ pub(crate) async fn create_test_actor_ex(
|
||||
chat_state_handle.record_token_usage(total_tokens);
|
||||
let (goal_update_tx, goal_update_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let actor = SessionActor {
|
||||
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||
session_info: SessionInfo {
|
||||
id: acp::SessionId::new("test-actor"),
|
||||
cwd: cwd.as_str().to_string(),
|
||||
@@ -225,6 +226,7 @@ pub(crate) async fn create_test_actor_ex(
|
||||
previous_model: std::cell::Cell::new(None),
|
||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||
verbatim_input: true,
|
||||
tool_choice: Default::default(),
|
||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
||||
},
|
||||
|
||||
Loaded 100 of 268 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user