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
|
/target
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
.DS_Store
|
.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
|
Never read or write `~/.kimi` (except the explicit one-time read-only
|
||||||
import) or any `KIMI_*` env var.
|
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
|
## Hard constraints
|
||||||
|
|
||||||
- **Zero egress**: outbound connections are limited to
|
- **Zero egress**: only `auth.kimi.com`, `api.kimi.com`, `api.moonshot.cn`,
|
||||||
`auth.kimi.com`, `api.kimi.com`, `api.moonshot.cn`, `api.moonshot.ai`,
|
`api.moonshot.ai`, GitHub Releases, user-configured MCP servers, credentialed
|
||||||
GitHub Releases domains, user-configured MCP servers, the endpoints of
|
provider endpoints, and `models.dev` (metadata refresh, ONLY when an enabled
|
||||||
provider platforms the user has credentialed, and `models.dev` (model
|
platform's `/models` lacks it — `wire_serves_metadata=false`, never
|
||||||
metadata refresh — reached ONLY when an enabled platform's `/models` wire
|
Kimi/Moonshot, `KIGI_MODELS_DEV_URL=0` disables). No telemetry, no analytics,
|
||||||
lacks metadata, `wire_serves_metadata=false`; Kimi/Moonshot never trigger
|
ever. `crates/codegen/kigi-env` is the single home of first-party endpoints.
|
||||||
it; `KIGI_MODELS_DEV_URL=0` disables). No telemetry, no analytics, ever.
|
- **One SSRF policy**: `ssrf::is_blocked_for_host` gates every model/settings
|
||||||
`crates/codegen/kigi-env` is the single home of first-party endpoints.
|
URL (`web_fetch` + hooks), per redirect hop. Never write a second copy.
|
||||||
- **Toolchain**: Rust 1.97.0 (rust-toolchain.toml), edition 2024.
|
- **Toolchain**: Rust 1.97.0 (rust-toolchain.toml), edition 2024.
|
||||||
- **Gates** (all must stay green):
|
- **Gates** (all must stay green):
|
||||||
`cargo check --workspace --all-targets`,
|
`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
|
the replan cap; `{"ops": []}` is a respected free no-op; failures
|
||||||
degrade.
|
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)
|
## Provider registry & API-key auth (post-0.1.3 expansion)
|
||||||
|
|
||||||
- The platform registry is compiled-in spec rows in `kigi-models`
|
- 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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -251,7 +251,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1626,9 +1626,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chacha20"
|
name = "chacha20"
|
||||||
version = "0.10.1"
|
version = "0.10.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.3.0",
|
"cpufeatures 0.3.0",
|
||||||
@@ -1810,7 +1810,7 @@ version = "3.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2654,7 +2654,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users 0.5.2",
|
"redox_users 0.5.2",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2980,7 +2980,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3958,7 +3958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b305d85504de270ad3525d726a6b69cc59ee7b2269b014387651107ab9f0755b"
|
checksum = "b305d85504de270ad3525d726a6b69cc59ee7b2269b014387651107ab9f0755b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bstr",
|
"bstr",
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.15.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4497,9 +4497,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.15"
|
version = "0.4.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -5442,7 +5442,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-acp-lib"
|
name = "kigi-acp-lib"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5456,7 +5456,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-agent"
|
name = "kigi-agent"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 6.0.0",
|
"dirs 6.0.0",
|
||||||
@@ -5486,7 +5486,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-agent-lifecycle"
|
name = "kigi-agent-lifecycle"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -5495,7 +5495,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-auth"
|
name = "kigi-auth"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"http 1.4.2",
|
"http 1.4.2",
|
||||||
@@ -5508,7 +5508,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-bin"
|
name = "kigi-bin"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -5543,7 +5543,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-chat-state"
|
name = "kigi-chat-state"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"kigi-compaction",
|
"kigi-compaction",
|
||||||
@@ -5560,7 +5560,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-codebase-graph"
|
name = "kigi-codebase-graph"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -5596,7 +5596,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-compaction"
|
name = "kigi-compaction"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5609,7 +5609,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-config"
|
name = "kigi-config"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"blake3",
|
"blake3",
|
||||||
@@ -5632,7 +5632,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-config-types"
|
name = "kigi-config-types"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
@@ -5646,7 +5646,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-crash-handler"
|
name = "kigi-crash-handler"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"backtrace",
|
"backtrace",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -5657,7 +5657,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-env"
|
name = "kigi-env"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
@@ -5665,7 +5665,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-fast-worktree"
|
name = "kigi-fast-worktree"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -5697,7 +5697,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-file-utils"
|
name = "kigi-file-utils"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
@@ -5721,7 +5721,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-fsnotify"
|
name = "kigi-fsnotify"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"criterion",
|
"criterion",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -5742,7 +5742,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-gix-status"
|
name = "kigi-gix-status"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gix",
|
"gix",
|
||||||
"kigi-test-utils",
|
"kigi-test-utils",
|
||||||
@@ -5752,7 +5752,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-hooks"
|
name = "kigi-hooks"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"kigi-config",
|
"kigi-config",
|
||||||
@@ -5771,7 +5771,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-hooks-plugins-types"
|
name = "kigi-hooks-plugins-types"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -5779,7 +5779,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-http"
|
name = "kigi-http"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-auth",
|
"kigi-auth",
|
||||||
"kigi-log",
|
"kigi-log",
|
||||||
@@ -5794,7 +5794,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-hunk-tracker"
|
name = "kigi-hunk-tracker"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -5815,14 +5815,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-interjection-core"
|
name = "kigi-interjection-core"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-log"
|
name = "kigi-log"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -5840,7 +5840,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-markdown"
|
name = "kigi-markdown"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"anstyle-lossy",
|
"anstyle-lossy",
|
||||||
@@ -5864,14 +5864,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-markdown-core"
|
name = "kigi-markdown-core"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-mcp"
|
name = "kigi-mcp"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5908,7 +5908,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-memory"
|
name = "kigi-memory"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
@@ -5942,7 +5942,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-mermaid"
|
name = "kigi-mermaid"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fontdb",
|
"fontdb",
|
||||||
"image",
|
"image",
|
||||||
@@ -5960,7 +5960,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-models"
|
name = "kigi-models"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-env",
|
"kigi-env",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -5970,7 +5970,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-pager-minimal"
|
name = "kigi-pager-minimal"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
@@ -5987,7 +5987,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-pager-pty-harness"
|
name = "kigi-pager-pty-harness"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alacritty_terminal",
|
"alacritty_terminal",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6012,7 +6012,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-pager-render"
|
name = "kigi-pager-render"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@@ -6064,7 +6064,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-paths"
|
name = "kigi-paths"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"camino",
|
"camino",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -6074,7 +6074,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-prompt-queue"
|
name = "kigi-prompt-queue"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -6082,7 +6082,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-proto-build"
|
name = "kigi-proto-build"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pbjson-build",
|
"pbjson-build",
|
||||||
@@ -6093,7 +6093,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-ratatui-inline"
|
name = "kigi-ratatui-inline"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ansi-width",
|
"ansi-width",
|
||||||
"anstyle-parse 0.2.7",
|
"anstyle-parse 0.2.7",
|
||||||
@@ -6110,7 +6110,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-ratatui-textarea"
|
name = "kigi-ratatui-textarea"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6131,7 +6131,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sampler"
|
name = "kigi-sampler"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-openai",
|
"async-openai",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@@ -6141,6 +6141,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"kigi-sampling-types",
|
"kigi-sampling-types",
|
||||||
|
"kigi-secrets",
|
||||||
"kigi-test-support",
|
"kigi-test-support",
|
||||||
"kigi-version",
|
"kigi-version",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
@@ -6154,7 +6155,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sampling-types"
|
name = "kigi-sampling-types"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"assert_matches",
|
"assert_matches",
|
||||||
"async-openai",
|
"async-openai",
|
||||||
@@ -6171,7 +6172,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sandbox"
|
name = "kigi-sandbox"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6186,13 +6187,14 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serial_test",
|
"serial_test",
|
||||||
|
"tempfile",
|
||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-secrets"
|
name = "kigi-secrets"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"regex",
|
"regex",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -6230,7 +6232,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-shell"
|
name = "kigi-shell"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6293,6 +6295,7 @@ dependencies = [
|
|||||||
"kigi-sampler",
|
"kigi-sampler",
|
||||||
"kigi-sampling-types",
|
"kigi-sampling-types",
|
||||||
"kigi-sandbox",
|
"kigi-sandbox",
|
||||||
|
"kigi-secrets",
|
||||||
"kigi-shared",
|
"kigi-shared",
|
||||||
"kigi-shell-base",
|
"kigi-shell-base",
|
||||||
"kigi-sqlite-journal",
|
"kigi-sqlite-journal",
|
||||||
@@ -6367,7 +6370,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-shell-base"
|
name = "kigi-shell-base"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6392,7 +6395,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sqlite-journal"
|
name = "kigi-sqlite-journal"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -6403,7 +6406,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-subagent-resolution"
|
name = "kigi-subagent-resolution"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-sampling-types",
|
"kigi-sampling-types",
|
||||||
"kigi-tool-types",
|
"kigi-tool-types",
|
||||||
@@ -6418,7 +6421,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-system-power"
|
name = "kigi-system-power"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
"zbus",
|
"zbus",
|
||||||
@@ -6426,7 +6429,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-test-support"
|
name = "kigi-test-support"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6448,7 +6451,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-test-utils"
|
name = "kigi-test-utils"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"runfiles",
|
"runfiles",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -6457,11 +6460,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-token-estimation"
|
name = "kigi-token-estimation"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-protocol"
|
name = "kigi-tool-protocol"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-tool-types",
|
"kigi-tool-types",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -6472,7 +6475,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-runtime"
|
name = "kigi-tool-runtime"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -6490,7 +6493,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-types"
|
name = "kigi-tool-types"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"minijinja",
|
"minijinja",
|
||||||
"schemars 1.2.1",
|
"schemars 1.2.1",
|
||||||
@@ -6500,7 +6503,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tools"
|
name = "kigi-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
@@ -6577,7 +6580,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tools-api"
|
name = "kigi-tools-api"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-proto-build",
|
"kigi-proto-build",
|
||||||
"kigi-tool-protocol",
|
"kigi-tool-protocol",
|
||||||
@@ -6590,11 +6593,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tracing-macros"
|
name = "kigi-tracing-macros"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tty-utils"
|
name = "kigi-tty-utils"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"nix 0.30.1",
|
"nix 0.30.1",
|
||||||
@@ -6604,7 +6607,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tui"
|
name = "kigi-tui"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"ansi-to-tui",
|
"ansi-to-tui",
|
||||||
@@ -6691,7 +6694,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-update"
|
name = "kigi-update"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -6720,14 +6723,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-version"
|
name = "kigi-version"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-workspace"
|
name = "kigi-workspace"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6806,7 +6809,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-workspace-types"
|
name = "kigi-workspace-types"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -7305,7 +7308,7 @@ version = "0.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
|
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7576,7 +7579,7 @@ version = "0.50.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7997,7 +8000,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8840,7 +8843,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ptyctl"
|
name = "ptyctl"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alacritty_terminal",
|
"alacritty_terminal",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -8858,7 +8861,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ptyctl-cli"
|
name = "ptyctl-cli"
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -8992,7 +8995,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9617,7 +9620,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9687,7 +9690,7 @@ dependencies = [
|
|||||||
"security-framework 3.7.0",
|
"security-framework 3.7.0",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
"webpki-root-certs",
|
"webpki-root-certs",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10536,7 +10539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10937,7 +10940,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -11882,7 +11885,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"memoffset",
|
"memoffset",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -12414,15 +12417,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webbrowser"
|
name = "webbrowser"
|
||||||
version = "1.2.1"
|
version = "1.2.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72"
|
checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"core-foundation 0.10.1",
|
|
||||||
"jni",
|
"jni",
|
||||||
"log",
|
"log",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
|
"objc2-app-kit",
|
||||||
"objc2-foundation 0.3.2",
|
"objc2-foundation 0.3.2",
|
||||||
"url",
|
"url",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
@@ -12561,7 +12564,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+1
-1
@@ -76,7 +76,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.9"
|
version = "0.1.15"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ key? OpenAI, Anthropic, Google, DeepSeek, Groq, Moonshot and
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# macOS / Linux
|
# 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
|
```powershell
|
||||||
# Windows PowerShell
|
# Windows PowerShell
|
||||||
irm https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.ps1 | iex
|
irm https://kigicli.dev/install.ps1 | iex
|
||||||
```
|
```
|
||||||
|
|
||||||
```sh
|
```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
|
official `kimi` CLI installed. `kigi import-kimi` copies your old config over
|
||||||
once, read-only.
|
once, read-only.
|
||||||
|
|
||||||
**Zero telemetry.** It talks to the APIs you configured, GitHub Releases, and
|
**Zero telemetry.** It talks to the APIs you configured, the Kigi release
|
||||||
your own MCP servers. Nothing else.
|
server, and your own MCP servers. Nothing else.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -1,8 +1,10 @@
|
|||||||
# Security Policy
|
# Security Policy
|
||||||
|
|
||||||
Kigi is a community build; report vulnerabilities via
|
Kigi is a community build; report vulnerabilities on the
|
||||||
[GitHub private vulnerability reporting](https://github.com/ZacharyZhang-NY/Kigi-CLI/security/advisories/new)
|
[Kigi issue tracker](https://github.com/ZacharyZhang-NY/Kigi-CLI/issues).
|
||||||
on this repository. Please do not open public issues for security reports.
|
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`;
|
Dependency advisories are gated in CI with `cargo deny check advisories`;
|
||||||
documented exceptions live in `deny.toml`.
|
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,
|
kigi_tools::types::tool::ToolNamespace::Kigi,
|
||||||
"task"
|
"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;
|
let mut task_stripped = false;
|
||||||
if !self.subagents_enabled {
|
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;
|
task_stripped = true;
|
||||||
} else {
|
} else {
|
||||||
let subagents = crate::discovery::all_subagents_with_plugins(
|
let subagents = crate::discovery::all_subagents_with_plugins(
|
||||||
@@ -722,7 +732,9 @@ impl AgentBuilder {
|
|||||||
self.plugin_registry.as_deref(),
|
self.plugin_registry.as_deref(),
|
||||||
);
|
);
|
||||||
if subagents.is_empty() {
|
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;
|
task_stripped = true;
|
||||||
} else if self.prompt_audience == crate::prompt::context::PromptAudience::Subagent {
|
} else if self.prompt_audience == crate::prompt::context::PromptAudience::Subagent {
|
||||||
if let Some(task_tc) = tool_config
|
if let Some(task_tc) = tool_config
|
||||||
@@ -810,7 +822,13 @@ impl AgentBuilder {
|
|||||||
.tools
|
.tools
|
||||||
.iter()
|
.iter()
|
||||||
.any(|t| AGENT_TASK_CLASSIFIER_RE.is_match(t));
|
.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 registered_tool_ids = tool_bridge_builder.known_tool_ids();
|
||||||
let present_kinds: std::collections::HashSet<ToolKind> =
|
let present_kinds: std::collections::HashSet<ToolKind> =
|
||||||
tool_config.tools.iter().filter_map(|tc| tc.kind).collect();
|
tool_config.tools.iter().filter_map(|tc| tc.kind).collect();
|
||||||
@@ -925,7 +943,13 @@ impl AgentBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if definition.allowed_subagent_types.as_deref() == Some(&[]) {
|
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
|
tool_config
|
||||||
.tools
|
.tools
|
||||||
.retain(|tc| !task_deps.contains(&short_tool_name(&tc.id)));
|
.retain(|tc| !task_deps.contains(&short_tool_name(&tc.id)));
|
||||||
@@ -1601,6 +1625,11 @@ mod tests {
|
|||||||
has_task, *subagents,
|
has_task, *subagents,
|
||||||
"[{label}] spawn_subagent presence should match subagents_enabled={subagents}; got tools: {names:?}"
|
"[{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!(
|
assert!(
|
||||||
names.contains(&"enter_plan_mode"),
|
names.contains(&"enter_plan_mode"),
|
||||||
"[{label}] enter_plan_mode must always be present (TUI plan-mode keybind needs it); got tools: {names:?}"
|
"[{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_name("spawn_subagent")
|
||||||
.with_param_rename("run_in_background", "background")
|
.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:
|
/// Task output tool renamed for clarity:
|
||||||
/// `get_task_output` → `get_command_or_subagent_output`.
|
/// `get_task_output` → `get_command_or_subagent_output`.
|
||||||
fn task_output_tool_config() -> ToolConfig {
|
fn task_output_tool_config() -> ToolConfig {
|
||||||
@@ -270,6 +275,7 @@ fn default_kigi_toolset() -> ToolServerConfig {
|
|||||||
task_output_tool_config(),
|
task_output_tool_config(),
|
||||||
wait_tasks_tool_config(),
|
wait_tasks_tool_config(),
|
||||||
task_tool_config(),
|
task_tool_config(),
|
||||||
|
agent_swarm_tool_config(),
|
||||||
(&kigi::SchedulerCreateTool).into(),
|
(&kigi::SchedulerCreateTool).into(),
|
||||||
(&kigi::SchedulerDeleteTool).into(),
|
(&kigi::SchedulerDeleteTool).into(),
|
||||||
(&kigi::SchedulerListTool).into(),
|
(&kigi::SchedulerListTool).into(),
|
||||||
@@ -318,6 +324,7 @@ pub fn kigi_hashline_toolset(
|
|||||||
task_output_tool_config(),
|
task_output_tool_config(),
|
||||||
wait_tasks_tool_config(),
|
wait_tasks_tool_config(),
|
||||||
task_tool_config(),
|
task_tool_config(),
|
||||||
|
agent_swarm_tool_config(),
|
||||||
(&kigi::WebSearchTool).into(),
|
(&kigi::WebSearchTool).into(),
|
||||||
(&kigi::SchedulerCreateTool).into(),
|
(&kigi::SchedulerCreateTool).into(),
|
||||||
(&kigi::SchedulerDeleteTool).into(),
|
(&kigi::SchedulerDeleteTool).into(),
|
||||||
@@ -400,6 +407,7 @@ fn kigi_plan_toolset() -> ToolServerConfig {
|
|||||||
(&kigi::TodoWriteTool).into(),
|
(&kigi::TodoWriteTool).into(),
|
||||||
task_output_tool_config(),
|
task_output_tool_config(),
|
||||||
task_tool_config(),
|
task_tool_config(),
|
||||||
|
agent_swarm_tool_config(),
|
||||||
(&kigi::SchedulerCreateTool).into(),
|
(&kigi::SchedulerCreateTool).into(),
|
||||||
(&kigi::SchedulerDeleteTool).into(),
|
(&kigi::SchedulerDeleteTool).into(),
|
||||||
(&kigi::SchedulerListTool).into(),
|
(&kigi::SchedulerListTool).into(),
|
||||||
@@ -428,6 +436,7 @@ fn orchestrator_toolset() -> ToolServerConfig {
|
|||||||
(&kigi::ListDirTool).into(),
|
(&kigi::ListDirTool).into(),
|
||||||
(&kigi::GrepTool).into(),
|
(&kigi::GrepTool).into(),
|
||||||
task_tool_config(),
|
task_tool_config(),
|
||||||
|
agent_swarm_tool_config(),
|
||||||
task_output_tool_config(),
|
task_output_tool_config(),
|
||||||
wait_tasks_tool_config(),
|
wait_tasks_tool_config(),
|
||||||
kill_task_tool_config(),
|
kill_task_tool_config(),
|
||||||
@@ -497,6 +506,7 @@ fn kigi_ask_user_toolset() -> ToolServerConfig {
|
|||||||
task_output_tool_config(),
|
task_output_tool_config(),
|
||||||
wait_tasks_tool_config(),
|
wait_tasks_tool_config(),
|
||||||
task_tool_config(),
|
task_tool_config(),
|
||||||
|
agent_swarm_tool_config(),
|
||||||
(&kigi::SchedulerCreateTool).into(),
|
(&kigi::SchedulerCreateTool).into(),
|
||||||
(&kigi::SchedulerDeleteTool).into(),
|
(&kigi::SchedulerDeleteTool).into(),
|
||||||
(&kigi::SchedulerListTool).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())
|
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(
|
fn by_name_with_home(
|
||||||
name: &str,
|
name: &str,
|
||||||
home: Option<&Path>,
|
home: Option<&Path>,
|
||||||
kigi_home: Option<&Path>,
|
kigi_home: Option<&Path>,
|
||||||
) -> Option<AgentDefinition> {
|
) -> 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.
|
// Check built-ins first — type-safe via BuiltinAgentName strum enum.
|
||||||
// Legacy pre-rebrand agent types (persisted in old session files) are
|
// Legacy pre-rebrand agent types (persisted in old session files) are
|
||||||
// mapped onto their current names first; see `canonical_agent_type`.
|
// mapped onto their current names first; see `canonical_agent_type`.
|
||||||
@@ -298,6 +307,10 @@ fn by_name_in_cwd_with_home(
|
|||||||
home: Option<&Path>,
|
home: Option<&Path>,
|
||||||
kigi_home: Option<&Path>,
|
kigi_home: Option<&Path>,
|
||||||
) -> Option<AgentDefinition> {
|
) -> 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) {
|
if let Some(def) = load_project_definition_by_name(name, cwd) {
|
||||||
return Some(def);
|
return Some(def);
|
||||||
}
|
}
|
||||||
@@ -827,6 +840,30 @@ mod tests {
|
|||||||
assert!(def.is_none());
|
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]
|
#[test]
|
||||||
fn test_discover_finds_md_files() {
|
fn test_discover_finds_md_files() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ pub enum AgentBuildError {
|
|||||||
#[error("IO error during agent construction: {0}")]
|
#[error("IO error during agent construction: {0}")]
|
||||||
IoError(#[from] std::io::Error),
|
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.
|
/// Carries template line numbers and surrounding context.
|
||||||
#[error("template rendering error: {0}")]
|
#[error("template rendering error: {0}")]
|
||||||
MiniJinjaError(#[from] minijinja::Error),
|
MiniJinjaError(#[from] minijinja::Error),
|
||||||
|
|||||||
@@ -32,5 +32,5 @@ fn get_global_gitignore_path() -> Option<PathBuf> {
|
|||||||
git2::Config::open_default()
|
git2::Config::open_default()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|cfg| cfg.get_path("core.excludesFile").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");
|
.expect("setup report has no non-serializable values");
|
||||||
println!("{out}");
|
println!("{out}");
|
||||||
if !report.configured {
|
if !report.configured {
|
||||||
eprintln!(
|
kigi_tui::best_effort_stderr::eprint_line(
|
||||||
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one."
|
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match managed_config::run_setup().await {
|
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 => {
|
SetupOutcome::NothingConfigured => {
|
||||||
eprintln!(
|
kigi_tui::best_effort_stderr::eprint_line(
|
||||||
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one."
|
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
SetupOutcome::Failed(e) => {
|
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);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,8 +214,8 @@ async fn run_leader_mgmt(args: LeaderMgmtArgs) -> Result<()> {
|
|||||||
println!("{info:#?}");
|
println!("{info:#?}");
|
||||||
} else {
|
} else {
|
||||||
print_leader_descriptor(&descriptor);
|
print_leader_descriptor(&descriptor);
|
||||||
eprintln!(
|
kigi_tui::best_effort_stderr::eprint_line(
|
||||||
" (detailed info unavailable — leader does not advertise control capabilities)"
|
" (detailed info unavailable — leader does not advertise control capabilities)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
client.cancel();
|
client.cancel();
|
||||||
@@ -220,7 +226,7 @@ async fn run_leader_mgmt(args: LeaderMgmtArgs) -> Result<()> {
|
|||||||
async fn kill_leaders() -> Result<()> {
|
async fn kill_leaders() -> Result<()> {
|
||||||
let leaders = kigi_shell::leader::discover_leaders().await;
|
let leaders = kigi_shell::leader::discover_leaders().await;
|
||||||
if leaders.is_empty() {
|
if leaders.is_empty() {
|
||||||
eprintln!("No leader candidates found.");
|
kigi_tui::best_effort_stderr::eprint_line("No leader candidates found.");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let mut killed = 0u32;
|
let mut killed = 0u32;
|
||||||
@@ -231,7 +237,9 @@ async fn kill_leaders() -> Result<()> {
|
|||||||
};
|
};
|
||||||
if !kigi_shell::util::is_kigi_process(pid) {
|
if !kigi_shell::util::is_kigi_process(pid) {
|
||||||
if let Some(ref lock) = d.lock_path {
|
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);
|
let _ = std::fs::remove_file(lock);
|
||||||
cleaned += 1;
|
cleaned += 1;
|
||||||
}
|
}
|
||||||
@@ -240,19 +248,23 @@ async fn kill_leaders() -> Result<()> {
|
|||||||
}
|
}
|
||||||
continue;
|
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) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
killed += 1;
|
killed += 1;
|
||||||
}
|
}
|
||||||
if killed > 0 {
|
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 {
|
} 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 {
|
} else {
|
||||||
eprintln!("No live leader processes found.");
|
kigi_tui::best_effort_stderr::eprint_line("No live leader processes found.");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -295,7 +307,7 @@ fn print_leader_descriptor(d: &LeaderDescriptor) {
|
|||||||
.map(|p| p.display().to_string())
|
.map(|p| p.display().to_string())
|
||||||
.unwrap_or_else(|| "?".into());
|
.unwrap_or_else(|| "?".into());
|
||||||
let state = format!("{:?}", d.classification);
|
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 {
|
fn leader_descriptor_json(d: &LeaderDescriptor) -> serde_json::Value {
|
||||||
serde_json::json!(
|
serde_json::json!(
|
||||||
@@ -1221,15 +1233,34 @@ fn main() {
|
|||||||
"Found crashed sessions from a previous run"
|
"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()
|
.enable_all()
|
||||||
.build()
|
.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);
|
let result = run_and_shutdown(runtime, async_main(), RUNTIME_SHUTDOWN_GRACE);
|
||||||
kigi_log::debug_log::flush();
|
kigi_log::debug_log::flush();
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
kigi_tty_utils::restore_native_stderr();
|
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);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1580,16 +1611,25 @@ async fn async_main() -> Result<()> {
|
|||||||
let result = kigi_tui::app::run(args, bg_update_rx).await;
|
let result = kigi_tui::app::run(args, bg_update_rx).await;
|
||||||
kigi_sandbox::flush();
|
kigi_sandbox::flush();
|
||||||
match result {
|
match result {
|
||||||
Ok(true) => {
|
Ok(exit) if exit.quit_for_update => {
|
||||||
let adopted = bg_update_wait.lock().await.take();
|
let adopted = bg_update_wait.lock().await.take();
|
||||||
if finish_update_on_exit(adopted, &update_config).await {
|
let installed =
|
||||||
eprintln!("Update installed. Run `kigi` to start.");
|
finish_update_on_exit(adopted, &update_config, exit.terminal_reading).await;
|
||||||
} else {
|
// A terminal that stopped reading at teardown would block these prints.
|
||||||
eprintln!("Update did not complete. Run `kigi update` to retry.");
|
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(())
|
||||||
}
|
}
|
||||||
Ok(false) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
Err(e) => Err(e),
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1605,10 +1645,15 @@ async fn async_main() -> Result<()> {
|
|||||||
async fn finish_update_on_exit(
|
async fn finish_update_on_exit(
|
||||||
adopted: Option<tokio::task::JoinHandle<std::io::Result<std::process::ExitStatus>>>,
|
adopted: Option<tokio::task::JoinHandle<std::io::Result<std::process::ExitStatus>>>,
|
||||||
update_config: &UpdateConfig,
|
update_config: &UpdateConfig,
|
||||||
|
terminal_reading: bool,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let run_blocking = |reason: Option<String>| async move {
|
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 {
|
if let Some(reason) = reason {
|
||||||
eprintln!("{reason}");
|
kigi_tui::best_effort_stderr::eprint_line(&reason);
|
||||||
}
|
}
|
||||||
auto_update::run_update_if_available(
|
auto_update::run_update_if_available(
|
||||||
auto_update::UpdateRunMode::Blocking,
|
auto_update::UpdateRunMode::Blocking,
|
||||||
@@ -1620,7 +1665,11 @@ async fn finish_update_on_exit(
|
|||||||
};
|
};
|
||||||
match adopted {
|
match adopted {
|
||||||
Some(handle) => {
|
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 {
|
match handle.await {
|
||||||
Ok(Ok(status)) if status.success() => true,
|
Ok(Ok(status)) if status.success() => true,
|
||||||
Ok(Ok(status)) => {
|
Ok(Ok(status)) => {
|
||||||
@@ -1793,7 +1842,9 @@ async fn signal_leaders_to_relaunch(installed_version: &str) {
|
|||||||
to_version,
|
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 })) => {
|
Ok(Ok(kigi_shell::leader::ControlPayload::RelaunchDeclined { reason })) => {
|
||||||
tracing::debug!(% reason, "Leader declined relaunch");
|
tracing::debug!(% reason, "Leader declined relaunch");
|
||||||
|
|||||||
@@ -390,14 +390,24 @@ impl ChatStateActor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the entire conversation, persist, re-estimate `total_tokens`,
|
/// Reseed after a rewrite: the raw estimate scaled by the provider-confirmed ratio, capped at the pre-rewrite total (see COMPACTION.md).
|
||||||
/// and emit reset + token-update events.
|
pub(super) fn reseed_total_tokens(&self, base_estimate: u64) -> u64 {
|
||||||
///
|
let pre_replace_total = self.state.total_tokens;
|
||||||
/// Compaction replaces carry the provider-side overhead forward as a
|
let mut estimated_tokens =
|
||||||
/// *ratio* (`base_estimate × provider_total ÷ estimate_at_last_response`,
|
if pre_replace_total > 0 && self.state.estimate_at_last_response > 0 {
|
||||||
/// capped at the pre-compaction total; `base_estimate` when that estimate is
|
let ratio = pre_replace_total as f64 / self.state.estimate_at_last_response as f64;
|
||||||
/// 0) so the reseed neither springs back nor over-counts (see
|
(base_estimate as f64 * ratio).round() as u64
|
||||||
/// `COMPACTION.md`).
|
} 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(
|
pub(super) fn replace_conversation(
|
||||||
&mut self,
|
&mut self,
|
||||||
items: Vec<ConversationItem>,
|
items: Vec<ConversationItem>,
|
||||||
@@ -407,28 +417,16 @@ impl ChatStateActor {
|
|||||||
if is_compaction && let Some(cap) = &mut self.state.turn_capture {
|
if is_compaction && let Some(cap) = &mut self.state.turn_capture {
|
||||||
cap.compaction_occurred = true;
|
cap.compaction_occurred = true;
|
||||||
}
|
}
|
||||||
let pre_replace_total = self.state.total_tokens;
|
|
||||||
// `harness_trace_buffer` / `harness_trace_turns` intentionally untouched:
|
// `harness_trace_buffer` / `harness_trace_turns` intentionally untouched:
|
||||||
// the planner/verifier subagents ran, so their sealed trace turns survive
|
// the planner/verifier subagents ran, so their sealed trace turns survive
|
||||||
// a conversation replace (same intent as the `TruncateToPromptIndex` arm).
|
// a conversation replace (same intent as the `TruncateToPromptIndex` arm).
|
||||||
self.persistence.replace_history(&items);
|
self.persistence.replace_history(&items);
|
||||||
let base_estimate = super::state::estimate_conversation_tokens(&items);
|
let base_estimate = super::state::estimate_conversation_tokens(&items);
|
||||||
let mut estimated_tokens =
|
let estimated_tokens = self.reseed_total_tokens(base_estimate);
|
||||||
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);
|
|
||||||
}
|
|
||||||
self.state.conversation = items;
|
self.state.conversation = items;
|
||||||
self.state.estimated_tokens_since_model = 0;
|
self.state.estimated_tokens_since_model = 0;
|
||||||
self.state.total_tokens = estimated_tokens;
|
self.state.total_tokens = estimated_tokens;
|
||||||
self.state.estimate_at_last_response =
|
self.state.estimate_at_last_response = base_estimate;
|
||||||
super::state::estimate_conversation_tokens(&self.state.conversation);
|
|
||||||
self.rebase_turn_capture_offset();
|
self.rebase_turn_capture_offset();
|
||||||
self.send_event(ChatStateEvent::ConversationReset {
|
self.send_event(ChatStateEvent::ConversationReset {
|
||||||
new_len: self.state.conversation.len(),
|
new_len: self.state.conversation.len(),
|
||||||
|
|||||||
@@ -68,10 +68,10 @@ impl ChatStateActor {
|
|||||||
self.state.conversation.truncate(truncate_at);
|
self.state.conversation.truncate(truncate_at);
|
||||||
self.state.prompt_texts.truncate(target_prompt_index);
|
self.state.prompt_texts.truncate(target_prompt_index);
|
||||||
self.state.prompt_index = target_prompt_index;
|
self.state.prompt_index = target_prompt_index;
|
||||||
self.state.total_tokens =
|
let base_estimate = super::state::estimate_conversation_tokens(&self.state.conversation);
|
||||||
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.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);
|
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())
|
kigi_token_estimation::estimate_tokens(&b.text_summary())
|
||||||
}
|
}
|
||||||
ConversationItem::Reasoning(r) => {
|
ConversationItem::Reasoning(r) => {
|
||||||
// Summary + content text follow the standard bytes-per-token
|
// Text and ciphertext are the same reasoning twice: the larger wins (base64 is 4/3 over raw).
|
||||||
// estimate; encrypted blobs are base64 and don't survive
|
|
||||||
// tokenization 1:1, so estimate at len/4 as well.
|
|
||||||
let text_bytes = kigi_sampling_types::reasoning_item_text(r).len();
|
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);
|
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]
|
#[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();
|
let h = TestHarness::new();
|
||||||
h.handle
|
h.handle
|
||||||
.push_user_message(ConversationItem::user("x".repeat(4000)));
|
.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
|
h.handle
|
||||||
.replace_conversation(vec![ConversationItem::user("q".repeat(4000))]);
|
.replace_conversation(vec![ConversationItem::user("q".repeat(4000))]);
|
||||||
|
|
||||||
let total = h.handle.get_total_tokens().await;
|
let total = h.handle.get_total_tokens().await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
total, 1_000,
|
total, 500,
|
||||||
"non-compaction replace (e.g. rewind) keeps the plain estimate"
|
"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]
|
#[tokio::test]
|
||||||
async fn flush_calls_persistence_flush() {
|
async fn flush_calls_persistence_flush() {
|
||||||
let mut h = TestHarness::new();
|
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).
|
/// remote settings verbatim-input flag; env (`KIGI_COMPACTION_VERBATIM_INPUT`) and config override it. `None` = default (true).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub compaction_verbatim_input: Option<bool>,
|
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.
|
/// Master switch for jemalloc heap sampling + threshold dumps.
|
||||||
/// `Some(true)` enables, `Some(false)` kill-switch, `None` = client default off.
|
/// `Some(true)` enables, `Some(false)` kill-switch, `None` = client default off.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
@@ -78,11 +78,10 @@ pub fn detect_windows_shell() -> &'static WindowsShell {
|
|||||||
kigi_tty_utils::detach_std_command(&mut cmd);
|
kigi_tty_utils::detach_std_command(&mut cmd);
|
||||||
cmd.arg("pwsh.exe").stdin(std::process::Stdio::null());
|
cmd.arg("pwsh.exe").stdin(std::process::Stdio::null());
|
||||||
cmd.output()
|
cmd.output()
|
||||||
} {
|
} && output.status.success()
|
||||||
if output.status.success() {
|
{
|
||||||
tracing::info!("Windows shell: pwsh");
|
tracing::info!("Windows shell: pwsh");
|
||||||
return WindowsShell::Pwsh;
|
return WindowsShell::Pwsh;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// powershell.exe (Windows PowerShell 5.1).
|
// powershell.exe (Windows PowerShell 5.1).
|
||||||
@@ -131,14 +130,13 @@ fn find_git_bash() -> Option<String> {
|
|||||||
kigi_tty_utils::detach_std_command(&mut cmd);
|
kigi_tty_utils::detach_std_command(&mut cmd);
|
||||||
cmd.arg("bash.exe").stdin(std::process::Stdio::null());
|
cmd.arg("bash.exe").stdin(std::process::Stdio::null());
|
||||||
cmd.output()
|
cmd.output()
|
||||||
} {
|
} && output.status.success()
|
||||||
if output.status.success() {
|
{
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
for line in stdout.lines() {
|
for line in stdout.lines() {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
if line.to_ascii_lowercase().contains("git") {
|
if line.to_ascii_lowercase().contains("git") {
|
||||||
return Some(line.to_string());
|
return Some(line.to_string());
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -616,7 +616,7 @@ mod win {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let signal = exception_to_signal(exception_code);
|
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.
|
// ExceptionInformation[1] holds the faulting address for ACCESS_VIOLATION.
|
||||||
let si_addr = if exception_code == EXCEPTION_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,
|
pub coding_api_base_url: &'static str,
|
||||||
/// OAuth device-flow host for Kimi Code subscription login.
|
/// OAuth device-flow host for Kimi Code subscription login.
|
||||||
pub oauth_host: &'static str,
|
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,
|
pub update_base_url: &'static str,
|
||||||
/// Human-facing page for subscription upgrade guidance.
|
/// Human-facing page for subscription upgrade guidance.
|
||||||
pub upgrade_page_url: &'static str,
|
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).
|
/// Env var overriding [`oauth_host`] (PRD F1).
|
||||||
pub const OAUTH_HOST_ENV: &str = "KIGI_OAUTH_HOST";
|
pub const OAUTH_HOST_ENV: &str = "KIGI_OAUTH_HOST";
|
||||||
/// Env var overriding [`update_base_url`] (PRD F8). Points the self-updater
|
/// 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";
|
pub const UPDATE_BASE_URL_ENV: &str = "KIGI_UPDATE_BASE_URL";
|
||||||
|
|
||||||
fn resolve(var: &str, compiled: &'static str) -> String {
|
fn resolve(var: &str, compiled: &'static str) -> String {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ mod tests {
|
|||||||
assert_eq!(std::fs::read(&dest).unwrap(), data);
|
assert_eq!(std::fs::read(&dest).unwrap(), data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn test_clone_preserves_permissions() {
|
fn test_clone_preserves_permissions() {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
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") {
|
if let Ok(v) = std::env::var("KIGI_SHARE_DIR") {
|
||||||
return Ok(PathBuf::from(v));
|
return Ok(PathBuf::from(v));
|
||||||
}
|
}
|
||||||
let home =
|
// Standard home-dir lookup (`USERPROFILE`/Known Folders on Windows, where
|
||||||
PathBuf::from(std::env::var("HOME").context("neither $KIGI_SHARE_DIR nor $HOME is set")?);
|
// `HOME` is usually unset outside Git Bash) — the same resolution as
|
||||||
// Canonicalize the home dir so worktree paths share the same physical .kigi
|
// kigi_config::default_kigi_home(), so worktree paths land in the same
|
||||||
// tree as trust/hooks even when it is symlinked. The dunce canonicalization
|
// physical .kigi tree as trust/hooks.
|
||||||
// must stay in sync with kigi_config::default_kigi_home();
|
let home = std::env::home_dir()
|
||||||
// home resolution deliberately differs ($HOME here vs 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"))
|
Ok(dunce::canonicalize(&home).unwrap_or(home).join(".kigi"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ pub(crate) fn execute_plan(plan: WorktreePlan) -> Result<CreateWorktreeResult> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[cfg(unix)]
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{IgnoredFilesMode, WorkingTreeMode, WorktreeBuilder};
|
use crate::{IgnoredFilesMode, WorkingTreeMode, WorktreeBuilder};
|
||||||
use kigi_test_utils::git::{git_commit_all, init_git_repo};
|
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")]
|
#[cfg(target_os = "windows")]
|
||||||
fn is_platform_system_dir(cwd: &Path) -> bool {
|
fn is_platform_system_dir(cwd: &Path) -> bool {
|
||||||
if let Ok(temp) = std::env::var("TEMP").or_else(|_| std::env::var("TMP")) {
|
if let Ok(temp) = std::env::var("TEMP").or_else(|_| std::env::var("TMP"))
|
||||||
if cwd.starts_with(&temp) {
|
&& cwd.starts_with(&temp)
|
||||||
return true;
|
{
|
||||||
}
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let path_lower = cwd.to_string_lossy().to_lowercase();
|
let path_lower = cwd.to_string_lossy().to_lowercase();
|
||||||
@@ -103,7 +103,7 @@ fn is_platform_system_dir(cwd: &Path) -> bool {
|
|||||||
return true;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -971,7 +971,7 @@ pub(crate) fn start_with_timeout(
|
|||||||
|
|
||||||
let progress_for_thread = progress.clone();
|
let progress_for_thread = progress.clone();
|
||||||
|
|
||||||
let thread = std::thread::spawn(move || {
|
let watcher_loop = move || {
|
||||||
let update_stage = |stage: &'static str| {
|
let update_stage = |stage: &'static str| {
|
||||||
if let Ok(mut p) = progress_for_thread.lock() {
|
if let Ok(mut p) = progress_for_thread.lock() {
|
||||||
p.set_stage(stage);
|
p.set_stage(stage);
|
||||||
@@ -1351,7 +1351,13 @@ pub(crate) fn start_with_timeout(
|
|||||||
let _ = ready_tx.send(Err(Box::new(e)));
|
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)
|
// Wait for watcher to be ready (with timeout)
|
||||||
if let Ok(mut p) = progress.lock() {
|
if let Ok(mut p) = progress.lock() {
|
||||||
|
|||||||
@@ -26,68 +26,14 @@ struct HttpHookOutput {
|
|||||||
reason: Option<String>,
|
reason: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CWE-918: Returns `true` if an IP address is in a private, link-local,
|
/// CWE-918: whether this address is blocked for a hook.
|
||||||
/// or cloud metadata range that should be blocked to prevent SSRF attacks.
|
|
||||||
///
|
///
|
||||||
/// Loopback (`127.x` / `::1`) is allowed for local development servers.
|
/// Delegates to the `web_fetch` policy, so one implementation
|
||||||
fn is_blocked_ip(ip: &IpAddr) -> bool {
|
/// governs every outbound URL. `allow_local` is on: a loopback hook
|
||||||
match ip {
|
/// receiver is legitimate, but only when named literally, so
|
||||||
IpAddr::V4(v4) => {
|
/// rebinding through a public name stays blocked.
|
||||||
let octets = v4.octets();
|
fn is_blocked_ip(ip: &IpAddr, host: &str) -> bool {
|
||||||
if octets[0] == 127 {
|
kigi_tools::implementations::kigi::web_fetch::ssrf::is_blocked_for_host(ip, host, true)
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CWE-918: Validate a hook URL to prevent SSRF.
|
/// 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 host is a literal IP, check it directly.
|
||||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
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 Err(format!("URL resolves to blocked private/internal IP: {ip}"));
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -131,7 +77,7 @@ async fn validate_hook_url(url: &str) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for addr in &addrs {
|
for addr in &addrs {
|
||||||
if is_blocked_ip(&addr.ip()) {
|
if is_blocked_ip(&addr.ip(), host) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"URL host {host} resolves to blocked private/internal IP: {}",
|
"URL host {host} resolves to blocked private/internal IP: {}",
|
||||||
addr.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]
|
#[test]
|
||||||
fn ssrf_blocks_rfc1918_10x() {
|
fn ssrf_delegates_to_the_shared_policy() {
|
||||||
assert!(is_blocked_ip(&"10.0.0.1".parse().unwrap()));
|
for ip in [
|
||||||
assert!(is_blocked_ip(&"10.255.255.255".parse().unwrap()));
|
"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]
|
#[test]
|
||||||
fn ssrf_blocks_rfc1918_172x() {
|
fn ssrf_allows_a_literal_loopback_hook_target() {
|
||||||
assert!(is_blocked_ip(&"172.16.0.1".parse().unwrap()));
|
assert!(!is_blocked_ip(&"127.0.0.1".parse().unwrap(), "127.0.0.1"));
|
||||||
assert!(is_blocked_ip(&"172.31.255.255".parse().unwrap()));
|
assert!(!is_blocked_ip(&"::1".parse().unwrap(), "localhost"));
|
||||||
assert!(!is_blocked_ip(&"172.15.0.1".parse().unwrap()));
|
|
||||||
assert!(!is_blocked_ip(&"172.32.0.1".parse().unwrap()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A public name resolving to loopback is rebinding.
|
||||||
#[test]
|
#[test]
|
||||||
fn ssrf_blocks_rfc1918_192168() {
|
fn ssrf_blocks_a_public_name_that_resolves_to_loopback() {
|
||||||
assert!(is_blocked_ip(&"192.168.0.1".parse().unwrap()));
|
let ip: IpAddr = "127.0.0.1".parse().unwrap();
|
||||||
assert!(is_blocked_ip(&"192.168.255.255".parse().unwrap()));
|
assert!(is_blocked_ip(&ip, "evil.example.com"));
|
||||||
}
|
|
||||||
|
|
||||||
#[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()
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSRF protection: validate_hook_url tests
|
// SSRF protection: validate_hook_url tests
|
||||||
@@ -839,14 +762,15 @@ mod tests {
|
|||||||
/// the secret does NOT appear in the returned error message.
|
/// the secret does NOT appear in the returned error message.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn run_http_hook_scrubs_url_from_reqwest_error() {
|
async fn run_http_hook_scrubs_url_from_reqwest_error() {
|
||||||
// Use a TEST-NET-1 host (RFC 5737, "MUST NOT be used in
|
// A closed loopback port: allowed as a literal local host,
|
||||||
// public networks"). It is not RFC1918 so SSRF validation
|
// and refused at once. TEST-NET-1 is now blocked by policy.
|
||||||
// will let it through, but no real DNS or connection will
|
let dead = {
|
||||||
// succeed -- reqwest will surface a connection error whose
|
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
// default Display includes the URL.
|
l.local_addr().unwrap()
|
||||||
|
};
|
||||||
let secret = "ghp_VERY_REAL_SECRET_TOKEN_42";
|
let secret = "ghp_VERY_REAL_SECRET_TOKEN_42";
|
||||||
let mut extra_env = std::collections::HashMap::new();
|
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());
|
extra_env.insert("MY_TOKEN".to_string(), secret.to_string());
|
||||||
|
|
||||||
let raw = "https://${RUNTIME_HOST}/check?token=${MY_TOKEN}";
|
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
|
// debugging). The wire-DTO consumer must prefer raw_url for
|
||||||
// display -- documented in the HttpInfo rustdoc.
|
// display -- documented in the HttpInfo rustdoc.
|
||||||
let info = info.expect("HttpInfo should be present for connection failures too");
|
let info = info.expect("HttpInfo should be present for connection failures too");
|
||||||
assert_eq!(
|
assert_eq!(info.url, format!("https://{dead}/check?token={secret}"));
|
||||||
info.url,
|
|
||||||
"https://192.0.2.1/check?token=ghp_VERY_REAL_SECRET_TOKEN_42"
|
|
||||||
);
|
|
||||||
assert_eq!(info.raw_url.as_deref(), Some(raw));
|
assert_eq!(info.raw_url.as_deref(), Some(raw));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -318,6 +318,7 @@ mod tests {
|
|||||||
assert!(matches!(state, FileContentState::TooLarge { .. }));
|
assert!(matches!(state, FileContentState::TooLarge { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_read_file_bounded_symlink() {
|
async fn test_read_file_bounded_symlink() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -18,6 +18,76 @@ use super::embedding::EmbeddingProvider as _;
|
|||||||
use super::storage::MemoryStorage;
|
use super::storage::MemoryStorage;
|
||||||
use super::watcher::MemoryFileWatcher;
|
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.
|
/// 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
|
/// Grouping these in one struct ensures every call site — ToolBridge, first-turn
|
||||||
@@ -30,7 +100,8 @@ pub struct MemoryBackendParams {
|
|||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
/// Embedding provider config — `None` forces FTS-only fallback everywhere.
|
/// Embedding provider config — `None` forces FTS-only fallback everywhere.
|
||||||
pub embed_config: Option<kigi_config_types::MemoryEmbeddingConfig>,
|
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,
|
pub embed_base_url: String,
|
||||||
/// API key for embedding API calls.
|
/// API key for embedding API calls.
|
||||||
pub embed_api_key: Option<String>,
|
pub embed_api_key: Option<String>,
|
||||||
@@ -47,10 +118,8 @@ pub struct MemoryBackendParams {
|
|||||||
/// - `"injection"` — first-turn memory context injection
|
/// - `"injection"` — first-turn memory context injection
|
||||||
/// - `"compaction_recovery"` — post-compaction context re-injection
|
/// - `"compaction_recovery"` — post-compaction context re-injection
|
||||||
pub search_source: &'static str,
|
pub search_source: &'static str,
|
||||||
/// Dynamic API key provider — when set, `make_embedding_provider()` resolves
|
/// The session credentials, and the single endpoint they may reach.
|
||||||
/// the key per-call instead of using the static `embed_api_key`.
|
pub embedding_credentials: EndpointScopedCredentials,
|
||||||
pub api_key_provider: Option<kigi_tools::types::SharedApiKeyProvider>,
|
|
||||||
pub auth_credentials: Option<Arc<dyn kigi_auth::AuthCredentialProvider>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryBackendParams {
|
impl MemoryBackendParams {
|
||||||
@@ -59,8 +128,7 @@ impl MemoryBackendParams {
|
|||||||
pub async fn make_embedding_provider(&self) -> Option<super::embedding::ApiEmbeddingProvider> {
|
pub async fn make_embedding_provider(&self) -> Option<super::embedding::ApiEmbeddingProvider> {
|
||||||
build_embedding_provider(
|
build_embedding_provider(
|
||||||
self.embed_config.as_ref(),
|
self.embed_config.as_ref(),
|
||||||
self.auth_credentials.as_ref(),
|
&self.embedding_credentials,
|
||||||
self.api_key_provider.as_ref(),
|
|
||||||
self.embed_api_key.as_deref(),
|
self.embed_api_key.as_deref(),
|
||||||
&self.embed_base_url,
|
&self.embed_base_url,
|
||||||
)
|
)
|
||||||
@@ -70,8 +138,7 @@ impl MemoryBackendParams {
|
|||||||
|
|
||||||
async fn build_embedding_provider(
|
async fn build_embedding_provider(
|
||||||
config: Option<&kigi_config_types::MemoryEmbeddingConfig>,
|
config: Option<&kigi_config_types::MemoryEmbeddingConfig>,
|
||||||
auth_credentials: Option<&Arc<dyn kigi_auth::AuthCredentialProvider>>,
|
credentials: &EndpointScopedCredentials,
|
||||||
api_key_provider: Option<&kigi_tools::types::SharedApiKeyProvider>,
|
|
||||||
static_api_key: Option<&str>,
|
static_api_key: Option<&str>,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
) -> Option<super::embedding::ApiEmbeddingProvider> {
|
) -> Option<super::embedding::ApiEmbeddingProvider> {
|
||||||
@@ -80,9 +147,19 @@ async fn build_embedding_provider(
|
|||||||
return None;
|
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
|
// Prefer the refresh-capable credential provider — the middleware gives
|
||||||
// 401 retry for free without any per-call key resolution.
|
// 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());
|
let client = super::embedding::build_middleware_client(creds.clone());
|
||||||
return super::embedding::ApiEmbeddingProvider::from_config(
|
return super::embedding::ApiEmbeddingProvider::from_config(
|
||||||
config,
|
config,
|
||||||
@@ -91,14 +168,13 @@ async fn build_embedding_provider(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: resolve API key per-call, wrap in a static middleware client
|
// The platform's own configured key, wrapped in a static middleware client
|
||||||
// (no 401 refresh, but auth header is still stamped by middleware).
|
// (no 401 refresh, but the auth header is still stamped by middleware).
|
||||||
let api_key = match api_key_provider {
|
super::embedding::ApiEmbeddingProvider::from_session(
|
||||||
Some(p) => p.current_api_key_async().await,
|
config,
|
||||||
None => None,
|
base_url.to_owned(),
|
||||||
}
|
static_api_key?.to_owned(),
|
||||||
.or_else(|| static_api_key.map(|s| s.to_owned()))?;
|
)
|
||||||
super::embedding::ApiEmbeddingProvider::from_session(config, base_url.to_owned(), api_key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `MemoryBackend` implementation backed by hybrid search (FTS5 + vector KNN).
|
/// `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;
|
/// Only the ToolBridge backend's counter is shared back to the session actor;
|
||||||
/// injection and compaction-recovery backends use their own local counters.
|
/// injection and compaction-recovery backends use their own local counters.
|
||||||
pub search_counter: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
pub search_counter: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||||
/// Dynamic API key provider for embedding requests.
|
/// The session credentials, and the single endpoint they may reach.
|
||||||
api_key_provider: Option<kigi_tools::types::SharedApiKeyProvider>,
|
embedding_credentials: EndpointScopedCredentials,
|
||||||
/// Refresh-capable credential provider for embedding HTTP middleware.
|
|
||||||
auth_credentials: Option<Arc<dyn kigi_auth::AuthCredentialProvider>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryBackendImpl {
|
impl MemoryBackendImpl {
|
||||||
@@ -150,8 +224,7 @@ impl MemoryBackendImpl {
|
|||||||
stale_claim_secs: 60,
|
stale_claim_secs: 60,
|
||||||
session_id: String::new(),
|
session_id: String::new(),
|
||||||
search_source: "tool",
|
search_source: "tool",
|
||||||
api_key_provider: None,
|
embedding_credentials: EndpointScopedCredentials::none(),
|
||||||
auth_credentials: None,
|
|
||||||
search_counter: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
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> {
|
async fn make_embedding_provider(&self) -> Option<super::embedding::ApiEmbeddingProvider> {
|
||||||
build_embedding_provider(
|
build_embedding_provider(
|
||||||
self.embed_config.as_ref(),
|
self.embed_config.as_ref(),
|
||||||
self.auth_credentials.as_ref(),
|
&self.embedding_credentials,
|
||||||
self.api_key_provider.as_ref(),
|
|
||||||
self.embed_api_key.as_deref(),
|
self.embed_api_key.as_deref(),
|
||||||
&self.embed_base_url,
|
&self.embed_base_url,
|
||||||
)
|
)
|
||||||
@@ -232,8 +304,7 @@ impl MemoryBackendImpl {
|
|||||||
if let Some(w) = ¶ms.watcher {
|
if let Some(w) = ¶ms.watcher {
|
||||||
backend = backend.with_watcher(w.clone(), params.stale_claim_secs);
|
backend = backend.with_watcher(w.clone(), params.stale_claim_secs);
|
||||||
}
|
}
|
||||||
backend.api_key_provider = params.api_key_provider.clone();
|
backend.embedding_credentials = params.embedding_credentials.clone();
|
||||||
backend.auth_credentials = params.auth_credentials.clone();
|
|
||||||
backend
|
backend
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -479,8 +550,7 @@ mod factory_tests {
|
|||||||
watcher: None,
|
watcher: None,
|
||||||
stale_claim_secs: 60,
|
stale_claim_secs: 60,
|
||||||
search_source: "tool",
|
search_source: "tool",
|
||||||
api_key_provider: None,
|
embedding_credentials: EndpointScopedCredentials::none(),
|
||||||
auth_credentials: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1038,72 +1108,128 @@ mod factory_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression: provider build must use `current_api_key_async`,
|
struct ProbeCredentials;
|
||||||
/// never sync. Prevents memory_search 401s on rotated tokens.
|
impl kigi_auth::HttpAuth for ProbeCredentials {
|
||||||
#[tokio::test]
|
fn apply(
|
||||||
async fn make_embedding_provider_uses_async_api_key_resolution() {
|
&self,
|
||||||
use kigi_tools::types::ApiKeyProvider;
|
builder: reqwest::RequestBuilder,
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
_base_url: &str,
|
||||||
|
) -> reqwest::RequestBuilder {
|
||||||
struct AsyncProbe {
|
builder.bearer_auth("session-bearer")
|
||||||
sync_calls: Arc<AtomicU32>,
|
|
||||||
async_calls: Arc<AtomicU32>,
|
|
||||||
}
|
}
|
||||||
impl ApiKeyProvider for AsyncProbe {
|
}
|
||||||
fn current_api_key(&self) -> Option<String> {
|
#[async_trait::async_trait]
|
||||||
self.sync_calls.fetch_add(1, Ordering::SeqCst);
|
impl kigi_auth::AuthCredentialProvider for ProbeCredentials {
|
||||||
Some("sync-stale".into())
|
fn snapshot(&self) -> kigi_auth::CredentialSnapshot {
|
||||||
}
|
kigi_auth::CredentialSnapshot {
|
||||||
fn current_api_key_async(
|
token: Some("session-bearer".into()),
|
||||||
&self,
|
..Default::default()
|
||||||
) -> 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 fn refresh_after_unauthorized(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let sync_calls = Arc::new(AtomicU32::new(0));
|
const SESSION_ENDPOINT: &str = "https://api.kimi.com/coding/v1";
|
||||||
let async_calls = Arc::new(AtomicU32::new(0));
|
const FOREIGN_ENDPOINT: &str = "https://api.anthropic.com/v1";
|
||||||
let probe: kigi_tools::types::SharedApiKeyProvider = Arc::new(AsyncProbe {
|
|
||||||
sync_calls: sync_calls.clone(),
|
|
||||||
async_calls: async_calls.clone(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let params = MemoryBackendParams {
|
fn params_at(base_url: &str, credentials: EndpointScopedCredentials) -> MemoryBackendParams {
|
||||||
|
MemoryBackendParams {
|
||||||
session_id: "s1".into(),
|
session_id: "s1".into(),
|
||||||
embed_config: Some(MemoryEmbeddingConfig {
|
embed_config: Some(MemoryEmbeddingConfig {
|
||||||
model: Some("test-embed-model".into()),
|
model: Some("test-embed-model".into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
embed_base_url: "http://example/v1".into(),
|
embed_base_url: base_url.into(),
|
||||||
embed_api_key: Some("static-fallback".into()),
|
embed_api_key: None,
|
||||||
search_config: MemorySearchConfig::default(),
|
search_config: MemorySearchConfig::default(),
|
||||||
watcher: None,
|
watcher: None,
|
||||||
stale_claim_secs: 60,
|
stale_claim_secs: 60,
|
||||||
search_source: "tool",
|
search_source: "tool",
|
||||||
api_key_provider: Some(probe),
|
embedding_credentials: credentials,
|
||||||
// No auth_credentials — forces the api_key_provider fallback path.
|
}
|
||||||
auth_credentials: None,
|
}
|
||||||
};
|
|
||||||
|
|
||||||
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!(
|
assert!(
|
||||||
provider.is_some(),
|
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,
|
/// The runtime re-check, not construction alone, is what guards the wire:
|
||||||
"must call current_api_key_async exactly once per provider build"
|
/// `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!(
|
let redirected = MemoryBackendParams {
|
||||||
sync_calls.load(Ordering::SeqCst),
|
embed_base_url: FOREIGN_ENDPOINT.into(),
|
||||||
0,
|
..params_at(SESSION_ENDPOINT, scoped)
|
||||||
"sync current_api_key must NOT be called — the async path is the contract"
|
};
|
||||||
|
|
||||||
|
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 text_utils;
|
||||||
pub mod watcher;
|
pub mod watcher;
|
||||||
|
|
||||||
pub use backend::{MemoryBackendImpl, MemoryBackendParams};
|
pub use backend::{EndpointScopedCredentials, MemoryBackendImpl, MemoryBackendParams};
|
||||||
pub use index::{MemoryIndex, init_sqlite_vec};
|
pub use index::{MemoryIndex, init_sqlite_vec};
|
||||||
pub use storage::{MemoryScope, MemoryStorage};
|
pub use storage::{MemoryScope, MemoryStorage};
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ fn write_private(path: &Path, contents: &str) -> std::io::Result<()> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
#[cfg(unix)]
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
/// Write an executable `#!/bin/sh` fake `mmdc` and return (dir-guard, path).
|
/// Write an executable `#!/bin/sh` fake `mmdc` and return (dir-guard, path).
|
||||||
|
|||||||
@@ -179,9 +179,12 @@ fn reap_process_group(_child: &Child) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
#[cfg(unix)]
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
|
#[cfg(unix)]
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn detached(mut cmd: Command) -> Command {
|
fn detached(mut cmd: Command) -> Command {
|
||||||
cmd.stdin(Stdio::null())
|
cmd.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
|
|||||||
@@ -1574,7 +1574,7 @@ impl PlatformId {
|
|||||||
/// `models_cache.json`, the `visibility=="list"` AND `supported_in_api==true`
|
/// `models_cache.json`, the `visibility=="list"` AND `supported_in_api==true`
|
||||||
/// set) because OpenAI exposes no stable public models endpoint for the
|
/// set) because OpenAI exposes no stable public models endpoint for the
|
||||||
/// ChatGPT Codex backend. Each entry carries context window + per-model
|
/// 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
|
/// tiers), so the fetch path maps them through the SAME
|
||||||
/// `platform_wire_model_to_entry` output as a live listing — no new type.
|
/// `platform_wire_model_to_entry` output as a live listing — no new type.
|
||||||
pub fn hardcoded_catalog(self) -> Option<Vec<WireModel>> {
|
pub fn hardcoded_catalog(self) -> Option<Vec<WireModel>> {
|
||||||
@@ -1617,13 +1617,13 @@ fn openai_codex_wire_models() -> Vec<WireModel> {
|
|||||||
codex_wire_model(
|
codex_wire_model(
|
||||||
"gpt-5.6-sol",
|
"gpt-5.6-sol",
|
||||||
"GPT-5.6-Sol",
|
"GPT-5.6-Sol",
|
||||||
&["low", "medium", "high", "xhigh", "max", "ultra"],
|
&["low", "medium", "high", "xhigh", "max"],
|
||||||
"low",
|
"low",
|
||||||
),
|
),
|
||||||
codex_wire_model(
|
codex_wire_model(
|
||||||
"gpt-5.6-terra",
|
"gpt-5.6-terra",
|
||||||
"GPT-5.6-Terra",
|
"GPT-5.6-Terra",
|
||||||
&["low", "medium", "high", "xhigh", "max", "ultra"],
|
&["low", "medium", "high", "xhigh", "max"],
|
||||||
"medium",
|
"medium",
|
||||||
),
|
),
|
||||||
codex_wire_model(
|
codex_wire_model(
|
||||||
@@ -2655,7 +2655,7 @@ mod tests {
|
|||||||
|
|
||||||
/// The HARDCODED openai-codex catalog is exactly the 4 supported+listed
|
/// The HARDCODED openai-codex catalog is exactly the 4 supported+listed
|
||||||
/// models, keyed by slug, ctx 272000, each exposing its exact supported
|
/// 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
|
/// list-but-broken / hidden models are absent. Every other platform serves
|
||||||
/// NO hardcoded catalog (its models come from the live wire).
|
/// NO hardcoded catalog (its models come from the live wire).
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2702,11 +2702,11 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
efforts("gpt-5.6-sol"),
|
efforts("gpt-5.6-sol"),
|
||||||
["low", "medium", "high", "xhigh", "max", "ultra"]
|
["low", "medium", "high", "xhigh", "max"]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
efforts("gpt-5.6-terra"),
|
efforts("gpt-5.6-terra"),
|
||||||
["low", "medium", "high", "xhigh", "max", "ultra"]
|
["low", "medium", "high", "xhigh", "max"]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
efforts("gpt-5.6-luna"),
|
efforts("gpt-5.6-luna"),
|
||||||
|
|||||||
@@ -1251,6 +1251,10 @@ mod tests {
|
|||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use similar::ChangeTag;
|
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![
|
let hunk = vec![
|
||||||
DiffLine {
|
DiffLine {
|
||||||
text: "let x = 1;\n".into(),
|
text: "let x = 1;\n".into(),
|
||||||
@@ -1293,23 +1297,24 @@ mod tests {
|
|||||||
// The committed edit uses a flat background (terminal transparency), but
|
// The committed edit uses a flat background (terminal transparency), but
|
||||||
// must still paint the per-line diff backgrounds — otherwise an added /
|
// must still paint the per-line diff backgrounds — otherwise an added /
|
||||||
// removed line is indistinguishable from context.
|
// 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 y in 0..h {
|
||||||
for x in 0..width {
|
for x in 0..width {
|
||||||
if let Some(cell) = buf.cell((x, y)) {
|
if let Some(cell) = buf.cell((x, y))
|
||||||
saw_insert |= cell.bg == theme.diff_insert_bg;
|
&& cell.bg != ratatui::style::Color::Reset
|
||||||
saw_delete |= cell.bg == theme.diff_delete_bg;
|
{
|
||||||
|
bands.insert(format!("{:?}", cell.bg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
saw_insert,
|
bands.len() >= 2,
|
||||||
"committed edit lost the insert (green) diff background"
|
"committed edit lost its insert/delete diff bands: \
|
||||||
);
|
theme={:?} h={h} bands={bands:?}",
|
||||||
assert!(
|
Theme::current_kind(),
|
||||||
saw_delete,
|
|
||||||
"committed edit lost the delete (red) diff background"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ pub fn open_url_if_safe(url: &str, filter: SchemeFilter) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
#[test]
|
#[test]
|
||||||
fn open_path_command_passes_path_as_a_single_arg() {
|
fn open_path_command_passes_path_as_a_single_arg() {
|
||||||
// Path with spaces must be one argument, never shell-interpolated.
|
// Path with spaces must be one argument, never shell-interpolated.
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ impl TermWriter {
|
|||||||
pub fn writer_sync(&self) -> &WriterSync {
|
pub fn writer_sync(&self) -> &WriterSync {
|
||||||
&self.sync
|
&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 {
|
impl Write for TermWriter {
|
||||||
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
||||||
@@ -166,6 +170,56 @@ impl Drop for TermWriter {
|
|||||||
let _ = self.flush();
|
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.
|
/// Handle for the background writer thread.
|
||||||
///
|
///
|
||||||
/// Joining ensures all queued frames have been written to the terminal
|
/// Joining ensures all queued frames have been written to the terminal
|
||||||
@@ -174,19 +228,45 @@ pub struct WriterThread {
|
|||||||
handle: Option<std::thread::JoinHandle<()>>,
|
handle: Option<std::thread::JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
impl WriterThread {
|
impl WriterThread {
|
||||||
/// Block until the writer thread has processed all pending frames and
|
/// Wait up to `grace` for the thread to drain and exit, else detach it; every sender (`EscapeWriter` clones too) must be dropped first.
|
||||||
/// exited. The [`mpsc::Sender`] must be dropped *before* calling this,
|
pub fn join_within(mut self, grace: Duration) -> WriterJoin {
|
||||||
/// otherwise the thread will never see the channel close.
|
let Some(handle) = self.handle.take() else {
|
||||||
pub fn join(mut self) {
|
return WriterJoin::Joined;
|
||||||
if let Some(h) = self.handle.take() {
|
};
|
||||||
let _ = h.join();
|
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 {
|
impl Drop for WriterThread {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if let Some(h) = self.handle.take() {
|
if let Some(handle) = self.handle.take()
|
||||||
let _ = h.join();
|
&& 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`]
|
/// `BufWriter`. The [`WriterSync`] must be shared with every [`TermWriter`]
|
||||||
/// built on the sender so [`WriterSync::wait_drained`] tracks the queue.
|
/// built on the sender so [`WriterSync::wait_drained`] tracks the queue.
|
||||||
/// Drop the sender to signal the thread to exit, then call
|
/// 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) {
|
pub fn spawn_writer_thread() -> (mpsc::Sender<Vec<u8>>, WriterSync, WriterThread) {
|
||||||
let (tx, rx) = mpsc::channel::<Vec<u8>>();
|
let (tx, rx) = mpsc::channel::<Vec<u8>>();
|
||||||
let sync = WriterSync::new();
|
let sync = WriterSync::new();
|
||||||
@@ -570,4 +650,47 @@ mod tests {
|
|||||||
"Round-tripped bytes do not decode to original UTF-8 string"
|
"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.
|
//! portable re-injection exists (TIOCSTI is blocked); accepted loss.
|
||||||
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
#[cfg(unix)]
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Bounds the reply buffer against terminals that stream without a terminator.
|
/// Bounds the reply buffer against terminals that stream without a terminator.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ eventsource-stream = { workspace = true }
|
|||||||
futures-util = { workspace = true }
|
futures-util = { workspace = true }
|
||||||
indexmap = { workspace = true, features = ["serde"] }
|
indexmap = { workspace = true, features = ["serde"] }
|
||||||
reqwest = { workspace = true, features = ["stream"] }
|
reqwest = { workspace = true, features = ["stream"] }
|
||||||
|
kigi-secrets = { path = "../kigi-secrets" }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["rt", "macros", "time", "sync"] }
|
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))
|
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
|
/// Record `success=false` + `error` on the active inference span when a stream
|
||||||
/// request fails before any response (transport/connect/TLS errors). Without
|
/// request fails before any response (transport/connect/TLS errors). Without
|
||||||
/// this the `#[instrument]` span closes with both fields Empty, so an outage
|
/// 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 server_message_lower = server_message.to_lowercase();
|
||||||
|
|
||||||
let mut context_parts = vec![server_message.to_string()];
|
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
|
// Show headers if error mentions headers
|
||||||
if server_message_lower.contains("header") {
|
if server_message_lower.contains("header") {
|
||||||
@@ -1006,7 +1016,8 @@ impl SamplingClient {
|
|||||||
let endpoint = self.endpoint("chat/completions");
|
let endpoint = self.endpoint("chat/completions");
|
||||||
let server_message = response.text().await.unwrap_or_default();
|
let server_message = response.text().await.unwrap_or_default();
|
||||||
return Err(SamplingError::Auth(format!(
|
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 endpoint = self.endpoint("responses");
|
||||||
let server_message = parse_error_bytes(bytes.as_ref());
|
let server_message = parse_error_bytes(bytes.as_ref());
|
||||||
return Err(SamplingError::Auth(format!(
|
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 endpoint = self.endpoint("responses");
|
||||||
let server_message = response.text().await.unwrap_or_default();
|
let server_message = response.text().await.unwrap_or_default();
|
||||||
return Err(SamplingError::Auth(format!(
|
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());
|
let model_metadata = extract_model_metadata(response.headers());
|
||||||
@@ -1542,7 +1555,8 @@ impl SamplingClient {
|
|||||||
let endpoint = self.endpoint("messages");
|
let endpoint = self.endpoint("messages");
|
||||||
let server_message = parse_error_bytes(bytes.as_ref());
|
let server_message = parse_error_bytes(bytes.as_ref());
|
||||||
return Err(SamplingError::Auth(format!(
|
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 endpoint = self.endpoint("messages");
|
||||||
let server_message = response.text().await.unwrap_or_default();
|
let server_message = response.text().await.unwrap_or_default();
|
||||||
return Err(SamplingError::Auth(format!(
|
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());
|
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
|
//! # Retry behavior summary
|
||||||
//!
|
//!
|
||||||
//! **Retried** (up to [`DEFAULT_MAX_RETRIES`] = 15, ~6 min with 30s backoff cap):
|
//! **Retried** (up to [`DEFAULT_MAX_RETRIES`] = 15, ~6 min with the
|
||||||
//! - 500, 502, 503, 504, 520 (server errors)
|
//! [`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)
|
//! - Connection errors (timeout, refused, reset)
|
||||||
//! - `EventStreamError` / `StreamError` (mid-stream failures)
|
//! - `EventStreamError` / `StreamError` (mid-stream failures)
|
||||||
//! - `EmptyResponse` (model returned no content/tool calls)
|
//! - `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)
|
Duration::from_millis(hasher.finish() % 251)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exponential backoff (2s, 4s, 8s, ..., capped 30s) with +/-20% jitter
|
/// Longest single wait on the generic retry path — the exponential-backoff
|
||||||
/// to prevent thundering-herd retry storms.
|
/// 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 {
|
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::hash::{Hash, Hasher};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
static JITTER_SEQ: AtomicU64 = AtomicU64::new(0);
|
static JITTER_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
let shift = retry_count.saturating_sub(1);
|
let base_ms = base.as_millis() as u64;
|
||||||
let base_ms = 2000u64.checked_shl(shift).unwrap_or(u64::MAX).min(30_000);
|
|
||||||
let jitter_range = base_ms / 5;
|
let jitter_range = base_ms / 5;
|
||||||
let mut hasher = std::hash::DefaultHasher::new();
|
let mut hasher = std::hash::DefaultHasher::new();
|
||||||
JITTER_SEQ.fetch_add(1, Ordering::Relaxed).hash(&mut hasher);
|
JITTER_SEQ.fetch_add(1, Ordering::Relaxed).hash(&mut hasher);
|
||||||
@@ -210,9 +232,11 @@ pub fn classify_error(
|
|||||||
if next_attempt >= max_retries {
|
if next_attempt >= max_retries {
|
||||||
return RetryDecision::Fatal(clone_error(err));
|
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
|
let backoff = err
|
||||||
.retry_after()
|
.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));
|
.unwrap_or_else(|| retry_backoff_with_jitter(next_attempt));
|
||||||
if next_attempt == 1 {
|
if next_attempt == 1 {
|
||||||
return RetryDecision::RetryWithClientRebuild { backoff };
|
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]
|
#[test]
|
||||||
fn classify_5xx_first_retry_rebuilds_client() {
|
fn classify_5xx_first_retry_rebuilds_client() {
|
||||||
let err = api_err(StatusCode::INTERNAL_SERVER_ERROR, "boom");
|
let err = api_err(StatusCode::INTERNAL_SERVER_ERROR, "boom");
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ enum BlockType {
|
|||||||
Thinking,
|
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
|
/// Transform a raw Anthropic Messages API stream into a stream of
|
||||||
/// [`SamplingEvent`]s.
|
/// [`SamplingEvent`]s.
|
||||||
///
|
///
|
||||||
@@ -454,11 +463,18 @@ pub fn stream_messages<'a>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if final_stop_reason == Some(StopReason::Length) {
|
if final_stop_reason == Some(StopReason::Length) {
|
||||||
yield SamplingEvent::Failed {
|
if !tool_calls_complete(&assistant_tool_calls) {
|
||||||
request_id: request_id.clone(),
|
yield SamplingEvent::Failed {
|
||||||
error: SamplingErrorInfo::from(&SamplingError::MaxTokensTruncation),
|
request_id: request_id.clone(),
|
||||||
};
|
error: SamplingErrorInfo::from(&SamplingError::MaxTokensTruncation),
|
||||||
return;
|
};
|
||||||
|
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
|
// 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.cached_prompt_tokens, 2500);
|
||||||
assert_eq!(usage.total_tokens, 2501);
|
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::InvalidConfiguration(_) => false,
|
||||||
SamplingError::Http(err) => is_retryable_reqwest(err),
|
SamplingError::Http(err) => is_retryable_reqwest(err),
|
||||||
SamplingError::Serialization(_) => false,
|
SamplingError::Serialization(_) => false,
|
||||||
SamplingError::Api { status, .. } => {
|
SamplingError::Api { status, .. } => is_retryable_api_status(status.as_u16()),
|
||||||
matches!(status.as_u16(), 429 | 500 | 502 | 503 | 504 | 520)
|
|
||||||
}
|
|
||||||
SamplingError::EventStreamError(_) => true,
|
SamplingError::EventStreamError(_) => true,
|
||||||
SamplingError::StreamError { .. } => true,
|
SamplingError::StreamError { .. } => true,
|
||||||
SamplingError::IdleTimeout { .. } => false,
|
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
|
/// 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
|
/// 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.
|
/// 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 prompt length")
|
||||||
|| m.contains("maximum context length")
|
|| m.contains("maximum context length")
|
||||||
|| m.contains("context_length_exceeded")
|
|| m.contains("context_length_exceeded")
|
||||||
|
|| (m.contains("current message") && m.contains("exceeds budget"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decide whether a [`reqwest::Error`] is worth retrying.
|
/// Decide whether a [`reqwest::Error`] is worth retrying.
|
||||||
@@ -378,6 +386,27 @@ pub fn is_retryable_reqwest(err: &reqwest::Error) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn context_length_error_matches_backend_variants() {
|
fn context_length_error_matches_backend_variants() {
|
||||||
for msg in [
|
for msg in [
|
||||||
@@ -387,6 +416,7 @@ mod tests {
|
|||||||
"This model's maximum context length is 200000 tokens",
|
"This model's maximum context length is 200000 tokens",
|
||||||
"invalid_request_error: prompt is too long: 300000 tokens > 200000 maximum",
|
"invalid_request_error: prompt is too long: 300000 tokens > 200000 maximum",
|
||||||
"error type: context_length_exceeded",
|
"error type: context_length_exceeded",
|
||||||
|
"the current message exceeds budget: 120000 < 250000",
|
||||||
] {
|
] {
|
||||||
assert!(is_context_length_error(msg), "should match: {msg}");
|
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;
|
/// Messages both accept `xhigh` AND `max` as separate levels in 2026;
|
||||||
/// the Kimi wire spells its top tier `max` with no `xhigh`).
|
/// the Kimi wire spells its top tier `max` with no `xhigh`).
|
||||||
Max,
|
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 {
|
impl ReasoningEffort {
|
||||||
@@ -944,15 +939,12 @@ impl ReasoningEffort {
|
|||||||
Self::High => "high",
|
Self::High => "high",
|
||||||
Self::Xhigh => "xhigh",
|
Self::Xhigh => "xhigh",
|
||||||
Self::Max => "max",
|
Self::Max => "max",
|
||||||
Self::Ultra => "ultra",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Anthropic Messages API effort string; `None` for unsupported variants.
|
/// Anthropic Messages API effort string; `None` for unsupported variants.
|
||||||
/// `xhigh` and `max` are distinct levels on the 2026 Messages API (both
|
/// `xhigh` and `max` are distinct levels on the 2026 Messages API (both
|
||||||
/// appear in `GET /v1/models` `capabilities.effort`). `ultra` is codex-only
|
/// appear in `GET /v1/models` `capabilities.effort`).
|
||||||
/// 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).
|
|
||||||
pub fn to_messages_api(self) -> Option<&'static str> {
|
pub fn to_messages_api(self) -> Option<&'static str> {
|
||||||
match self {
|
match self {
|
||||||
Self::None | Self::Minimal => None,
|
Self::None | Self::Minimal => None,
|
||||||
@@ -961,7 +953,6 @@ impl ReasoningEffort {
|
|||||||
Self::High => Some("high"),
|
Self::High => Some("high"),
|
||||||
Self::Xhigh => Some("xhigh"),
|
Self::Xhigh => Some("xhigh"),
|
||||||
Self::Max => Some("max"),
|
Self::Max => Some("max"),
|
||||||
Self::Ultra => Some("ultra"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -984,9 +975,8 @@ impl std::str::FromStr for ReasoningEffort {
|
|||||||
"high" => Ok(Self::High),
|
"high" => Ok(Self::High),
|
||||||
"xhigh" => Ok(Self::Xhigh),
|
"xhigh" => Ok(Self::Xhigh),
|
||||||
"max" => Ok(Self::Max),
|
"max" => Ok(Self::Max),
|
||||||
"ultra" => Ok(Self::Ultra),
|
|
||||||
_ => Err(format!(
|
_ => 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"));
|
assert_eq!(ReasoningEffort::Max.to_messages_api(), Some("max"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The codex-only `ultra` tier parses, serializes, and patches onto a
|
/// `ultra` is not a tier any backend accepts.
|
||||||
/// Responses body as `reasoning.effort = "ultra"` (the crux of surfacing a
|
///
|
||||||
/// codex model's full thinking menu). It is a DISTINCT level above `max`.
|
/// 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]
|
#[test]
|
||||||
fn reasoning_effort_ultra_is_a_distinct_codex_tier() {
|
fn reasoning_effort_ultra_is_not_a_tier() {
|
||||||
assert_eq!(
|
assert!("ultra".parse::<ReasoningEffort>().is_err());
|
||||||
"ultra".parse::<ReasoningEffort>().unwrap(),
|
assert!("ULTRA".parse::<ReasoningEffort>().is_err());
|
||||||
ReasoningEffort::Ultra
|
assert!(serde_json::from_str::<ReasoningEffort>("\"ultra\"").is_err());
|
||||||
);
|
assert_eq!(ReasoningEffort::Max.as_str(), "max");
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The account id is decoded STATELESSLY from the bearer JWT payload's
|
/// 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}));
|
let bad_type = as_map(serde_json::json!({"reasoningEffort": 3}));
|
||||||
assert_eq!(parse_reasoning_effort_meta(Some(&bad_type)), None);
|
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"}));
|
let unknown = as_map(serde_json::json!({"reasoningEffort": "MEGA"}));
|
||||||
assert_eq!(parse_reasoning_effort_meta(Some(&unknown)), None);
|
assert_eq!(parse_reasoning_effort_meta(Some(&unknown)), None);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ enforce = []
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serial_test = { workspace = true }
|
serial_test = { workspace = true }
|
||||||
|
tempfile = { workspace = true }
|
||||||
# Compiles the hand-rolled macOS deny-glob regex in tests to assert it matches
|
# 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
|
# EXACTLY the set globset (the Linux backend) matches — the cross-platform parity
|
||||||
# guard for the glob dialect.
|
# guard for the glob dialect.
|
||||||
|
|||||||
@@ -14,9 +14,19 @@
|
|||||||
//! cargo run -p kigi-sandbox --example sandbox_smoke_test -- read-only
|
//! 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};
|
use kigi_sandbox::{ProfileName, SandboxManager};
|
||||||
|
#[cfg(unix)]
|
||||||
use std::path::Path;
|
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() {
|
fn main() {
|
||||||
let profile_name = std::env::args()
|
let profile_name = std::env::args()
|
||||||
.nth(1)
|
.nth(1)
|
||||||
@@ -121,6 +131,7 @@ fn main() {
|
|||||||
println!("\n✅ Smoke test complete");
|
println!("\n✅ Smoke test complete");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn test_read(label: &str, path: &Path) {
|
fn test_read(label: &str, path: &Path) {
|
||||||
if path.is_file() {
|
if path.is_file() {
|
||||||
match std::fs::read(path) {
|
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) {
|
fn test_write(label: &str, path: &Path) {
|
||||||
match std::fs::write(path, b"sandbox-test") {
|
match std::fs::write(path, b"sandbox-test") {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
|||||||
@@ -1,91 +1,130 @@
|
|||||||
//! Per-child seccomp network filter. No-op on non-Linux.
|
//! 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
|
/// # Safety
|
||||||
///
|
///
|
||||||
/// Must be called in a `pre_exec` context (after `fork`, before `exec`).
|
/// Must be called in a `pre_exec` context (after `fork`, before `exec`).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
|
pub unsafe fn install_child_network_filter(filter: &[libc::sock_filter]) -> std::io::Result<()> {
|
||||||
use libc::{
|
use libc::{PR_SET_NO_NEW_PRIVS, PR_SET_SECCOMP, SECCOMP_MODE_FILTER, prctl, sock_fprog};
|
||||||
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));
|
|
||||||
|
|
||||||
let prog = sock_fprog {
|
let prog = sock_fprog {
|
||||||
len: filter.len() as u16,
|
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.
|
// 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 {
|
if unsafe { prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
|
||||||
return Err(std::io::Error::last_os_error());
|
return Err(std::io::Error::last_os_error());
|
||||||
}
|
}
|
||||||
|
// SAFETY: prog is a valid sock_fprog pointing to a filter the kernel copies.
|
||||||
// SAFETY: prog is a valid sock_fprog pointing to our filter array.
|
|
||||||
if unsafe {
|
if unsafe {
|
||||||
prctl(
|
prctl(
|
||||||
PR_SET_SECCOMP,
|
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());
|
return Err(std::io::Error::last_os_error());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
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<()> {
|
pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
|
||||||
Ok(())
|
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,
|
_ => 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
|
/// 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)
|
/// for this classification so callers (e.g. the shell's fail-closed startup path)
|
||||||
/// cannot drift and silently fail open.
|
/// 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 {
|
pub fn requires_read_deny(_profile: &ProfileName, _workspace: &Path) -> bool {
|
||||||
false
|
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`),
|
/// A profile's resolved bwrap deny plan: read-only mounts (`deny_write`),
|
||||||
/// bound-over unreadable placeholders (`deny_read`), and whether the profile
|
/// bound-over unreadable placeholders (`deny_read`), and whether the profile
|
||||||
/// carries deny globs (`has_globs`, so the re-exec proceeds even with zero
|
/// 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"))]
|
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
||||||
fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> {
|
fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> {
|
||||||
let config = profiles::load_sandbox_config(workspace);
|
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()]
|
vec!["/data".to_string()]
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -424,7 +466,11 @@ fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyP
|
|||||||
#[cfg(all(not(feature = "enforce"), target_os = "linux"))]
|
#[cfg(all(not(feature = "enforce"), target_os = "linux"))]
|
||||||
fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> {
|
fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> {
|
||||||
let config = profiles::load_sandbox_config(workspace);
|
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()]
|
vec!["/data".to_string()]
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -595,6 +641,43 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[test]
|
#[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() {
|
fn configured_profile_is_recorded() {
|
||||||
set_configured_profile("read-only");
|
set_configured_profile("read-only");
|
||||||
assert_eq!(configured_profile_name(), Some("read-only"));
|
assert_eq!(configured_profile_name(), Some("read-only"));
|
||||||
@@ -700,10 +783,11 @@ mod tests {
|
|||||||
"devbox-empty",
|
"devbox-empty",
|
||||||
"[profiles.devempty]\nextends = \"devbox\"\n",
|
"[profiles.devempty]\nextends = \"devbox\"\n",
|
||||||
);
|
);
|
||||||
assert!(
|
assert_eq!(
|
||||||
bwrap_reexec_for_profile(&ProfileName::Custom("devempty".to_string()), &ws_empty)
|
bwrap_reexec_for_profile(&ProfileName::Custom("devempty".to_string()), &ws_empty)
|
||||||
.is_some(),
|
.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 _ = std::fs::remove_dir_all(&ws_empty);
|
||||||
let ws_ws = temp_workspace_with_sandbox_toml(
|
let ws_ws = temp_workspace_with_sandbox_toml(
|
||||||
@@ -717,3 +801,31 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&ws_ws);
|
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.
|
//! Sandbox event logger.
|
||||||
//!
|
//!
|
||||||
//! Events (profile applied, violations, bypasses) are buffered in memory and
|
//! 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::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -87,8 +87,12 @@ impl SandboxLogger {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Under `sessions/`: the only home subtree a strict sandbox leaves
|
||||||
|
/// writable, and this flush runs after enforcement.
|
||||||
fn log_file_path() -> PathBuf {
|
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
|
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).
|
/// 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`.
|
/// Device files are handled separately via `allow_file` in `to_capability_set_with_config`.
|
||||||
#[cfg(all(feature = "enforce", unix))]
|
#[cfg(all(feature = "enforce", unix))]
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use crate::paths::kigi_home;
|
|||||||
#[cfg(all(feature = "enforce", unix))]
|
#[cfg(all(feature = "enforce", unix))]
|
||||||
use crate::paths::{
|
use crate::paths::{
|
||||||
DEVICE_DIRS, DEVICE_FILES, essential_writable_paths, essential_writable_paths_minimal,
|
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`.
|
/// 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))]
|
#[cfg(all(feature = "enforce", unix))]
|
||||||
impl ProfileName {
|
impl ProfileName {
|
||||||
/// Convert this profile into a nono `CapabilitySet` for the given workspace.
|
/// 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> {
|
pub fn to_capability_set(&self, workspace: &Path) -> anyhow::Result<CapabilitySet> {
|
||||||
let config = load_sandbox_config(workspace);
|
let config = load_sandbox_config(workspace);
|
||||||
self.to_capability_set_with_config(workspace, &config)
|
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
|
// apply time (it opens an O_PATH fd), but new files within it can
|
||||||
// be created freely after the sandbox is applied. Pre-create
|
// be created freely after the sandbox is applied. Pre-create
|
||||||
// directories like ~/.kigi/ that may not exist on first run.
|
// directories like ~/.kigi/ that may not exist on first run.
|
||||||
|
let home = kigi_home();
|
||||||
for path in &profile.read_write {
|
for path in &profile.read_write {
|
||||||
if !path.exists() && std::fs::create_dir_all(path).is_err() {
|
let Some(grant) = Self::read_write_grant_path(path, &home) else {
|
||||||
tracing::warn!(path = ?path, "read_write path does not exist and could not be created, skipping");
|
tracing::warn!(path = ?path, "read_write grant skipped");
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
let Some(path_str) = path.to_str() else {
|
let Some(path_str) = grant.to_str() else {
|
||||||
tracing::warn!(path = ?path, "Skipping non-UTF8 read_write path");
|
tracing::warn!(path = ?grant, "Skipping non-UTF8 read_write path");
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
caps = caps.allow_path(path_str, AccessMode::ReadWrite)?;
|
caps = caps.allow_path(path_str, AccessMode::ReadWrite)?;
|
||||||
@@ -380,12 +408,15 @@ impl ProfileName {
|
|||||||
.chain(std::iter::once(home.join("Library")))
|
.chain(std::iter::once(home.join("Library")))
|
||||||
.filter(|p| p.exists())
|
.filter(|p| p.exists())
|
||||||
.chain(std::iter::once(workspace.to_path_buf()))
|
.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();
|
.collect();
|
||||||
|
|
||||||
Ok(SandboxProfile {
|
Ok(SandboxProfile {
|
||||||
name: "strict".to_string(),
|
name: "strict".to_string(),
|
||||||
read_only: system_read,
|
read_only: system_read,
|
||||||
read_write: essential_writable_paths(workspace),
|
read_write: essential_writable_paths_strict(workspace),
|
||||||
deny: vec![],
|
deny: vec![],
|
||||||
default_read: false,
|
default_read: false,
|
||||||
restrict_network: true,
|
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]
|
#[test]
|
||||||
#[cfg(all(feature = "enforce", unix))]
|
#[cfg(all(feature = "enforce", unix))]
|
||||||
fn base_profile_capability_set_builds() {
|
fn base_profile_capability_set_builds() {
|
||||||
|
|||||||
@@ -1164,6 +1164,7 @@ mod platform {
|
|||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
mod platform {
|
mod platform {
|
||||||
use super::ImageData;
|
use super::ImageData;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
/// No subprocess-free pasteboard probe exists off-macOS.
|
/// No subprocess-free pasteboard probe exists off-macOS.
|
||||||
@@ -1926,19 +1927,18 @@ mod platform {
|
|||||||
// Public API
|
// Public API
|
||||||
|
|
||||||
pub fn get_text() -> anyhow::Result<Option<String>> {
|
pub fn get_text() -> anyhow::Result<Option<String>> {
|
||||||
let mut arboard_error = None;
|
let arboard_error = match arboard_get_text() {
|
||||||
match arboard_get_text() {
|
|
||||||
Ok(Some(text)) => return Ok(Some(text)),
|
Ok(Some(text)) => return Ok(Some(text)),
|
||||||
// Wayland-only: arboard's empty answer is not authoritative,
|
// Wayland-only: arboard's empty answer is not authoritative,
|
||||||
// fall through to wl-paste (see `wayland_tool_selected`).
|
// fall through to wl-paste (see `wayland_tool_selected`).
|
||||||
#[cfg(target_os = "linux")]
|
#[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),
|
Ok(None) => return Ok(None),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::debug!("arboard get_text failed: {e}");
|
tracing::debug!("arboard get_text failed: {e}");
|
||||||
arboard_error = Some(e);
|
Some(e)
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
if let Some(spec) = linux_tool_spec() {
|
if let Some(spec) = linux_tool_spec() {
|
||||||
let bytes = run_capture_out_checked(spec.read_text, CLI_READ_WAIT)?;
|
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>> {
|
pub fn get_image() -> anyhow::Result<Option<ImageData>> {
|
||||||
let mut arboard_error = None;
|
let arboard_error = match arboard_get_image() {
|
||||||
match arboard_get_image() {
|
|
||||||
Ok(Some(image)) => return Ok(Some(image)),
|
Ok(Some(image)) => return Ok(Some(image)),
|
||||||
// Wayland-only: arboard's empty answer is not authoritative,
|
// Wayland-only: arboard's empty answer is not authoritative,
|
||||||
// fall through to wl-paste (see `wayland_tool_selected`).
|
// fall through to wl-paste (see `wayland_tool_selected`).
|
||||||
#[cfg(target_os = "linux")]
|
#[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),
|
Ok(None) => return Ok(None),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::debug!("arboard get_image failed: {e}");
|
tracing::debug!("arboard get_image failed: {e}");
|
||||||
arboard_error = Some(e);
|
Some(e)
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
if let Some(spec) = linux_tool_spec()
|
if let Some(spec) = linux_tool_spec()
|
||||||
&& let Some(argv) = spec.read_png
|
&& let Some(argv) = spec.read_png
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ use std::io::{self, Write};
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[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).
|
/// 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.write_all(contents)?;
|
||||||
file.flush()?;
|
file.flush()?;
|
||||||
|
|
||||||
// On Windows, we need to set permissions after file creation
|
// Re-assert owner-only bits: `OpenOptions::mode` only applies on create,
|
||||||
#[cfg(windows)]
|
// so an existing world-readable file would otherwise keep open perms.
|
||||||
{
|
ensure_owner_only_permissions(path)?;
|
||||||
set_windows_secure_permissions(path)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens a file for writing with secure permissions set during creation (Unix)
|
/// Opens a file for writing with secure permissions set during creation (Unix)
|
||||||
/// or prepares it for permission setting after creation (Windows).
|
/// 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> {
|
pub fn open_secure_file(path: &Path) -> io::Result<File> {
|
||||||
let mut options = OpenOptions::new();
|
let mut options = OpenOptions::new();
|
||||||
options.truncate(true).write(true).create(true);
|
options.truncate(true).write(true).create(true);
|
||||||
@@ -82,6 +85,44 @@ pub fn open_secure_file(path: &Path) -> io::Result<File> {
|
|||||||
options.open(path)
|
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.
|
/// Sets Windows-specific secure permissions on a file.
|
||||||
///
|
///
|
||||||
/// This function modifies the file's ACL to:
|
/// This function modifies the file's ACL to:
|
||||||
@@ -219,8 +260,6 @@ mod tests {
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn test_unix_permissions() {
|
fn test_unix_permissions() {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
|
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
let file_path = temp_dir.path().join("test_perms.txt");
|
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
|
// Check that only owner has read/write (0o600), ignoring file type bits
|
||||||
assert_eq!(mode & 0o777, 0o600);
|
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 }
|
zstd = { workspace = true }
|
||||||
tar = { workspace = true }
|
tar = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
|
kigi-secrets = { path = "../kigi-secrets" }
|
||||||
walkdir = { workspace = true }
|
walkdir = { workspace = true }
|
||||||
tdigests = "1.0"
|
tdigests = "1.0"
|
||||||
semver = { workspace = true }
|
semver = { workspace = true }
|
||||||
|
|||||||
@@ -1309,6 +1309,7 @@ output_byte_limit = 65536 # max output size (64KB)
|
|||||||
[toolset.web_fetch]
|
[toolset.web_fetch]
|
||||||
proxy_endpoint = "https://proxy.example.com" # egress proxy URL (all requests routed through it)
|
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
|
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]
|
[shortcuts]
|
||||||
send = ["Enter"]
|
send = ["Enter"]
|
||||||
@@ -2100,7 +2101,7 @@ kigi --sandbox strict
|
|||||||
| `off` (default) | Unrestricted | Unrestricted | Unrestricted | No sandbox |
|
| `off` (default) | Unrestricted | Unrestricted | Unrestricted | No sandbox |
|
||||||
| `workspace` | Everywhere | CWD + `/tmp` + `~/.kigi/` | Allowed | Normal development |
|
| `workspace` | Everywhere | CWD + `/tmp` + `~/.kigi/` | Allowed | Normal development |
|
||||||
| `read-only` | Everywhere | `~/.kigi/` only | Blocked | Exploration, code review |
|
| `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
|
Sensitive paths (`~/.ssh/`, `~/.aws/`, `~/.gnupg/`, `~/.kigi/auth/`) are always
|
||||||
write-protected regardless of profile.
|
write-protected regardless of profile.
|
||||||
@@ -2159,7 +2160,7 @@ model cannot convince the agent to relax restrictions at runtime.
|
|||||||
|
|
||||||
### Event Logging
|
### 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.
|
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_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` | 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_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_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_FEEDBACK_ENABLED` | Enable (`1`) or disable (`0`) feedback system independently from telemetry |
|
||||||
| `KIGI_DEPLOYMENT_KEY` | Management API key for enterprise deployments |
|
| `KIGI_DEPLOYMENT_KEY` | Management API key for enterprise deployments |
|
||||||
|
|||||||
@@ -2171,6 +2171,19 @@ impl Config {
|
|||||||
)
|
)
|
||||||
.with_segment_detail(self.resolve_compaction_detail())
|
.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`.
|
/// Resolve verbatim-input flag: env `KIGI_COMPACTION_VERBATIM_INPUT` > config > remote settings > default `true`.
|
||||||
pub(crate) fn resolve_compaction_verbatim_input(&self) -> bool {
|
pub(crate) fn resolve_compaction_verbatim_input(&self) -> bool {
|
||||||
BoolFlag::env("KIGI_COMPACTION_VERBATIM_INPUT")
|
BoolFlag::env("KIGI_COMPACTION_VERBATIM_INPUT")
|
||||||
@@ -2588,6 +2601,7 @@ pub(crate) fn resolve_model_list(
|
|||||||
}
|
}
|
||||||
resolved = prefetched;
|
resolved = prefetched;
|
||||||
}
|
}
|
||||||
|
let mut explicit_api_backend_keys = std::collections::HashSet::new();
|
||||||
for (key, model_override) in &cfg.config_models {
|
for (key, model_override) in &cfg.config_models {
|
||||||
let had_base = resolved.contains_key(key);
|
let had_base = resolved.contains_key(key);
|
||||||
let base = resolved.shift_remove(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);
|
let entry = model_override.apply(key, base, &cfg.endpoints);
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
model_key = % key, base_url = % entry.info.base_url, has_api_key = entry
|
model_key = % key, base_url = % entry.info.base_url, has_api_key = entry
|
||||||
@@ -2625,7 +2642,7 @@ pub(crate) fn resolve_model_list(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.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 let Some((donor_cw, donor_backend)) = donors.get(&entry.info.model) {
|
||||||
if entry.info.context_window.get() == default_cw {
|
if entry.info.context_window.get() == default_cw {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -2635,7 +2652,9 @@ pub(crate) fn resolve_model_list(
|
|||||||
);
|
);
|
||||||
entry.info.context_window = *donor_cw;
|
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()
|
&& *donor_backend != ApiBackend::default()
|
||||||
{
|
{
|
||||||
entry.info.api_backend.clone_from(donor_backend);
|
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).
|
/// 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")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub compaction_verbatim_input: Option<bool>,
|
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
|
/// Snapshot a completed subagent's isolated worktree into a durable git ref
|
||||||
/// and delete its directory (resume rehydrates from the ref). This is the
|
/// and delete its directory (resume rehydrates from the ref). This is the
|
||||||
/// per-deployment rollout lever (set in managed_config.toml `[features]`).
|
/// 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}"
|
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.
|
/// Slug propagation should carry over api_backend but NOT agent_type.
|
||||||
#[test]
|
#[test]
|
||||||
fn slug_propagation_inherits_api_backend_but_not_agent_type() {
|
fn slug_propagation_inherits_api_backend_but_not_agent_type() {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! Port of kimi-cli's `/feedback` slash command (kimi-cli
|
//! Port of kimi-cli's `/feedback` slash command (kimi-cli
|
||||||
//! `src/kimi_cli/ui/shell/slash.py`, `feedback()`): subscription (OAuth)
|
//! `src/kimi_cli/ui/shell/slash.py`, `feedback()`): subscription (OAuth)
|
||||||
//! sessions POST the user's feedback text to `{coding_api_base_url}/feedback`
|
//! 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:
|
//! The request body carries exactly the fields kimi-cli sends:
|
||||||
//! `session_id`, `content`, `version`, `os`, `model`.
|
//! `session_id`, `content`, `version`, `os`, `model`.
|
||||||
|
|
||||||
|
|||||||
@@ -210,9 +210,9 @@ pub(crate) async fn apply(
|
|||||||
auto_compact_threshold_percent: new_threshold,
|
auto_compact_threshold_percent: new_threshold,
|
||||||
responds_to: tx,
|
responds_to: tx,
|
||||||
});
|
});
|
||||||
let updated_model = rx
|
let (updated_model, context_window) = rx
|
||||||
.await
|
.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) {
|
if let Some(handle) = agent.sessions.borrow_mut().get_mut(&session_id) {
|
||||||
handle.model_id = model_id.clone();
|
handle.model_id = model_id.clone();
|
||||||
handle.reasoning_effort = applied_effort;
|
handle.reasoning_effort = applied_effort;
|
||||||
@@ -224,6 +224,7 @@ pub(crate) async fn apply(
|
|||||||
&session_id,
|
&session_id,
|
||||||
model_id.0.as_ref(),
|
model_id.0.as_ref(),
|
||||||
applied_effort.map(|eff| eff.to_string()),
|
applied_effort.map(|eff| eff.to_string()),
|
||||||
|
context_window.get(),
|
||||||
);
|
);
|
||||||
if agent.cfg.borrow().mode != config::AgentMode::Leader {
|
if agent.cfg.borrow().mode != config::AgentMode::Leader {
|
||||||
agent.models_manager.set_current_model_id(model_id);
|
agent.models_manager.set_current_model_id(model_id);
|
||||||
@@ -232,9 +233,12 @@ pub(crate) async fn apply(
|
|||||||
.set_current_reasoning_effort(applied_effort);
|
.set_current_reasoning_effort(applied_effort);
|
||||||
}
|
}
|
||||||
Ok(acp::SetSessionModelResponse::new().meta(
|
Ok(acp::SetSessionModelResponse::new().meta(
|
||||||
serde_json::json!({ "model" : updated_model, })
|
serde_json::json!({
|
||||||
.as_object()
|
"model": updated_model,
|
||||||
.cloned(),
|
"totalContextTokens": context_window.get(),
|
||||||
|
})
|
||||||
|
.as_object()
|
||||||
|
.cloned(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
/// Broadcast a `ModelChanged` to every client subscribed to this session so
|
/// Broadcast a `ModelChanged` to every client subscribed to this session so
|
||||||
@@ -245,12 +249,14 @@ fn broadcast_model_changed(
|
|||||||
session_id: &acp::SessionId,
|
session_id: &acp::SessionId,
|
||||||
model_id: &str,
|
model_id: &str,
|
||||||
reasoning_effort: Option<String>,
|
reasoning_effort: Option<String>,
|
||||||
|
context_window: u64,
|
||||||
) {
|
) {
|
||||||
let notification = crate::extensions::notification::SessionNotification {
|
let notification = crate::extensions::notification::SessionNotification {
|
||||||
session_id: session_id.clone(),
|
session_id: session_id.clone(),
|
||||||
update: crate::extensions::notification::SessionUpdate::ModelChanged {
|
update: crate::extensions::notification::SessionUpdate::ModelChanged {
|
||||||
model_id: model_id.to_owned(),
|
model_id: model_id.to_owned(),
|
||||||
reasoning_effort,
|
reasoning_effort,
|
||||||
|
context_window: Some(context_window),
|
||||||
},
|
},
|
||||||
meta: None,
|
meta: None,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1938,7 +1938,7 @@ fn resolve_prefetch_env(kimi_code_config: Option<KimiCodeConfig>) -> Option<Pref
|
|||||||
/// credentials from disk.
|
/// credentials from disk.
|
||||||
pub fn start_early_prefetch_with_auth(auth: Option<KimiAuth>) -> Option<EarlyPrefetchHandle> {
|
pub fn start_early_prefetch_with_auth(auth: Option<KimiAuth>) -> Option<EarlyPrefetchHandle> {
|
||||||
let env = resolve_prefetch_env_with_auth(auth)?;
|
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.
|
/// Start the model-catalog prefetch on a background thread.
|
||||||
@@ -1949,51 +1949,64 @@ pub fn start_early_prefetch(
|
|||||||
kimi_code_config: Option<KimiCodeConfig>,
|
kimi_code_config: Option<KimiCodeConfig>,
|
||||||
) -> Option<EarlyPrefetchHandle> {
|
) -> Option<EarlyPrefetchHandle> {
|
||||||
let env = resolve_prefetch_env(kimi_code_config)?;
|
let env = resolve_prefetch_env(kimi_code_config)?;
|
||||||
Some(spawn_prefetch_thread(env))
|
spawn_prefetch_thread(env)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_prefetch_thread(env: PrefetchEnv) -> EarlyPrefetchHandle {
|
/// `None` when the OS refuses a thread: the prefetch is an optimization and
|
||||||
std::thread::spawn(move || {
|
/// runs before the runtime builds, so a spawn panic here would preempt the
|
||||||
let mut timer = crate::instrumentation_timer!("startup.early_prefetch");
|
/// runtime's own out-of-threads diagnostic.
|
||||||
let proxy_endpoint = env.endpoints.proxy_url();
|
fn spawn_prefetch_thread(env: PrefetchEnv) -> Option<EarlyPrefetchHandle> {
|
||||||
timer.with_field("endpoint", proxy_endpoint.as_str());
|
let spawned = std::thread::Builder::new()
|
||||||
// Resolve each stored subscription-OAuth session's bearer (refreshed
|
.name("early-prefetch".into())
|
||||||
// on expiry) so those platforms are part of the STARTUP fetch plan —
|
.spawn(move || {
|
||||||
// otherwise a claude-pro-max-only user boots onto the bundled Kimi
|
let mut timer = crate::instrumentation_timer!("startup.early_prefetch");
|
||||||
// table until some later async refresh happens to run, and the cache
|
let proxy_endpoint = env.endpoints.proxy_url();
|
||||||
// origin (which encodes enabled platforms) never matches the
|
timer.with_field("endpoint", proxy_endpoint.as_str());
|
||||||
// async-path's claude-inclusive origin.
|
// Resolve each stored subscription-OAuth session's bearer (refreshed
|
||||||
let oauth_tokens = tokio::runtime::Builder::new_current_thread()
|
// on expiry) so those platforms are part of the STARTUP fetch plan —
|
||||||
.enable_all()
|
// otherwise a claude-pro-max-only user boots onto the bundled Kimi
|
||||||
.build()
|
// table until some later async refresh happens to run, and the cache
|
||||||
.map(|rt| {
|
// origin (which encodes enabled platforms) never matches the
|
||||||
rt.block_on(crate::agent::models_fetch::resolve_generic_oauth_tokens(
|
// async-path's claude-inclusive origin.
|
||||||
&crate::auth::oauth_registry::pool_home(),
|
let oauth_tokens = tokio::runtime::Builder::new_current_thread()
|
||||||
))
|
|
||||||
})
|
|
||||||
.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()
|
.enable_all()
|
||||||
.build()
|
.build()
|
||||||
{
|
.map(|rt| {
|
||||||
crate::managed_config::clear_orphan();
|
rt.block_on(crate::agent::models_fetch::resolve_generic_oauth_tokens(
|
||||||
let _ = rt.block_on(crate::managed_config::sync());
|
&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.
|
/// 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
|
/// short-circuits BEFORE any HTTP — there is NO mock `/models` server, yet
|
||||||
/// the fetch returns exactly the 4 compiled-in models keyed
|
/// the fetch returns exactly the 4 compiled-in models keyed
|
||||||
/// `openai-codex/<slug>` on the Responses backend, ctx 272000, each exposing
|
/// `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
|
/// A BOGUS base URL confirms no live `/models` request is attempted (it would
|
||||||
/// otherwise fail against an unroutable host).
|
/// otherwise fail against an unroutable host).
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
@@ -1556,10 +1556,10 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|o| o.id.as_str())
|
.map(|o| o.id.as_str())
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec!["low", "medium", "high", "xhigh", "max", "ultra"],
|
vec!["low", "medium", "high", "xhigh", "max"],
|
||||||
"sol exposes the full codex effort menu incl. ultra"
|
"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
|
let five_five = result
|
||||||
.models
|
.models
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -1725,8 +1725,8 @@ impl acp::Agent for MvpAgent {
|
|||||||
return Ok(acp::PromptResponse::new(acp::StopReason::EndTurn));
|
return Ok(acp::PromptResponse::new(acp::StopReason::EndTurn));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let intake_lock = self.prompt_intake_lock(&arguments.session_id);
|
let dispatch_lock = self.dispatch_lock(&arguments.session_id);
|
||||||
let intake_guard = intake_lock.lock().await;
|
let dispatch_guard = dispatch_lock.lock().await;
|
||||||
let meta_prompt_mode = arguments
|
let meta_prompt_mode = arguments
|
||||||
.meta
|
.meta
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1830,7 +1830,7 @@ impl acp::Agent for MvpAgent {
|
|||||||
acp::Error::internal_error()
|
acp::Error::internal_error()
|
||||||
.data(format!("failed to dispatch prompt to session: {e}"))
|
.data(format!("failed to dispatch prompt to session: {e}"))
|
||||||
})?;
|
})?;
|
||||||
drop(intake_guard);
|
drop(dispatch_guard);
|
||||||
self.push_roster_activity_delta(
|
self.push_roster_activity_delta(
|
||||||
&arguments.session_id,
|
&arguments.session_id,
|
||||||
crate::agent::roster::RosterActivity::Working,
|
crate::agent::roster::RosterActivity::Working,
|
||||||
@@ -2025,6 +2025,11 @@ impl acp::Agent for MvpAgent {
|
|||||||
.and_then(|m| m.get("rewindIfPristine"))
|
.and_then(|m| m.get("rewindIfPristine"))
|
||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
.unwrap_or(false);
|
.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
|
let _ = handle
|
||||||
.cmd_tx
|
.cmd_tx
|
||||||
.send(SessionCommand::Cancel {
|
.send(SessionCommand::Cancel {
|
||||||
|
|||||||
@@ -357,6 +357,11 @@ impl MvpAgent {
|
|||||||
// time, so advertise pre-session; the in-session path
|
// time, so advertise pre-session; the in-session path
|
||||||
// re-checks the live toolset.
|
// re-checks the live toolset.
|
||||||
graph: goal && self.cfg.borrow().resolve_graph().value,
|
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()
|
..crate::session::slash_commands::CommandAvailability::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -973,7 +978,10 @@ impl MvpAgent {
|
|||||||
sessions: RefCell::new(HashMap::new()),
|
sessions: RefCell::new(HashMap::new()),
|
||||||
activity,
|
activity,
|
||||||
loading_sessions: RefCell::new(HashMap::new()),
|
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()),
|
session_threads: RefCell::new(HashMap::new()),
|
||||||
resident_roster_titles: RefCell::new(HashMap::new()),
|
resident_roster_titles: RefCell::new(HashMap::new()),
|
||||||
initialize_request: OnceLock::new(),
|
initialize_request: OnceLock::new(),
|
||||||
@@ -1357,7 +1365,8 @@ impl MvpAgent {
|
|||||||
/// `FlushAndAck` on the persistence actor — a true sync barrier that only
|
/// `FlushAndAck` on the persistence actor — a true sync barrier that only
|
||||||
/// resolves after all queued writes (chat messages, updates) hit disk.
|
/// 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(
|
pub(crate) async fn flush_session(
|
||||||
&self,
|
&self,
|
||||||
session_id: &acp::SessionId,
|
session_id: &acp::SessionId,
|
||||||
@@ -1376,7 +1385,11 @@ impl MvpAgent {
|
|||||||
return Err("send failed");
|
return Err("send failed");
|
||||||
}
|
}
|
||||||
match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await {
|
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"),
|
Ok(Err(_)) => Err("channel closed"),
|
||||||
Err(_) => Err("timeout"),
|
Err(_) => Err("timeout"),
|
||||||
}
|
}
|
||||||
@@ -2178,6 +2191,7 @@ impl MvpAgent {
|
|||||||
.cfg
|
.cfg
|
||||||
.borrow()
|
.borrow()
|
||||||
.resolve_compaction_verbatim_input();
|
.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 two_pass_enabled = self.cfg.borrow().is_two_pass_compaction_enabled();
|
||||||
let auto_update = self.cfg.borrow().cli.auto_update;
|
let auto_update = self.cfg.borrow().cli.auto_update;
|
||||||
let client_type = *self.client_type.borrow();
|
let client_type = *self.client_type.borrow();
|
||||||
@@ -2563,6 +2577,7 @@ impl MvpAgent {
|
|||||||
system_prompt_label,
|
system_prompt_label,
|
||||||
compaction_mode,
|
compaction_mode,
|
||||||
compaction_verbatim_input,
|
compaction_verbatim_input,
|
||||||
|
compaction_tool_choice,
|
||||||
two_pass_enabled,
|
two_pass_enabled,
|
||||||
buffering_settings,
|
buffering_settings,
|
||||||
origin_client.clone(),
|
origin_client.clone(),
|
||||||
|
|||||||
@@ -462,21 +462,30 @@ pub struct MvpAgent {
|
|||||||
loading_sessions: RefCell<
|
loading_sessions: RefCell<
|
||||||
HashMap<acp::SessionId, tokio::sync::watch::Receiver<bool>>,
|
HashMap<acp::SessionId, tokio::sync::watch::Receiver<bool>>,
|
||||||
>,
|
>,
|
||||||
/// Per-session prompt-intake serialization lock. LEADER-SAFE(per-session):
|
/// Per-session lock ordering dispatch onto the actor's mailbox:
|
||||||
/// keyed by SessionId, mirrors `sessions` lifecycle.
|
/// [`Self::prompt`] holds it across its intake preamble (prompt-mode
|
||||||
///
|
/// query, trace context, model lookup) and releases it right after the
|
||||||
/// Each incoming `session/prompt` RPC is dispatched as its own task by the
|
/// enqueue, so prompts land in submission order; `cancel` holds it
|
||||||
/// ACP message loop, and [`Self::prompt`] runs an async preamble (prompt-mode
|
/// around its `Cancel` send, so a cancel cannot overtake the prompt it
|
||||||
/// query, trace context, model lookup) BEFORE it enqueues
|
/// targets and wedge the turn slot (see
|
||||||
/// `SessionCommand::Prompt` onto the actor's FIFO mailbox. Without
|
/// `cancel_never_overtakes_in_flight_prompt_intake`). Cancels wait out
|
||||||
/// serialization those preambles interleave across tasks, so the mailbox —
|
/// preambles held ahead of them — keep preambles lean.
|
||||||
/// and therefore the authoritative prompt queue — receives prompts out of
|
/// LEADER-SAFE(per-session): mirrors `sessions` lifecycle.
|
||||||
/// submission order. `prompt()` holds this lock across the preamble and
|
dispatch_locks: RefCell<
|
||||||
/// releases it immediately after the enqueue (the turn itself runs unlocked),
|
|
||||||
/// which makes intake order match arrival order.
|
|
||||||
prompt_intake_locks: RefCell<
|
|
||||||
HashMap<acp::SessionId, std::rc::Rc<tokio::sync::Mutex<()>>>,
|
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.
|
/// LEADER-SAFE(per-session): keyed by SessionId. Mirrors `sessions` lifecycle.
|
||||||
session_threads: RefCell<HashMap<acp::SessionId, SessionThread>>,
|
session_threads: RefCell<HashMap<acp::SessionId, SessionThread>>,
|
||||||
/// Title per resident session id, refreshed each `build_roster`. Lets the
|
/// 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
|
/// sampling client is discarded, but the TLS root certificates it loads
|
||||||
/// are cached at the process level by `rustls-native-certs`.
|
/// are cached at the process level by `rustls-native-certs`.
|
||||||
pub fn warm_async_http_client() {
|
pub fn warm_async_http_client() {
|
||||||
std::thread::spawn(|| {
|
// Recoverable spawn: the warmup is an optimization that runs before the
|
||||||
let _timer = crate::instrumentation_timer!("startup.async_http_warmup");
|
// runtime builds — an out-of-threads host must reach the runtime's own
|
||||||
let _ = crate::http::shared_client();
|
// 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(
|
pub(crate) fn resolve_required_agent_type(
|
||||||
model_agent_type: Option<&str>,
|
model_agent_type: Option<&str>,
|
||||||
|
|||||||
@@ -38,19 +38,16 @@ impl MvpAgent {
|
|||||||
/// drops the `session_live_state` entry so that map stays bounded.
|
/// drops the `session_live_state` entry so that map stays bounded.
|
||||||
pub(crate) fn remove_session(&self, id: &acp::SessionId) {
|
pub(crate) fn remove_session(&self, id: &acp::SessionId) {
|
||||||
self.sessions.borrow_mut().remove(id);
|
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_threads.borrow_mut().remove(id);
|
||||||
self.session_index_claims.borrow_mut().remove(id);
|
self.session_index_claims.borrow_mut().remove(id);
|
||||||
self.require_gateway_sessions.borrow_mut().remove(id);
|
self.require_gateway_sessions.borrow_mut().remove(id);
|
||||||
self.session_live_state.borrow_mut().remove(id);
|
self.session_live_state.borrow_mut().remove(id);
|
||||||
}
|
}
|
||||||
/// Get-or-create the per-session prompt-intake lock (see
|
/// Get-or-create the per-session dispatch lock (see
|
||||||
/// [`Self::prompt_intake_locks`]).
|
/// [`Self::dispatch_locks`]). Cheap clone of the shared `Rc`.
|
||||||
pub(super) fn prompt_intake_lock(
|
pub(super) fn dispatch_lock(&self, id: &acp::SessionId) -> std::rc::Rc<tokio::sync::Mutex<()>> {
|
||||||
&self,
|
self.dispatch_locks
|
||||||
id: &acp::SessionId,
|
|
||||||
) -> std::rc::Rc<tokio::sync::Mutex<()>> {
|
|
||||||
self.prompt_intake_locks
|
|
||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.entry(id.clone())
|
.entry(id.clone())
|
||||||
.or_default()
|
.or_default()
|
||||||
|
|||||||
@@ -272,6 +272,19 @@ impl MvpAgent {
|
|||||||
self.try_build_subagent_spawn_context(parent_session_id)
|
self.try_build_subagent_spawn_context(parent_session_id)
|
||||||
.expect("parent session must exist when spawning subagents")
|
.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
|
/// Fallible variant of [`Self::build_subagent_spawn_context`]: returns
|
||||||
/// `None` when the parent `SessionHandle` is absent (evicted / torn down)
|
/// `None` when the parent `SessionHandle` is absent (evicted / torn down)
|
||||||
/// instead of panicking, so read-only paths that can race a teardown can
|
/// instead of panicking, so read-only paths that can race a teardown can
|
||||||
@@ -387,6 +400,8 @@ impl MvpAgent {
|
|||||||
};
|
};
|
||||||
Some(crate::agent::subagent::SubagentSpawnContext {
|
Some(crate::agent::subagent::SubagentSpawnContext {
|
||||||
lsp: parent_lsp,
|
lsp: parent_lsp,
|
||||||
|
spawn_limits: self.subagent_limits,
|
||||||
|
spawn_permits: self.subagent_spawn_permit(parent_session_id),
|
||||||
gateway: self.gateway.clone(),
|
gateway: self.gateway.clone(),
|
||||||
client_hooks: Default::default(),
|
client_hooks: Default::default(),
|
||||||
sampling_config: self.sampling_config.borrow().clone(),
|
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;
|
use crate::session::SessionCommand as TestSessionCommand;
|
||||||
/// Build a session handle wired to a *live* command channel. Returns the
|
/// 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
|
/// 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(
|
kigi_workspace::permission::spawn_permission_manager(
|
||||||
sid.clone(),
|
sid.clone(),
|
||||||
gateway,
|
gateway,
|
||||||
cwd,
|
cwd.clone(),
|
||||||
kigi_workspace::permission::types::ClientType::Generic,
|
kigi_workspace::permission::types::ClientType::Generic,
|
||||||
Some(PermissionConfig::new(vec![PermissionRule {
|
Some(PermissionConfig::new(vec![PermissionRule {
|
||||||
action: RuleAction::Deny,
|
action: RuleAction::Deny,
|
||||||
@@ -64,6 +64,8 @@ async fn subagent_spawn_context_inherits_parent_permission_handle() {
|
|||||||
Some("child-session".to_owned()),
|
Some("child-session".to_owned()),
|
||||||
Some("general-purpose".to_owned()),
|
Some("general-purpose".to_owned()),
|
||||||
Some("permission inheritance regression".to_owned()),
|
Some("permission inheritance regression".to_owned()),
|
||||||
|
cwd.clone(),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ fn effort_label(effort: ReasoningEffort) -> String {
|
|||||||
ReasoningEffort::High => "High",
|
ReasoningEffort::High => "High",
|
||||||
ReasoningEffort::Xhigh => "X-High",
|
ReasoningEffort::Xhigh => "X-High",
|
||||||
ReasoningEffort::Max => "Max",
|
ReasoningEffort::Max => "Max",
|
||||||
ReasoningEffort::Ultra => "Ultra",
|
|
||||||
}
|
}
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,99 @@ use kigi_tools::implementations::kigi::task::types::*;
|
|||||||
use kigi_workspace::file_system::AsyncFileSystem;
|
use kigi_workspace::file_system::AsyncFileSystem;
|
||||||
use kigi_hunk_tracker::HunkTrackerHandle;
|
use kigi_hunk_tracker::HunkTrackerHandle;
|
||||||
use super::*;
|
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(
|
pub(super) fn task_model_override_error(
|
||||||
requested: Option<&str>,
|
requested: Option<&str>,
|
||||||
provenance: ModelOverrideProvenance,
|
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
|
let run_in_background = request.run_in_background
|
||||||
|| definition.background.unwrap_or(false);
|
|| definition.background.unwrap_or(false);
|
||||||
let cancel_token = CancellationToken::new();
|
let cancel_token = CancellationToken::new();
|
||||||
@@ -396,11 +547,14 @@ pub(crate) async fn handle_subagent_request(
|
|||||||
let child_depth = ctx.parent_depth + 1;
|
let child_depth = ctx.parent_depth + 1;
|
||||||
if child_depth >= MAX_SUBAGENT_DEPTH {
|
if child_depth >= MAX_SUBAGENT_DEPTH {
|
||||||
let before = definition.tool_config.tools.len();
|
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 {
|
if definition.tool_config.tools.len() < before {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
subagent_id = % request.id, child_depth, max_depth =
|
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);
|
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 {
|
let agent_mcp_servers: Vec<_> = if definition.mcp_servers.is_empty() {
|
||||||
if !definition.mcp_servers.is_empty() {
|
vec![]
|
||||||
tracing::warn!(
|
} else if is_plugin_agent {
|
||||||
agent = % definition.name, plugin = ? definition.plugin_name,
|
tracing::warn!(
|
||||||
"ignoring mcpServers on plugin agent (not supported for security)"
|
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![]
|
vec![]
|
||||||
} else {
|
} else {
|
||||||
definition
|
definition
|
||||||
@@ -1003,6 +1166,7 @@ pub(crate) async fn handle_subagent_request(
|
|||||||
kigi_agent::DEFAULT_SYSTEM_PROMPT_LABEL.to_string(),
|
kigi_agent::DEFAULT_SYSTEM_PROMPT_LABEL.to_string(),
|
||||||
kigi_chat_state::CompactionMode::Summary,
|
kigi_chat_state::CompactionMode::Summary,
|
||||||
ctx.resolve_compaction_verbatim_input(),
|
ctx.resolve_compaction_verbatim_input(),
|
||||||
|
ctx.resolve_compaction_tool_choice(),
|
||||||
false,
|
false,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -1271,7 +1435,12 @@ pub(crate) async fn handle_subagent_request(
|
|||||||
let duration_ms = start.elapsed().as_millis() as u64;
|
let duration_ms = start.elapsed().as_millis() as u64;
|
||||||
let mut result = match wait_outcome {
|
let mut result = match wait_outcome {
|
||||||
SubagentWaitOutcome::Cancelled => {
|
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 {
|
SubagentResult {
|
||||||
success: false,
|
success: false,
|
||||||
cancelled: true,
|
cancelled: true,
|
||||||
@@ -1298,14 +1467,28 @@ pub(crate) async fn handle_subagent_request(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
) => (snapshot.current.tool_call_count, snapshot.current.turn_count),
|
) => (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
|
let final_text = child_actor_query(
|
||||||
.chat_state_handle
|
"get_last_assistant_text",
|
||||||
.get_last_assistant_text()
|
child_handle.chat_state_handle.get_last_assistant_text(),
|
||||||
.await
|
None,
|
||||||
.unwrap_or_default();
|
)
|
||||||
let result_tokens = child_handle.chat_state_handle.get_total_tokens().await;
|
.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 {
|
match *turn_result {
|
||||||
Ok(
|
Ok(
|
||||||
Ok(
|
Ok(
|
||||||
@@ -1322,6 +1505,7 @@ pub(crate) async fn handle_subagent_request(
|
|||||||
SubagentResult {
|
SubagentResult {
|
||||||
success: false,
|
success: false,
|
||||||
cancelled: true,
|
cancelled: true,
|
||||||
|
rate_limited: false,
|
||||||
error: Some(reason),
|
error: Some(reason),
|
||||||
output: if final_text.is_empty() {
|
output: if final_text.is_empty() {
|
||||||
std::sync::Arc::from(
|
std::sync::Arc::from(
|
||||||
@@ -1359,6 +1543,7 @@ pub(crate) async fn handle_subagent_request(
|
|||||||
SubagentResult {
|
SubagentResult {
|
||||||
success: false,
|
success: false,
|
||||||
cancelled: true,
|
cancelled: true,
|
||||||
|
rate_limited: false,
|
||||||
error: Some(format!("max turns reached (limit: {limit})")),
|
error: Some(format!("max turns reached (limit: {limit})")),
|
||||||
output: if final_text.is_empty() {
|
output: if final_text.is_empty() {
|
||||||
std::sync::Arc::from(
|
std::sync::Arc::from(
|
||||||
@@ -1413,6 +1598,9 @@ pub(crate) async fn handle_subagent_request(
|
|||||||
SubagentResult {
|
SubagentResult {
|
||||||
success: false,
|
success: false,
|
||||||
cancelled: was_cancelled,
|
cancelled: was_cancelled,
|
||||||
|
rate_limited: !was_cancelled
|
||||||
|
&& i32::from(e.code)
|
||||||
|
== crate::sampling::error::RATE_LIMITED_ERROR_CODE,
|
||||||
error: Some(
|
error: Some(
|
||||||
if was_cancelled {
|
if was_cancelled {
|
||||||
"Subagent was cancelled".to_string()
|
"Subagent was cancelled".to_string()
|
||||||
@@ -1460,14 +1648,21 @@ pub(crate) async fn handle_subagent_request(
|
|||||||
let final_status = result.status().to_string();
|
let final_status = result.status().to_string();
|
||||||
let snapshot_dispose_enabled = ctx.resolve_subagent_worktree_snapshot_enabled();
|
let snapshot_dispose_enabled = ctx.resolve_subagent_worktree_snapshot_enabled();
|
||||||
let telemetry_tokens = if result.tool_calls > 0 || result.success {
|
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 {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
let (subagent_usage_by_model, subagent_usage_incomplete) = match child_handle
|
let (subagent_usage_by_model, subagent_usage_incomplete) = match child_actor_query(
|
||||||
.chat_state_handle
|
"try_get_session_usage",
|
||||||
.try_get_session_usage()
|
child_handle.chat_state_handle.try_get_session_usage(),
|
||||||
.await
|
Err(()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
Ok(u) => (Some(u.by_model.into_iter().collect::<Vec<_>>()), u.incomplete),
|
Ok(u) => (Some(u.by_model.into_iter().collect::<Vec<_>>()), u.incomplete),
|
||||||
Err(()) => (None, true),
|
Err(()) => (None, true),
|
||||||
@@ -1486,7 +1681,7 @@ pub(crate) async fn handle_subagent_request(
|
|||||||
respond_to,
|
respond_to,
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
Ok(()) => ack.await.is_ok(),
|
Ok(()) => parent_ack("RecordSubagentUsage", ack).await,
|
||||||
Err(_) => false,
|
Err(_) => false,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1503,53 +1698,71 @@ pub(crate) async fn handle_subagent_request(
|
|||||||
.parent_prompt_id
|
.parent_prompt_id
|
||||||
.clone()
|
.clone()
|
||||||
.or_else(|| coordinator.borrow().parent_prompt_id_for(&request.id));
|
.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();
|
let (respond_to, ack) = tokio::sync::oneshot::channel();
|
||||||
if cmd_tx
|
cmd_tx
|
||||||
.send(crate::session::commands::SessionCommand::MarkSubagentUsageNotApplied {
|
.send(crate::session::commands::SessionCommand::MarkSubagentUsageNotApplied {
|
||||||
parent_prompt_id: sticky_prompt,
|
parent_prompt_id: sticky_prompt.clone(),
|
||||||
respond_to,
|
respond_to,
|
||||||
})
|
})
|
||||||
.is_ok()
|
.is_ok()
|
||||||
{
|
&& parent_ack("MarkSubagentUsageNotApplied", ack).await
|
||||||
let _ = ack.await;
|
} else {
|
||||||
}
|
false
|
||||||
} else if let Some(ref pid) = sticky_prompt {
|
};
|
||||||
|
if !marked_by_parent && let Some(ref pid) = sticky_prompt {
|
||||||
coordinator.borrow_mut().mark_subagent_usage_not_applied(pid);
|
coordinator.borrow_mut().mark_subagent_usage_not_applied(pid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match (&ctx.parent_terminal_backend, &ctx.parent_notification_handle) {
|
match (&ctx.parent_terminal_backend, &ctx.parent_notification_handle) {
|
||||||
(Some(parent_tb), Some(parent_notif_handle)) => {
|
(Some(parent_tb), Some(parent_notif_handle)) => {
|
||||||
if !request.surface_completion {
|
if !request.surface_completion {
|
||||||
let reparented_task_ids: Vec<String> = parent_tb
|
match tokio::time::timeout(PARENT_ACK_TIMEOUT, parent_tb.list_tasks()).await {
|
||||||
.list_tasks()
|
Ok(tasks) => {
|
||||||
.await
|
let reparented_task_ids: Vec<String> = tasks
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|t| {
|
.filter(|t| {
|
||||||
!t.completed
|
!t.completed
|
||||||
&& t.owner_session_id.as_deref()
|
&& t.owner_session_id.as_deref()
|
||||||
== Some(&*child_session_id.0)
|
== Some(&*child_session_id.0)
|
||||||
})
|
})
|
||||||
.map(|t| t.task_id)
|
.map(|t| t.task_id)
|
||||||
.collect();
|
.collect();
|
||||||
if !reparented_task_ids.is_empty()
|
if !reparented_task_ids.is_empty()
|
||||||
&& let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref()
|
&& let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref()
|
||||||
{
|
{
|
||||||
let _ = cmd_tx
|
let _ = cmd_tx.send(SessionCommand::RecordGoalTurnTaskIds {
|
||||||
.send(SessionCommand::RecordGoalTurnTaskIds {
|
task_ids: reparented_task_ids,
|
||||||
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);
|
let parent_backend_weak = std::sync::Arc::downgrade(parent_tb);
|
||||||
parent_tb
|
if tokio::time::timeout(
|
||||||
.reparent_notifications(
|
PARENT_ACK_TIMEOUT,
|
||||||
|
parent_tb.reparent_notifications(
|
||||||
&child_session_id.0,
|
&child_session_id.0,
|
||||||
&ctx.parent_session_id,
|
&ctx.parent_session_id,
|
||||||
parent_notif_handle.clone(),
|
parent_notif_handle.clone(),
|
||||||
parent_backend_weak,
|
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(_)) => {
|
(Some(_), None) | (None, Some(_)) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -1570,55 +1783,17 @@ pub(crate) async fn handle_subagent_request(
|
|||||||
if snapshot_dispose_enabled {
|
if snapshot_dispose_enabled {
|
||||||
let ref_name = format!("refs/kigi/subagents/{}", request.id);
|
let ref_name = format!("refs/kigi/subagents/{}", request.id);
|
||||||
let source_repo = resolve_subagent_source_repo(&ctx);
|
let source_repo = resolve_subagent_source_repo(&ctx);
|
||||||
match crate::session::worktree::snapshot_subagent_worktree(
|
let (snapshot_ref, removed) = dispose_subagent_worktree(
|
||||||
wt_path,
|
wt_path,
|
||||||
&source_repo,
|
&source_repo,
|
||||||
&ref_name,
|
&ref_name,
|
||||||
)
|
&subagent_meta_dir,
|
||||||
.await
|
&final_status,
|
||||||
{
|
&request.id,
|
||||||
Ok(snapshot_ref) => {
|
)
|
||||||
let persisted = update_subagent_meta_snapshot_ref(
|
.await;
|
||||||
&subagent_meta_dir,
|
disposed_snapshot_ref = snapshot_ref;
|
||||||
&snapshot_ref,
|
worktree_removed = removed;
|
||||||
&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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
subagent_id = % request.id, worktree_path = % wt_path.display(),
|
subagent_id = % request.id, worktree_path = % wt_path.display(),
|
||||||
|
|||||||
@@ -133,6 +133,13 @@ impl AutoCompactThresholdTiers {
|
|||||||
pub(crate) struct SubagentSpawnContext {
|
pub(crate) struct SubagentSpawnContext {
|
||||||
/// Parent's LSP runtime — inherited via ToolContext, same as fs/terminal.
|
/// Parent's LSP runtime — inherited via ToolContext, same as fs/terminal.
|
||||||
pub lsp: Option<std::sync::Arc<dyn kigi_tools::implementations::lsp::LspBackend>>,
|
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(
|
#[expect(
|
||||||
dead_code,
|
dead_code,
|
||||||
reason = "unused in production; remove expect when wired or delete the item"
|
reason = "unused in production; remove expect when wired or delete the item"
|
||||||
@@ -397,6 +404,21 @@ impl SubagentSpawnContext {
|
|||||||
.resolve()
|
.resolve()
|
||||||
.value
|
.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
|
/// Whether a completed subagent's worktree is snapshotted into a durable ref
|
||||||
/// and its directory deleted. Resolution mirrors the other subagent gates
|
/// and its directory deleted. Resolution mirrors the other subagent gates
|
||||||
/// (env > config > remote settings > default). Default `false` so it ships dark;
|
/// (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;
|
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
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -253,6 +282,38 @@ mod tests {
|
|||||||
"snapshot must reflect refreshed token for subsequent apply() calls"
|
"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).
|
/// Deployment-key path has no recovery (operator owns the bearer).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn refresh_after_unauthorized_is_noop_for_deployment_key() {
|
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> {
|
pub fn read_auth_json(auth_file: &Path) -> std::io::Result<AuthStore> {
|
||||||
let mut file = File::open(auth_file)?;
|
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();
|
let mut contents = String::new();
|
||||||
file.read_to_string(&mut contents)?;
|
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) {
|
match std::fs::rename(path, &backup) {
|
||||||
Ok(()) => {
|
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!(
|
tracing::warn!(
|
||||||
original = %path.display(),
|
original = %path.display(),
|
||||||
backup = %backup.display(),
|
backup = %backup.display(),
|
||||||
@@ -421,18 +441,59 @@ fn write_store_to(path: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
|
|||||||
.into_inner()
|
.into_inner()
|
||||||
.map_err(|e| e.into_error())?
|
.map_err(|e| e.into_error())?
|
||||||
.sync_all()?;
|
.sync_all()?;
|
||||||
#[cfg(windows)]
|
// `open_secure_file` mode bits apply only on create; tighten existing
|
||||||
{
|
// paths. Best-effort after durable content: a chmod-only failure must not
|
||||||
crate::util::secure_file::set_windows_secure_permissions(path)?;
|
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomic write: tmp + Windows-safe replace (see `util::fs::replace_file`).
|
/// 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<()> {
|
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_auth_json_atomic_with(auth_file, auth_store, write_store_to)
|
||||||
write_store_to(&tmp, auth_store)?;
|
}
|
||||||
crate::util::fs::replace_file(&tmp, auth_file)
|
|
||||||
|
/// 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.
|
/// 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)?;
|
let mut file = open_secure_file(auth_file)?;
|
||||||
file.write_all(bytes)?;
|
file.write_all(bytes)?;
|
||||||
file.sync_all()?;
|
file.sync_all()?;
|
||||||
#[cfg(windows)]
|
crate::util::secure_file::ensure_owner_only_permissions(auth_file)?;
|
||||||
{
|
|
||||||
crate::util::secure_file::set_windows_secure_permissions(auth_file)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
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)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn in_place_restore_is_owner_only() {
|
fn in_place_restore_is_owner_only() {
|
||||||
@@ -812,9 +950,13 @@ mod write_fallback_tests {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let path = dir.path().join("auth.json");
|
let path = dir.path().join("auth.json");
|
||||||
write_auth_json_in_place(&path, &sample_store()).unwrap();
|
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 _ = write_auth_json_in_place_with(&path, &sample_store(), fake_truncate_then_fail);
|
||||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
|
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())
|
.and_then(|p| dunce::canonicalize(p).ok())
|
||||||
.or_else(|| std::env::current_dir().ok())
|
.or_else(|| std::env::current_dir().ok())
|
||||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
.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")]
|
#[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")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
let refuse_unprotected = |detail: &str| {
|
let refuse_unprotected = |detail: &str| {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"error: this sandbox could not enforce its read-deny set on Linux \
|
"error: this sandbox could not enforce its read-deny set or the devbox \
|
||||||
(bubblewrap missing/unusable, or a deny glob exceeded its expansion \
|
`/data` write-deny on Linux (bubblewrap missing/unusable, or a deny glob \
|
||||||
limit — see any message above). Install bubblewrap with \
|
exceeded its expansion limit — see any message above). Install bubblewrap \
|
||||||
`apt install -y bubblewrap` if needed. Refusing to start with denied \
|
with `apt install -y bubblewrap` if needed. Refusing to start with denied \
|
||||||
paths unprotected.{detail}"
|
paths unprotected.{detail}"
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1051,7 +1062,7 @@ pub fn apply_sandbox(
|
|||||||
Some(mut cmd) => {
|
Some(mut cmd) => {
|
||||||
use std::os::unix::process::CommandExt;
|
use std::os::unix::process::CommandExt;
|
||||||
let err = cmd.exec();
|
let err = cmd.exec();
|
||||||
if requires_read_deny {
|
if bwrap_required {
|
||||||
refuse_unprotected(&format!(" (bwrap exec failed: {err})"));
|
refuse_unprotected(&format!(" (bwrap exec failed: {err})"));
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
@@ -1061,7 +1072,7 @@ pub fn apply_sandbox(
|
|||||||
Install bubblewrap: apt install -y bubblewrap"
|
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("");
|
refuse_unprotected("");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ async fn handle_compact(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
|||||||
respond_to: tx,
|
respond_to: tx,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// The session's own ACP error goes through unchanged: a Debug wrapper would escape its detail.
|
||||||
rx.await
|
rx.await
|
||||||
.map_err(|_| acp::Error::internal_error().data("session failed to respond"))?
|
.map_err(|_| acp::Error::internal_error().data("session failed to respond"))??;
|
||||||
.map_err(|e| acp::Error::internal_error().data(format!("Internal error: {:?}", e)))?;
|
|
||||||
to_raw_response(&CompactConversationResponse {})
|
to_raw_response(&CompactConversationResponse {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -691,6 +691,11 @@ pub enum SessionUpdate {
|
|||||||
/// does not support reasoning effort or no effort override was applied.
|
/// does not support reasoning effort or no effort override was applied.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
reasoning_effort: Option<String>,
|
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.
|
/// Streaming chunk of a tool call's arguments.
|
||||||
///
|
///
|
||||||
@@ -1970,15 +1975,18 @@ mod tests {
|
|||||||
let with_effort = SessionUpdate::ModelChanged {
|
let with_effort = SessionUpdate::ModelChanged {
|
||||||
model_id: "kigi-4".into(),
|
model_id: "kigi-4".into(),
|
||||||
reasoning_effort: Some("high".into()),
|
reasoning_effort: Some("high".into()),
|
||||||
|
context_window: Some(262_144),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_value(&with_effort).unwrap();
|
let json = serde_json::to_value(&with_effort).unwrap();
|
||||||
assert_eq!(json["sessionUpdate"], "model_changed");
|
assert_eq!(json["sessionUpdate"], "model_changed");
|
||||||
assert_eq!(json["model_id"], "kigi-4");
|
assert_eq!(json["model_id"], "kigi-4");
|
||||||
assert_eq!(json["reasoning_effort"], "high");
|
assert_eq!(json["reasoning_effort"], "high");
|
||||||
|
assert_eq!(json["context_window"], 262_144);
|
||||||
|
|
||||||
let without_effort = SessionUpdate::ModelChanged {
|
let without_effort = SessionUpdate::ModelChanged {
|
||||||
model_id: "kigi-3".into(),
|
model_id: "kigi-3".into(),
|
||||||
reasoning_effort: None,
|
reasoning_effort: None,
|
||||||
|
context_window: None,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_value(&without_effort).unwrap();
|
let json = serde_json::to_value(&without_effort).unwrap();
|
||||||
assert_eq!(json["sessionUpdate"], "model_changed");
|
assert_eq!(json["sessionUpdate"], "model_changed");
|
||||||
@@ -1988,6 +1996,7 @@ mod tests {
|
|||||||
"reasoning_effort: None must be skipped on the wire so old pagers \
|
"reasoning_effort: None must be skipped on the wire so old pagers \
|
||||||
and third-party ACP clients see a smaller, no-extra-keys payload"
|
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
|
/// `ModelChanged` round-trips through JSON: a follower client deserializes
|
||||||
@@ -2000,6 +2009,7 @@ mod tests {
|
|||||||
let original = SessionUpdate::ModelChanged {
|
let original = SessionUpdate::ModelChanged {
|
||||||
model_id: "kigi-4".into(),
|
model_id: "kigi-4".into(),
|
||||||
reasoning_effort: Some("medium".into()),
|
reasoning_effort: Some("medium".into()),
|
||||||
|
context_window: Some(1_000_000),
|
||||||
};
|
};
|
||||||
let json_str = serde_json::to_string(&original).unwrap();
|
let json_str = serde_json::to_string(&original).unwrap();
|
||||||
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
|
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
|
||||||
@@ -2021,6 +2031,7 @@ mod tests {
|
|||||||
update: SessionUpdate::ModelChanged {
|
update: SessionUpdate::ModelChanged {
|
||||||
model_id: "kigi-4".into(),
|
model_id: "kigi-4".into(),
|
||||||
reasoning_effort: None,
|
reasoning_effort: None,
|
||||||
|
context_window: None,
|
||||||
},
|
},
|
||||||
meta: None,
|
meta: None,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,10 +52,16 @@ pub fn default_lock_path_in(root: &Path) -> PathBuf {
|
|||||||
root.join("leader.lock")
|
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
|
/// 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 {
|
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 {
|
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,
|
/// 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 {
|
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
|
/// 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> {
|
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()
|
Ok(OpenOptions::new()
|
||||||
.read(true)
|
.read(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
//! │ │ - Tracks session ownership for routing ││
|
//! │ │ - 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
|
entries
|
||||||
}
|
}
|
||||||
pub async fn discover_leaders() -> Vec<LeaderDescriptor> {
|
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
|
/// (pid, leader_binary_version) of socket-verified (Reachable) leaders; a
|
||||||
/// stale-lock-only descriptor is skipped (its `pid_from_lock` may be recycled).
|
/// 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(
|
fn resolve_target_from_descriptors(
|
||||||
target: LeaderTarget,
|
target: LeaderTarget,
|
||||||
leaders: Vec<LeaderDescriptor>,
|
leaders: Vec<LeaderDescriptor>,
|
||||||
|
current_default_socket: &Path,
|
||||||
) -> Result<LeaderTargetSelection, LeaderTargetError> {
|
) -> Result<LeaderTargetSelection, LeaderTargetError> {
|
||||||
match target {
|
match target {
|
||||||
LeaderTarget::Default => {
|
LeaderTarget::Default => {
|
||||||
let matching: Vec<_> = leaders
|
let defaults: Vec<_> = leaders
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|descriptor| descriptor.is_default)
|
.filter(|descriptor| descriptor.is_default)
|
||||||
.collect();
|
.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)
|
resolve_matching_descriptors("default", matching)
|
||||||
}
|
}
|
||||||
LeaderTarget::Socket(socket_path) => {
|
LeaderTarget::Socket(socket_path) => {
|
||||||
@@ -685,7 +703,7 @@ pub async fn resolve_leader_target(
|
|||||||
target: LeaderTarget,
|
target: LeaderTarget,
|
||||||
) -> Result<LeaderTargetSelection, LeaderTargetError> {
|
) -> Result<LeaderTargetSelection, LeaderTargetError> {
|
||||||
let leaders = discover_leaders().await;
|
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)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ConnectionError {
|
pub enum ConnectionError {
|
||||||
@@ -1315,7 +1333,12 @@ fn spawn_leader_subprocess() -> Result<u32, ConnectionError> {
|
|||||||
}
|
}
|
||||||
cmd.stdin(std::process::Stdio::null())
|
cmd.stdin(std::process::Stdio::null())
|
||||||
.stdout(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) {
|
match std::fs::File::create(&log_path) {
|
||||||
Ok(log_file) => {
|
Ok(log_file) => {
|
||||||
info!("Leader stderr → log file");
|
info!("Leader stderr → log file");
|
||||||
@@ -1444,6 +1467,46 @@ mod tests {
|
|||||||
vec![(222, "0.2.52".to_string())]
|
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]
|
#[test]
|
||||||
fn leader_is_older_than_directional() {
|
fn leader_is_older_than_directional() {
|
||||||
assert!(leader_is_older_than("0.1.0", "0.2.0"));
|
assert!(leader_is_older_than("0.1.0", "0.2.0"));
|
||||||
|
|||||||
@@ -1146,6 +1146,9 @@ pub async fn run_leader_server(
|
|||||||
control_state: LeaderServerControlState,
|
control_state: LeaderServerControlState,
|
||||||
) -> Result<(), ServerError> {
|
) -> Result<(), ServerError> {
|
||||||
let _ = std::fs::remove_file(&socket_path);
|
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 shutdown_reason_rx = shutdown_tx.subscribe();
|
||||||
let listener = LeaderListener::bind(&socket_path)?;
|
let listener = LeaderListener::bind(&socket_path)?;
|
||||||
info!("Leader server listening");
|
info!("Leader server listening");
|
||||||
|
|||||||
@@ -171,6 +171,70 @@ pub fn error_detail_from_data(data: &serde_json::Value) -> Option<String> {
|
|||||||
.map(str::to_owned)
|
.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> {
|
pub fn http_status_from_error(err: &acp::Error) -> Option<u16> {
|
||||||
err.data
|
err.data
|
||||||
.as_ref()?
|
.as_ref()?
|
||||||
@@ -254,6 +318,77 @@ pub fn prompt_complete_fields(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 super::*;
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ mod model_switch;
|
|||||||
mod prompt_queue;
|
mod prompt_queue;
|
||||||
#[path = "acp_session_impl/slash_exec.rs"]
|
#[path = "acp_session_impl/slash_exec.rs"]
|
||||||
mod slash_exec;
|
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::PromptOrigin;
|
||||||
use super::acp_types;
|
use super::acp_types;
|
||||||
use super::chat_persistence;
|
use super::chat_persistence;
|
||||||
@@ -616,6 +620,9 @@ pub(crate) struct SessionActor {
|
|||||||
/// layered over the goal engine. Modeled after `goal_tracker` above;
|
/// layered over the goal engine. Modeled after `goal_tracker` above;
|
||||||
/// all graph state logic lives in `graph_tracker.rs`.
|
/// all graph state logic lives in `graph_tracker.rs`.
|
||||||
pub(crate) graph_tracker: Arc<parking_lot::Mutex<crate::session::graph_tracker::GraphTracker>>,
|
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).
|
/// Max graph nodes running concurrently (1 = serial G0 behavior).
|
||||||
/// Cached at actor construction from `resolve_graph_concurrency`.
|
/// Cached at actor construction from `resolve_graph_concurrency`.
|
||||||
pub(crate) graph_concurrency: u32,
|
pub(crate) graph_concurrency: u32,
|
||||||
@@ -1033,6 +1040,9 @@ impl SessionActor {
|
|||||||
// Graph rides the goal harness: nodes execute as goals, so
|
// Graph rides the goal harness: nodes execute as goals, so
|
||||||
// `/graph` is only real when `/goal` is.
|
// `/graph` is only real when `/goal` is.
|
||||||
graph: self.graph_enabled && goal,
|
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.
|
/// 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"]
|
#[path = "acp_session_tests/memory_config_tests.rs"]
|
||||||
mod memory_config_tests;
|
mod memory_config_tests;
|
||||||
#[cfg(test)]
|
#[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"]
|
#[path = "acp_session_tests/parallel_dispatch_tests.rs"]
|
||||||
mod parallel_dispatch_tests;
|
mod parallel_dispatch_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ pub(super) fn turn_result_to_hook_outcome(
|
|||||||
) -> kigi_tool_protocol::turn_hook::TurnHookOutcome {
|
) -> kigi_tool_protocol::turn_hook::TurnHookOutcome {
|
||||||
use kigi_tool_protocol::turn_hook::TurnHookOutcome;
|
use kigi_tool_protocol::turn_hook::TurnHookOutcome;
|
||||||
match result {
|
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 { .. }) => {
|
Ok(TurnOutcome::Cancelled { .. }) | Ok(TurnOutcome::MaxTurnsReached { .. }) => {
|
||||||
TurnHookOutcome::Cancelled
|
TurnHookOutcome::Cancelled
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ impl SessionActor {
|
|||||||
apply_prompt_override: bool,
|
apply_prompt_override: bool,
|
||||||
skip_prompt_rewrite: bool,
|
skip_prompt_rewrite: bool,
|
||||||
auto_compact_threshold_percent: u8,
|
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());
|
let model_id = acp::ModelId::new(sampling_config.model.clone());
|
||||||
// H4: record the picker's catalog KEY as this SESSION's own selection.
|
// 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
|
// `sampling_config.model` is the ambiguous bare slug; the key is what
|
||||||
@@ -120,7 +120,7 @@ impl SessionActor {
|
|||||||
agent_name: Some(agent_name),
|
agent_name: Some(agent_name),
|
||||||
reasoning_effort: Some(sampling_config.reasoning_effort),
|
reasoning_effort: Some(sampling_config.reasoning_effort),
|
||||||
});
|
});
|
||||||
Ok(model_id)
|
Ok((model_id, new_context_window))
|
||||||
}
|
}
|
||||||
/// Handle [`SessionCommand::RebuildAgentForDefinition`].
|
/// Handle [`SessionCommand::RebuildAgentForDefinition`].
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -548,17 +548,10 @@ impl SessionActor {
|
|||||||
|
|
||||||
if report.changed() && !dry_run {
|
if report.changed() && !dry_run {
|
||||||
// Flush barrier: success must mean the rewrite is on disk.
|
// Flush barrier: success must mean the rewrite is on disk.
|
||||||
let (flush_tx, flush_rx) = tokio::sync::oneshot::channel();
|
if let Err(error) = self.persistence_barrier().await {
|
||||||
if self
|
anyhow::bail!(
|
||||||
.notifications
|
"history repaired in memory but the persistence flush failed: {error}"
|
||||||
.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");
|
|
||||||
}
|
}
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
session_id = %self.session_info.id.0,
|
session_id = %self.session_info.id.0,
|
||||||
|
|||||||
@@ -709,6 +709,36 @@ impl SessionActor {
|
|||||||
BuiltinAction::GoalSet { .. } => {
|
BuiltinAction::GoalSet { .. } => {
|
||||||
unreachable!("GoalSet is intercepted in handle_prompt")
|
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 => {
|
BuiltinAction::GoalStatus => {
|
||||||
let current_tokens = self.chat_state_handle.get_total_tokens().await as i64;
|
let current_tokens = self.chat_state_handle.get_total_tokens().await as i64;
|
||||||
let goal_tokens = self.goal_tokens_used(current_tokens);
|
let goal_tokens = self.goal_tokens_used(current_tokens);
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ pub(crate) async fn spawn_session_actor(
|
|||||||
system_prompt_label: String,
|
system_prompt_label: String,
|
||||||
compaction_mode: kigi_chat_state::CompactionMode,
|
compaction_mode: kigi_chat_state::CompactionMode,
|
||||||
compaction_verbatim_input: bool,
|
compaction_verbatim_input: bool,
|
||||||
|
compaction_tool_choice: crate::util::config::CompactionToolChoice,
|
||||||
two_pass_enabled: bool,
|
two_pass_enabled: bool,
|
||||||
buffering_settings: Option<BufferingSettings>,
|
buffering_settings: Option<BufferingSettings>,
|
||||||
origin_client: Option<crate::http::OriginClientInfo>,
|
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_base_url = sampling_config.base_url.clone();
|
||||||
let embed_api_key = sampling_config.api_key.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(
|
let session_pruning_config: crate::config::PruningConfig = memory_config.as_ref().map_or_else(
|
||||||
|| crate::config::PruningConfig {
|
|| crate::config::PruningConfig {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -618,16 +631,11 @@ pub(crate) async fn spawn_session_actor(
|
|||||||
watcher,
|
watcher,
|
||||||
stale_claim_secs: watcher_config.stale_claim_secs,
|
stale_claim_secs: watcher_config.stale_claim_secs,
|
||||||
search_source: "tool",
|
search_source: "tool",
|
||||||
api_key_provider: api_key_provider.clone(),
|
embedding_credentials: crate::auth::credential_provider::embedding_session_credentials(
|
||||||
auth_credentials: auth_manager.as_ref().map(|am| {
|
&embed_base_url,
|
||||||
std::sync::Arc::new(
|
embed_platform,
|
||||||
crate::auth::credential_provider::ShellAuthCredentialProvider::new(
|
&models_manager.credential_authority(),
|
||||||
am.clone(),
|
),
|
||||||
None,
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
) as std::sync::Arc<dyn kigi_auth::AuthCredentialProvider>
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
let backend = crate::session::memory::MemoryBackendImpl::from_session_params(
|
let backend = crate::session::memory::MemoryBackendImpl::from_session_params(
|
||||||
storage.clone(),
|
storage.clone(),
|
||||||
@@ -1025,6 +1033,7 @@ pub(crate) async fn spawn_session_actor(
|
|||||||
previous_model: std::cell::Cell::new(None),
|
previous_model: std::cell::Cell::new(None),
|
||||||
compaction_mode,
|
compaction_mode,
|
||||||
verbatim_input: compaction_verbatim_input,
|
verbatim_input: compaction_verbatim_input,
|
||||||
|
tool_choice: compaction_tool_choice,
|
||||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
||||||
},
|
},
|
||||||
@@ -1108,6 +1117,7 @@ pub(crate) async fn spawn_session_actor(
|
|||||||
goal_tracker,
|
goal_tracker,
|
||||||
graph_enabled,
|
graph_enabled,
|
||||||
graph_tracker,
|
graph_tracker,
|
||||||
|
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||||
graph_concurrency: effective_config.resolve_graph_concurrency(),
|
graph_concurrency: effective_config.resolve_graph_concurrency(),
|
||||||
graph_node_rounds: effective_config.resolve_graph_node_rounds(),
|
graph_node_rounds: effective_config.resolve_graph_node_rounds(),
|
||||||
graph_replan_cap: effective_config.resolve_graph_replan_cap(),
|
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())
|
.map(|mc| mc.embedding.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let embed_dims = embed_config.dimensions;
|
let embed_dims = embed_config.dimensions;
|
||||||
let sampling_base_url = embed_base_url.clone();
|
// The session's own params, so the background reindex embeds through
|
||||||
let sampling_api_key = embed_api_key.clone();
|
// 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 session_id_for_reindex = session_info.id.to_string();
|
||||||
let chunks_added_counter = session.memory.chunks_added.clone();
|
let chunks_added_counter = session.memory.chunks_added.clone();
|
||||||
tokio::task::spawn_local(async move {
|
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(),
|
target : kigi_log::memory_log::TARGET, files = files.len(),
|
||||||
"MEMORY_REINDEX: background reindex complete"
|
"MEMORY_REINDEX: background reindex complete"
|
||||||
);
|
);
|
||||||
if let Some(api_key) = sampling_api_key
|
if let Some(ref params) = reindex_params
|
||||||
&& let Some(provider) =
|
&& let Some(provider) = params.make_embedding_provider().await
|
||||||
crate::session::memory::embedding::ApiEmbeddingProvider::from_session(
|
|
||||||
&embed_config,
|
|
||||||
sampling_base_url,
|
|
||||||
api_key,
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
crate::session::memory::embed_missing_chunks(&index, &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,
|
system_prompt_label: String,
|
||||||
compaction_mode: kigi_chat_state::CompactionMode,
|
compaction_mode: kigi_chat_state::CompactionMode,
|
||||||
compaction_verbatim_input: bool,
|
compaction_verbatim_input: bool,
|
||||||
|
compaction_tool_choice: crate::util::config::CompactionToolChoice,
|
||||||
two_pass_enabled: bool,
|
two_pass_enabled: bool,
|
||||||
buffering_settings: Option<BufferingSettings>,
|
buffering_settings: Option<BufferingSettings>,
|
||||||
origin_client: Option<crate::http::OriginClientInfo>,
|
origin_client: Option<crate::http::OriginClientInfo>,
|
||||||
@@ -1630,10 +1639,23 @@ pub(crate) async fn spawn_session_on_thread(
|
|||||||
};
|
};
|
||||||
(initial_last_compaction, initial_prompt_texts)
|
(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()
|
.enable_all()
|
||||||
.build()
|
.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();
|
let local = tokio::task::LocalSet::new();
|
||||||
local.block_on(&rt, async move {
|
local.block_on(&rt, async move {
|
||||||
let _trace_span = parent_traceparent.as_ref().map(|tp| {
|
let _trace_span = parent_traceparent.as_ref().map(|tp| {
|
||||||
@@ -1673,6 +1695,7 @@ pub(crate) async fn spawn_session_on_thread(
|
|||||||
system_prompt_label,
|
system_prompt_label,
|
||||||
compaction_mode,
|
compaction_mode,
|
||||||
compaction_verbatim_input,
|
compaction_verbatim_input,
|
||||||
|
compaction_tool_choice,
|
||||||
two_pass_enabled,
|
two_pass_enabled,
|
||||||
buffering_settings,
|
buffering_settings,
|
||||||
origin_client,
|
origin_client,
|
||||||
@@ -1753,8 +1776,19 @@ pub(crate) async fn spawn_session_on_thread(
|
|||||||
}));
|
}));
|
||||||
let _ = session_done_rx.await;
|
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
|
let init = init_rx
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.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()),
|
Some(self.session_info.id.0.to_string()),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
self.tool_context.cwd.clone(),
|
||||||
|
self.display_cwd.get().map(std::path::PathBuf::from),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
};
|
};
|
||||||
@@ -1546,6 +1548,18 @@ impl SessionActor {
|
|||||||
vec![],
|
vec![],
|
||||||
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(_) => (
|
ToolInput::EnterPlanMode(_) => (
|
||||||
"Plan: Enter".to_string(),
|
"Plan: Enter".to_string(),
|
||||||
acp::ToolKind::Other,
|
acp::ToolKind::Other,
|
||||||
|
|||||||
@@ -217,6 +217,10 @@ impl SessionActor {
|
|||||||
persist_ack: Option<oneshot::Sender<()>>,
|
persist_ack: Option<oneshot::Sender<()>>,
|
||||||
) -> PromptTurnResult {
|
) -> PromptTurnResult {
|
||||||
let handle_prompt_start = std::time::Instant::now();
|
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
|
let prompt_length: usize = prompt_blocks
|
||||||
.iter()
|
.iter()
|
||||||
.map(|b| match b {
|
.map(|b| match b {
|
||||||
@@ -301,6 +305,13 @@ impl SessionActor {
|
|||||||
span.record("command_source", "builtin");
|
span.record("command_source", "builtin");
|
||||||
}
|
}
|
||||||
match action {
|
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 {
|
BuiltinAction::GoalSet {
|
||||||
objective,
|
objective,
|
||||||
token_budget,
|
token_budget,
|
||||||
@@ -670,22 +681,14 @@ impl SessionActor {
|
|||||||
.await
|
.await
|
||||||
.is_some()
|
.is_some()
|
||||||
{
|
{
|
||||||
let (flush_tx, flush_rx) = oneshot::channel();
|
match self.persistence_barrier().await {
|
||||||
if self
|
Ok(()) => {
|
||||||
.notifications
|
let _ = ack.send(());
|
||||||
.persistence_tx
|
}
|
||||||
.send(PersistenceMsg::FlushAndAck {
|
Err(error) => tracing::error!(
|
||||||
respond_to: flush_tx,
|
session_id = % self.session_info.id.0, prompt_id = % prompt_id,
|
||||||
})
|
%error, "persist_ack flush barrier failed"
|
||||||
.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"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
@@ -779,7 +782,7 @@ impl SessionActor {
|
|||||||
let turn_tool_count = self.events.tool_count_this_turn();
|
let turn_tool_count = self.events.tool_count_this_turn();
|
||||||
let bridge_outcome = turn_result_to_hook_outcome(&result);
|
let bridge_outcome = turn_result_to_hook_outcome(&result);
|
||||||
match &result {
|
match &result {
|
||||||
Ok(TurnOutcome::Completed { .. }) => {
|
Ok(TurnOutcome::Completed { .. }) | Ok(TurnOutcome::StationarityHalted { .. }) => {
|
||||||
self.emit_turn_ended(
|
self.emit_turn_ended(
|
||||||
crate::session::events::TurnOutcomeLabel::Completed,
|
crate::session::events::TurnOutcomeLabel::Completed,
|
||||||
None,
|
None,
|
||||||
@@ -869,7 +872,9 @@ impl SessionActor {
|
|||||||
let doom_tally = std::mem::take(&mut *self.doom_loop_turn_tally.lock());
|
let doom_tally = std::mem::take(&mut *self.doom_loop_turn_tally.lock());
|
||||||
doom_tally.fired();
|
doom_tally.fired();
|
||||||
let stop_reason_str = match &result {
|
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 { .. }) => {
|
Ok(TurnOutcome::Cancelled { .. }) | Ok(TurnOutcome::MaxTurnsReached { .. }) => {
|
||||||
"cancelled"
|
"cancelled"
|
||||||
}
|
}
|
||||||
@@ -885,7 +890,7 @@ impl SessionActor {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
match &result {
|
match &result {
|
||||||
Ok(TurnOutcome::Completed { .. }) => {
|
Ok(TurnOutcome::Completed { .. }) | Ok(TurnOutcome::StationarityHalted { .. }) => {
|
||||||
for contributor in self.extension_registry.turn_lifecycle_contributors() {
|
for contributor in self.extension_registry.turn_lifecycle_contributors() {
|
||||||
contributor
|
contributor
|
||||||
.on_turn_done(&kigi_agent_lifecycle::TurnDoneInput)
|
.on_turn_done(&kigi_agent_lifecycle::TurnDoneInput)
|
||||||
@@ -970,6 +975,26 @@ impl SessionActor {
|
|||||||
PromptCompletionKind::MaxTurnsReached { limit },
|
PromptCompletionKind::MaxTurnsReached { limit },
|
||||||
None,
|
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() {
|
if let Some(snapshot) = snapshot.as_mut() {
|
||||||
self.apply_prompt_modes_to_snapshot(snapshot);
|
self.apply_prompt_modes_to_snapshot(snapshot);
|
||||||
@@ -1271,7 +1296,11 @@ impl SessionActor {
|
|||||||
let mut result = self
|
let mut result = self
|
||||||
.process_conversation_turn(req_id, json_schema.clone())
|
.process_conversation_turn(req_id, json_schema.clone())
|
||||||
.await;
|
.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;
|
return result;
|
||||||
}
|
}
|
||||||
if let Ok(TurnOutcome::Completed {
|
if let Ok(TurnOutcome::Completed {
|
||||||
@@ -1588,6 +1617,10 @@ impl SessionActor {
|
|||||||
self.record_turn_model().await;
|
self.record_turn_model().await;
|
||||||
let mut metrics_drop_guard = TurnMetrics::new();
|
let mut metrics_drop_guard = TurnMetrics::new();
|
||||||
let mut turn_tools_called: Vec<String> = Vec::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 tool_turn_count: usize = 1;
|
||||||
let mut loop_index: u32 = 0;
|
let mut loop_index: u32 = 0;
|
||||||
let mut todo_gate_fires: u32 = 0;
|
let mut todo_gate_fires: u32 = 0;
|
||||||
@@ -2004,6 +2037,8 @@ impl SessionActor {
|
|||||||
}
|
}
|
||||||
turn_tools_called.push(tc.name.clone());
|
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
|
let tool_call_responses: Vec<ToolCallResponse> = tool_calls
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|tc| ToolCallResponse {
|
.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;
|
let next_turn = tool_turn_count + 1;
|
||||||
if let Some(limit) = self.max_turns
|
if let Some(limit) = self.max_turns
|
||||||
&& next_turn > limit
|
&& next_turn > limit
|
||||||
|
|||||||
@@ -62,6 +62,19 @@ pub(crate) enum TurnOutcome {
|
|||||||
},
|
},
|
||||||
/// The `--max-turns` limit was reached after a tool-execution cycle.
|
/// The `--max-turns` limit was reached after a tool-execution cycle.
|
||||||
MaxTurnsReached { limit: usize },
|
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)]
|
#[derive(Debug)]
|
||||||
|
|||||||
@@ -333,15 +333,24 @@ impl SessionActor {
|
|||||||
if let Err(e) = crate::session::replay_events::flush_replay_actor(&self.event_tx).await {
|
if let Err(e) = crate::session::replay_events::flush_replay_actor(&self.event_tx).await {
|
||||||
tracing::warn!(?e, "flush_replay_actor failed");
|
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();
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
if self
|
if self
|
||||||
.notifications
|
.notifications
|
||||||
.persistence_tx
|
.persistence_tx
|
||||||
.send(PersistenceMsg::FlushAndAck { respond_to: 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
|
/// Extracts the update type name and relevant parameters for logging
|
||||||
fn extract_update_info(
|
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(),
|
tokio_util::sync::CancellationToken::new(),
|
||||||
);
|
);
|
||||||
let actor = Arc::new(SessionActor {
|
let actor = Arc::new(SessionActor {
|
||||||
|
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||||
session_info,
|
session_info,
|
||||||
auth_method_id: test_auth_method_id("test-auth"),
|
auth_method_id: test_auth_method_id("test-auth"),
|
||||||
model_auth_facts: std::cell::RefCell::new(None),
|
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),
|
previous_model: std::cell::Cell::new(None),
|
||||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||||
verbatim_input: true,
|
verbatim_input: true,
|
||||||
|
tool_choice: Default::default(),
|
||||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
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,
|
respond_to: flush_tx,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
flush_rx.await.unwrap();
|
flush_rx.await.unwrap().unwrap();
|
||||||
let loaded = storage
|
let loaded = storage
|
||||||
.load_session_without_updates(&session_info)
|
.load_session_without_updates(&session_info)
|
||||||
.await
|
.await
|
||||||
@@ -555,11 +557,11 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
|
|||||||
watcher: None,
|
watcher: None,
|
||||||
stale_claim_secs: 60,
|
stale_claim_secs: 60,
|
||||||
search_source: "tool",
|
search_source: "tool",
|
||||||
api_key_provider: None,
|
embedding_credentials: kigi_memory::EndpointScopedCredentials::none(),
|
||||||
auth_credentials: None,
|
|
||||||
};
|
};
|
||||||
let (event_tx, _event_rx) = tokio::sync::mpsc::unbounded_channel::<SessionEvent>();
|
let (event_tx, _event_rx) = tokio::sync::mpsc::unbounded_channel::<SessionEvent>();
|
||||||
let actor = Arc::new(SessionActor {
|
let actor = Arc::new(SessionActor {
|
||||||
|
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||||
session_info: session_info.clone(),
|
session_info: session_info.clone(),
|
||||||
auth_method_id: test_auth_method_id("test-auth"),
|
auth_method_id: test_auth_method_id("test-auth"),
|
||||||
model_auth_facts: std::cell::RefCell::new(None),
|
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),
|
previous_model: std::cell::Cell::new(None),
|
||||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||||
verbatim_input: true,
|
verbatim_input: true,
|
||||||
|
tool_choice: Default::default(),
|
||||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
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,
|
respond_to: flush_tx,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
flush_rx.await.unwrap();
|
flush_rx.await.unwrap().unwrap();
|
||||||
let storage = crate::session::storage::JsonlStorageAdapter::with_explicit_session_dir(
|
let storage = crate::session::storage::JsonlStorageAdapter::with_explicit_session_dir(
|
||||||
session_dir.path().to_path_buf(),
|
session_dir.path().to_path_buf(),
|
||||||
);
|
);
|
||||||
@@ -821,6 +824,7 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let actor = SessionActor {
|
let actor = SessionActor {
|
||||||
|
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||||
session_info: SessionInfo {
|
session_info: SessionInfo {
|
||||||
id: acp::SessionId::new("test-cancel"),
|
id: acp::SessionId::new("test-cancel"),
|
||||||
cwd: cwd.as_str().to_string(),
|
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),
|
previous_model: std::cell::Cell::new(None),
|
||||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||||
verbatim_input: true,
|
verbatim_input: true,
|
||||||
|
tool_choice: Default::default(),
|
||||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
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 {
|
tokio::task::spawn_local(async move {
|
||||||
while let Some(msg) = persistence_rx.recv().await {
|
while let Some(msg) = persistence_rx.recv().await {
|
||||||
if let PersistenceMsg::FlushAndAck { respond_to } = msg {
|
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;
|
.await;
|
||||||
let actor = SessionActor {
|
let actor = SessionActor {
|
||||||
|
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||||
session_info: SessionInfo {
|
session_info: SessionInfo {
|
||||||
id: acp::SessionId::new("test-cancel-sampler"),
|
id: acp::SessionId::new("test-cancel-sampler"),
|
||||||
cwd: cwd.as_str().to_string(),
|
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),
|
previous_model: std::cell::Cell::new(None),
|
||||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||||
verbatim_input: true,
|
verbatim_input: true,
|
||||||
|
tool_choice: Default::default(),
|
||||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
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;
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
let actor = SessionActor {
|
let actor = SessionActor {
|
||||||
|
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||||
session_info: SessionInfo {
|
session_info: SessionInfo {
|
||||||
id: acp::SessionId::new("test-idle-resume"),
|
id: acp::SessionId::new("test-idle-resume"),
|
||||||
cwd: cwd.as_str().to_string(),
|
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),
|
previous_model: std::cell::Cell::new(None),
|
||||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||||
verbatim_input: true,
|
verbatim_input: true,
|
||||||
|
tool_choice: Default::default(),
|
||||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
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);
|
chat_state_handle.record_token_usage(total_tokens);
|
||||||
SessionActor {
|
SessionActor {
|
||||||
|
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||||
session_info: SessionInfo {
|
session_info: SessionInfo {
|
||||||
id: acp::SessionId::new("test-auto-compact"),
|
id: acp::SessionId::new("test-auto-compact"),
|
||||||
cwd: cwd.as_str().to_string(),
|
cwd: cwd.as_str().to_string(),
|
||||||
@@ -111,6 +112,7 @@ async fn create_test_actor(
|
|||||||
previous_model: std::cell::Cell::new(None),
|
previous_model: std::cell::Cell::new(None),
|
||||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||||
verbatim_input: true,
|
verbatim_input: true,
|
||||||
|
tool_choice: Default::default(),
|
||||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
||||||
},
|
},
|
||||||
@@ -393,8 +395,7 @@ fn initial_injection_backend_params_use_override_min_score() {
|
|||||||
watcher: None,
|
watcher: None,
|
||||||
stale_claim_secs: 60,
|
stale_claim_secs: 60,
|
||||||
search_source: "tool",
|
search_source: "tool",
|
||||||
api_key_provider: None,
|
embedding_credentials: kigi_memory::EndpointScopedCredentials::none(),
|
||||||
auth_credentials: None,
|
|
||||||
};
|
};
|
||||||
let initial_injection = crate::config::MemoryInitialInjectionConfig {
|
let initial_injection = crate::config::MemoryInitialInjectionConfig {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -422,8 +423,7 @@ fn initial_injection_backend_params_preserve_default_zero_min_score() {
|
|||||||
watcher: None,
|
watcher: None,
|
||||||
stale_claim_secs: 60,
|
stale_claim_secs: 60,
|
||||||
search_source: "tool",
|
search_source: "tool",
|
||||||
api_key_provider: None,
|
embedding_credentials: kigi_memory::EndpointScopedCredentials::none(),
|
||||||
auth_credentials: None,
|
|
||||||
};
|
};
|
||||||
let (adjusted, effective_min_score) = build_initial_injection_backend_params(
|
let (adjusted, effective_min_score) = build_initial_injection_backend_params(
|
||||||
¶ms,
|
¶ms,
|
||||||
@@ -495,6 +495,7 @@ async fn create_test_actor_with_memory(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or_else(Default::default, |mc| mc.initial_injection.clone());
|
.map_or_else(Default::default, |mc| mc.initial_injection.clone());
|
||||||
SessionActor {
|
SessionActor {
|
||||||
|
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||||
session_info: SessionInfo {
|
session_info: SessionInfo {
|
||||||
id: acp::SessionId::new("test-memory"),
|
id: acp::SessionId::new("test-memory"),
|
||||||
cwd: cwd.as_str().to_string(),
|
cwd: cwd.as_str().to_string(),
|
||||||
@@ -539,6 +540,7 @@ async fn create_test_actor_with_memory(
|
|||||||
previous_model: std::cell::Cell::new(None),
|
previous_model: std::cell::Cell::new(None),
|
||||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||||
verbatim_input: true,
|
verbatim_input: true,
|
||||||
|
tool_choice: Default::default(),
|
||||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
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;
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
let actor = SessionActor {
|
let actor = SessionActor {
|
||||||
|
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||||
session_info: SessionInfo {
|
session_info: SessionInfo {
|
||||||
id: acp::SessionId::new("test-idle-resume"),
|
id: acp::SessionId::new("test-idle-resume"),
|
||||||
cwd: cwd.as_str().to_string(),
|
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),
|
previous_model: std::cell::Cell::new(None),
|
||||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||||
verbatim_input: true,
|
verbatim_input: true,
|
||||||
|
tool_choice: Default::default(),
|
||||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ fn initial_injection_backend_params_use_override_min_score() {
|
|||||||
watcher: None,
|
watcher: None,
|
||||||
stale_claim_secs: 60,
|
stale_claim_secs: 60,
|
||||||
search_source: "tool",
|
search_source: "tool",
|
||||||
api_key_provider: None,
|
embedding_credentials: kigi_memory::EndpointScopedCredentials::none(),
|
||||||
auth_credentials: None,
|
|
||||||
};
|
};
|
||||||
let initial_injection = crate::config::MemoryInitialInjectionConfig {
|
let initial_injection = crate::config::MemoryInitialInjectionConfig {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -47,8 +46,7 @@ fn initial_injection_backend_params_preserve_default_zero_min_score() {
|
|||||||
watcher: None,
|
watcher: None,
|
||||||
stale_claim_secs: 60,
|
stale_claim_secs: 60,
|
||||||
search_source: "tool",
|
search_source: "tool",
|
||||||
api_key_provider: None,
|
embedding_credentials: kigi_memory::EndpointScopedCredentials::none(),
|
||||||
auth_credentials: None,
|
|
||||||
};
|
};
|
||||||
let (adjusted, effective_min_score) = build_initial_injection_backend_params(
|
let (adjusted, effective_min_score) = build_initial_injection_backend_params(
|
||||||
¶ms,
|
¶ms,
|
||||||
@@ -121,6 +119,7 @@ async fn create_test_actor_with_memory(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or_else(Default::default, |mc| mc.initial_injection.clone());
|
.map_or_else(Default::default, |mc| mc.initial_injection.clone());
|
||||||
SessionActor {
|
SessionActor {
|
||||||
|
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||||
session_info: SessionInfo {
|
session_info: SessionInfo {
|
||||||
id: acp::SessionId::new("test-memory"),
|
id: acp::SessionId::new("test-memory"),
|
||||||
cwd: cwd.as_str().to_string(),
|
cwd: cwd.as_str().to_string(),
|
||||||
@@ -164,6 +163,7 @@ async fn create_test_actor_with_memory(
|
|||||||
previous_model: std::cell::Cell::new(None),
|
previous_model: std::cell::Cell::new(None),
|
||||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||||
verbatim_input: true,
|
verbatim_input: true,
|
||||||
|
tool_choice: Default::default(),
|
||||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
||||||
},
|
},
|
||||||
@@ -524,8 +524,7 @@ async fn create_injection_ready_actor(
|
|||||||
watcher: None,
|
watcher: None,
|
||||||
stale_claim_secs: 60,
|
stale_claim_secs: 60,
|
||||||
search_source: "tool",
|
search_source: "tool",
|
||||||
api_key_provider: None,
|
embedding_credentials: kigi_memory::EndpointScopedCredentials::none(),
|
||||||
auth_credentials: None,
|
|
||||||
});
|
});
|
||||||
actor
|
actor
|
||||||
.chat_state_handle
|
.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,
|
None,
|
||||||
None,
|
None,
|
||||||
|
session.tool_context.cwd.clone(),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
// cargo is heuristic-allow when sampling fails; must not be Prompt-only
|
// 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,
|
None,
|
||||||
None,
|
None,
|
||||||
|
session.tool_context.cwd.clone(),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(
|
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 (event_tx, event_rx) = mpsc::unbounded_channel::<SessionEvent>();
|
||||||
let actor = SessionActor {
|
let actor = SessionActor {
|
||||||
|
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||||
session_info: SessionInfo {
|
session_info: SessionInfo {
|
||||||
id: acp::SessionId::new("test-session"),
|
id: acp::SessionId::new("test-session"),
|
||||||
cwd: cwd.as_str().to_string(),
|
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),
|
previous_model: std::cell::Cell::new(None),
|
||||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||||
verbatim_input: true,
|
verbatim_input: true,
|
||||||
|
tool_choice: Default::default(),
|
||||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
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);
|
chat_state_handle.record_token_usage(total_tokens);
|
||||||
let (goal_update_tx, goal_update_rx) = tokio::sync::mpsc::unbounded_channel();
|
let (goal_update_tx, goal_update_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
let actor = SessionActor {
|
let actor = SessionActor {
|
||||||
|
swarm_mode: std::cell::Cell::new(Default::default()),
|
||||||
session_info: SessionInfo {
|
session_info: SessionInfo {
|
||||||
id: acp::SessionId::new("test-actor"),
|
id: acp::SessionId::new("test-actor"),
|
||||||
cwd: cwd.as_str().to_string(),
|
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),
|
previous_model: std::cell::Cell::new(None),
|
||||||
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
|
||||||
verbatim_input: true,
|
verbatim_input: true,
|
||||||
|
tool_choice: Default::default(),
|
||||||
prefire: crate::session::compaction_config::PrefireState::default(),
|
prefire: crate::session::compaction_config::PrefireState::default(),
|
||||||
prefix_released: std::sync::atomic::AtomicBool::new(false),
|
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