Compare commits
10
Commits
9edb8729ef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
553ec1c620 | ||
|
|
f656627c10 | ||
|
|
11c3ca9803 | ||
|
|
bac8470c80 | ||
|
|
74402f3078 | ||
|
|
649c5f5641 | ||
|
|
ad3840f9ec | ||
|
|
ac23ebc9a1 | ||
|
|
a07d889ad9 | ||
|
|
16328e55f9 |
@@ -16,14 +16,14 @@ import) or any `KIMI_*` env var.
|
|||||||
|
|
||||||
## 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 +164,56 @@ 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.
|
||||||
|
- `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
+62
-62
@@ -5442,7 +5442,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-acp-lib"
|
name = "kigi-acp-lib"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-log"
|
name = "kigi-log"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-mcp"
|
name = "kigi-mcp"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
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.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-openai",
|
"async-openai",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@@ -6154,7 +6154,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sampling-types"
|
name = "kigi-sampling-types"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"assert_matches",
|
"assert_matches",
|
||||||
"async-openai",
|
"async-openai",
|
||||||
@@ -6171,7 +6171,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sandbox"
|
name = "kigi-sandbox"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6192,7 +6192,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-secrets"
|
name = "kigi-secrets"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"regex",
|
"regex",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -6230,7 +6230,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-shell"
|
name = "kigi-shell"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6367,7 +6367,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-shell-base"
|
name = "kigi-shell-base"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6392,7 +6392,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sqlite-journal"
|
name = "kigi-sqlite-journal"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -6403,7 +6403,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-subagent-resolution"
|
name = "kigi-subagent-resolution"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-sampling-types",
|
"kigi-sampling-types",
|
||||||
"kigi-tool-types",
|
"kigi-tool-types",
|
||||||
@@ -6418,7 +6418,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-system-power"
|
name = "kigi-system-power"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
"zbus",
|
"zbus",
|
||||||
@@ -6426,7 +6426,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-test-support"
|
name = "kigi-test-support"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6448,7 +6448,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-test-utils"
|
name = "kigi-test-utils"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"runfiles",
|
"runfiles",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -6457,11 +6457,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-token-estimation"
|
name = "kigi-token-estimation"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-protocol"
|
name = "kigi-tool-protocol"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-tool-types",
|
"kigi-tool-types",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -6472,7 +6472,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-runtime"
|
name = "kigi-tool-runtime"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -6490,7 +6490,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-types"
|
name = "kigi-tool-types"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"minijinja",
|
"minijinja",
|
||||||
"schemars 1.2.1",
|
"schemars 1.2.1",
|
||||||
@@ -6500,7 +6500,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tools"
|
name = "kigi-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
@@ -6577,7 +6577,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tools-api"
|
name = "kigi-tools-api"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-proto-build",
|
"kigi-proto-build",
|
||||||
"kigi-tool-protocol",
|
"kigi-tool-protocol",
|
||||||
@@ -6590,11 +6590,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tracing-macros"
|
name = "kigi-tracing-macros"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tty-utils"
|
name = "kigi-tty-utils"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"nix 0.30.1",
|
"nix 0.30.1",
|
||||||
@@ -6604,7 +6604,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tui"
|
name = "kigi-tui"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"ansi-to-tui",
|
"ansi-to-tui",
|
||||||
@@ -6691,7 +6691,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-update"
|
name = "kigi-update"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -6720,14 +6720,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-version"
|
name = "kigi-version"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-workspace"
|
name = "kigi-workspace"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6806,7 +6806,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-workspace-types"
|
name = "kigi-workspace-types"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -8840,7 +8840,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ptyctl"
|
name = "ptyctl"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alacritty_terminal",
|
"alacritty_terminal",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -8858,7 +8858,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ptyctl-cli"
|
name = "ptyctl-cli"
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
+1
-1
@@ -76,7 +76,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1115,6 +1115,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(),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -779,7 +790,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 +880,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 +898,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 +983,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 +1304,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 +1625,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 +2045,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 +2089,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)]
|
||||||
|
|||||||
@@ -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),
|
||||||
@@ -559,6 +560,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
|
|||||||
};
|
};
|
||||||
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),
|
||||||
@@ -820,6 +822,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(),
|
||||||
@@ -1813,6 +1816,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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
+3
@@ -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(),
|
||||||
@@ -493,6 +494,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(),
|
||||||
@@ -1235,6 +1237,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(),
|
||||||
|
|||||||
@@ -119,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(),
|
||||||
|
|||||||
+1
@@ -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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ pub enum PromptCompletionKind {
|
|||||||
MaxTurnsReached {
|
MaxTurnsReached {
|
||||||
limit: usize,
|
limit: usize,
|
||||||
},
|
},
|
||||||
|
/// One tool call repeated past its ceiling. Reported as `EndTurn`:
|
||||||
|
/// the turn ended, nobody interrupted it.
|
||||||
|
StationarityHalted {
|
||||||
|
tool_name: String,
|
||||||
|
run_len: u32,
|
||||||
|
true_noop: bool,
|
||||||
|
},
|
||||||
Rewound,
|
Rewound,
|
||||||
/// A queued prompt was removed (or cleared) from the server-authoritative
|
/// A queued prompt was removed (or cleared) from the server-authoritative
|
||||||
/// queue before it ever ran. Used to resolve the still-pending
|
/// queue before it ever ran. Used to resolve the still-pending
|
||||||
|
|||||||
@@ -2154,6 +2154,7 @@ mod inline_auto_compact_flow_tests {
|
|||||||
);
|
);
|
||||||
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(),
|
||||||
|
|||||||
@@ -326,9 +326,11 @@ pub mod restore;
|
|||||||
pub mod result;
|
pub mod result;
|
||||||
pub mod signals;
|
pub mod signals;
|
||||||
pub(crate) mod slash_commands;
|
pub(crate) mod slash_commands;
|
||||||
|
pub(crate) mod stationarity;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub(crate) mod streaming_capture;
|
pub(crate) mod streaming_capture;
|
||||||
pub(crate) mod summary;
|
pub(crate) mod summary;
|
||||||
|
pub mod swarm_mode;
|
||||||
pub(crate) mod telemetry;
|
pub(crate) mod telemetry;
|
||||||
pub mod tool_index;
|
pub mod tool_index;
|
||||||
pub(crate) mod turn_completion;
|
pub(crate) mod turn_completion;
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ pub(crate) enum BuiltinGate {
|
|||||||
/// available (graph nodes execute as goals, so `/graph` needs
|
/// available (graph nodes execute as goals, so `/graph` needs
|
||||||
/// everything `/goal` needs).
|
/// everything `/goal` needs).
|
||||||
Graph,
|
Graph,
|
||||||
|
/// The `agent_swarm` tool is in the session toolset. The mode is only a
|
||||||
|
/// standing instruction to use that tool, so without it the command would
|
||||||
|
/// advertise a doctrine the model has no way to follow.
|
||||||
|
Swarm,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All built-in slash commands. Order here = display order in autocomplete.
|
/// All built-in slash commands. Order here = display order in autocomplete.
|
||||||
@@ -303,6 +307,26 @@ pub(super) const BUILTIN_COMMANDS: &[BuiltinCommand] = &[
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
BuiltinCommand {
|
||||||
|
name: "swarm",
|
||||||
|
description: "Delegate aggressively: split the work across a fleet of subagents",
|
||||||
|
argument_hint: Some("[on | off | <task>]"),
|
||||||
|
aliases: &[],
|
||||||
|
gate: BuiltinGate::Swarm,
|
||||||
|
resolve: |args| {
|
||||||
|
let trimmed = args.trim();
|
||||||
|
match trimmed.to_lowercase().as_str() {
|
||||||
|
"on" => BuiltinAction::SwarmSet { enabled: true },
|
||||||
|
"off" => BuiltinAction::SwarmSet { enabled: false },
|
||||||
|
"" => BuiltinAction::SwarmToggle,
|
||||||
|
// Anything else is the task itself: arm the mode for exactly
|
||||||
|
// this turn and send the text as the prompt.
|
||||||
|
_ => BuiltinAction::SwarmTask {
|
||||||
|
prompt: trimmed.to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Split a trailing `--budget <tokens>` flag off a `/goal` objective.
|
/// Split a trailing `--budget <tokens>` flag off a `/goal` objective.
|
||||||
@@ -437,6 +461,8 @@ pub(crate) struct CommandAvailability {
|
|||||||
/// `/graph` gate: the graph feature flag AND the goal harness (nodes
|
/// `/graph` gate: the graph feature flag AND the goal harness (nodes
|
||||||
/// execute as goals) are both available.
|
/// execute as goals) are both available.
|
||||||
pub graph: bool,
|
pub graph: bool,
|
||||||
|
/// `/swarm` gate: the `agent_swarm` tool is in the active toolset.
|
||||||
|
pub swarm: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommandAvailability {
|
impl CommandAvailability {
|
||||||
@@ -452,6 +478,7 @@ impl CommandAvailability {
|
|||||||
BuiltinGate::Plugins => self.plugins,
|
BuiltinGate::Plugins => self.plugins,
|
||||||
BuiltinGate::Goal => self.goal,
|
BuiltinGate::Goal => self.goal,
|
||||||
BuiltinGate::Graph => self.graph,
|
BuiltinGate::Graph => self.graph,
|
||||||
|
BuiltinGate::Swarm => self.swarm,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,6 +495,7 @@ impl CommandAvailability {
|
|||||||
plugins: true,
|
plugins: true,
|
||||||
goal: true,
|
goal: true,
|
||||||
graph: true,
|
graph: true,
|
||||||
|
swarm: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -705,6 +733,17 @@ pub(super) enum BuiltinAction {
|
|||||||
token_budget: Option<i64>,
|
token_budget: Option<i64>,
|
||||||
},
|
},
|
||||||
GoalStatus,
|
GoalStatus,
|
||||||
|
/// `/swarm on|off` — arm or disarm the standing delegate-aggressively
|
||||||
|
/// instruction. Survives turns until switched off.
|
||||||
|
SwarmSet {
|
||||||
|
enabled: bool,
|
||||||
|
},
|
||||||
|
/// `/swarm` with no argument.
|
||||||
|
SwarmToggle,
|
||||||
|
/// `/swarm <task>` — arm for this turn only, then send `prompt`.
|
||||||
|
SwarmTask {
|
||||||
|
prompt: String,
|
||||||
|
},
|
||||||
GoalPause,
|
GoalPause,
|
||||||
GoalResume,
|
GoalResume,
|
||||||
GoalClear,
|
GoalClear,
|
||||||
@@ -757,6 +796,9 @@ impl BuiltinAction {
|
|||||||
| BuiltinAction::GraphPause
|
| BuiltinAction::GraphPause
|
||||||
| BuiltinAction::GraphResume { .. }
|
| BuiltinAction::GraphResume { .. }
|
||||||
| BuiltinAction::GraphClear => "graph",
|
| BuiltinAction::GraphClear => "graph",
|
||||||
|
BuiltinAction::SwarmSet { .. }
|
||||||
|
| BuiltinAction::SwarmToggle
|
||||||
|
| BuiltinAction::SwarmTask { .. } => "swarm",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,6 +837,8 @@ impl BuiltinAction {
|
|||||||
| BuiltinAction::GraphShow
|
| BuiltinAction::GraphShow
|
||||||
| BuiltinAction::GraphPause
|
| BuiltinAction::GraphPause
|
||||||
| BuiltinAction::GraphClear => false,
|
| BuiltinAction::GraphClear => false,
|
||||||
|
BuiltinAction::SwarmToggle => false,
|
||||||
|
BuiltinAction::SwarmSet { .. } | BuiltinAction::SwarmTask { .. } => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1584,6 +1628,7 @@ mod tests {
|
|||||||
"feedback",
|
"feedback",
|
||||||
"goal",
|
"goal",
|
||||||
"graph",
|
"graph",
|
||||||
|
"swarm",
|
||||||
"loop",
|
"loop",
|
||||||
"commit",
|
"commit",
|
||||||
"deploy",
|
"deploy",
|
||||||
@@ -1694,6 +1739,51 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Without the `agent_swarm` tool the mode has nothing to steer toward, so
|
||||||
|
/// the command must fall through as ordinary prompt text rather than
|
||||||
|
/// arming a doctrine the model cannot act on.
|
||||||
|
#[test]
|
||||||
|
fn swarm_does_not_resolve_when_gate_off() {
|
||||||
|
let availability = CommandAvailability {
|
||||||
|
swarm: false,
|
||||||
|
..CommandAvailability::all_enabled()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
resolve(
|
||||||
|
vec![text_block("/swarm on")],
|
||||||
|
&[],
|
||||||
|
availability,
|
||||||
|
SkillSlashRewrite::default(),
|
||||||
|
)
|
||||||
|
.is_ok(),
|
||||||
|
"expected pass-through (Ok), got an outcome",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn swarm_resolves_each_form_to_its_own_action() {
|
||||||
|
assert!(matches!(
|
||||||
|
resolve_builtin("swarm", "on").expect("/swarm on must resolve"),
|
||||||
|
BuiltinAction::SwarmSet { enabled: true }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
resolve_builtin("swarm", "off").expect("/swarm off must resolve"),
|
||||||
|
BuiltinAction::SwarmSet { enabled: false }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
resolve_builtin("swarm", "").expect("bare /swarm must resolve"),
|
||||||
|
BuiltinAction::SwarmToggle
|
||||||
|
));
|
||||||
|
// Anything else is the task, NOT an unknown subcommand: mis-parsing it
|
||||||
|
// would silently drop the user's work instead of running it.
|
||||||
|
match resolve_builtin("swarm", "split the auth refactor")
|
||||||
|
.expect("/swarm <task> must resolve")
|
||||||
|
{
|
||||||
|
BuiltinAction::SwarmTask { prompt } => assert_eq!(prompt, "split the auth refactor"),
|
||||||
|
other => panic!("expected SwarmTask, got {}", other.command_name()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn graph_resolves_subcommands_and_budget() {
|
fn graph_resolves_subcommands_and_budget() {
|
||||||
let set = resolve_builtin("graph", "ship the feature --budget 5000")
|
let set = resolve_builtin("graph", "ship the feature --budget 5000")
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
//! Detects a model stuck repeating one identical tool call.
|
||||||
|
|
||||||
|
/// Consecutive identical batches after which the turn is halted.
|
||||||
|
pub(crate) const MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS: u32 = 16;
|
||||||
|
|
||||||
|
/// Nudge threshold; half the budget remains after it.
|
||||||
|
pub(crate) const NUDGE_AFTER_IDENTICAL_TOOL_CALLS: u32 = 8;
|
||||||
|
|
||||||
|
/// Below the nudge on purpose: no-op runs get none.
|
||||||
|
pub(crate) const MAX_CONSECUTIVE_TRUE_NOOPS: u32 = 4;
|
||||||
|
|
||||||
|
const _: () = assert!(NUDGE_AFTER_IDENTICAL_TOOL_CALLS < MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS);
|
||||||
|
const _: () = assert!(MAX_CONSECUTIVE_TRUE_NOOPS < NUDGE_AFTER_IDENTICAL_TOOL_CALLS);
|
||||||
|
|
||||||
|
/// A shell command that does nothing whatsoever.
|
||||||
|
pub(crate) fn command_is_true(cmd: &str) -> bool {
|
||||||
|
cmd.trim().eq_ignore_ascii_case("true")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hashes name+args per call; separators prevent concatenation collisions.
|
||||||
|
pub(crate) fn hash_batch<'a>(calls: impl IntoIterator<Item = (&'a str, &'a str)>) -> u64 {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
for (name, args) in calls {
|
||||||
|
name.hash(&mut hasher);
|
||||||
|
'\u{1f}'.hash(&mut hasher);
|
||||||
|
args.hash(&mut hasher);
|
||||||
|
'\u{1e}'.hash(&mut hasher);
|
||||||
|
}
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One identity, so re-spelling a no-op cannot reset it.
|
||||||
|
const TRUE_NOOP_HASH: u64 = u64::MAX;
|
||||||
|
|
||||||
|
/// How many times the current batch has repeated unchanged.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct IdenticalToolCallRun {
|
||||||
|
/// Hash only: signatures hold raw tool arguments.
|
||||||
|
last_hash: Option<u64>,
|
||||||
|
tool_name: String,
|
||||||
|
run_len: u32,
|
||||||
|
is_true_noop_run: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdenticalToolCallRun {
|
||||||
|
/// Records one batch and returns the length of the run it belongs to.
|
||||||
|
pub(crate) fn observe(&mut self, batch_hash: u64, tool_name: &str, is_true_noop: bool) -> u32 {
|
||||||
|
let hash = if is_true_noop {
|
||||||
|
TRUE_NOOP_HASH
|
||||||
|
} else {
|
||||||
|
batch_hash
|
||||||
|
};
|
||||||
|
if self.last_hash == Some(hash) {
|
||||||
|
self.run_len += 1;
|
||||||
|
} else {
|
||||||
|
self.run_len = 1;
|
||||||
|
self.last_hash = Some(hash);
|
||||||
|
self.is_true_noop_run = is_true_noop;
|
||||||
|
}
|
||||||
|
self.tool_name = tool_name.to_string();
|
||||||
|
self.run_len
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The run length at which this turn must be halted.
|
||||||
|
pub(crate) fn hard_stop_threshold(&self) -> u32 {
|
||||||
|
if self.is_true_noop_run {
|
||||||
|
MAX_CONSECUTIVE_TRUE_NOOPS
|
||||||
|
} else {
|
||||||
|
MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn tool_name(&self) -> &str {
|
||||||
|
&self.tool_name
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_true_noop_run(&self) -> bool {
|
||||||
|
self.is_true_noop_run
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn call(name: &str, args: &str) -> u64 {
|
||||||
|
hash_batch([(name, args)])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unchanged_batch_accumulates_a_run() {
|
||||||
|
let mut run = IdenticalToolCallRun::default();
|
||||||
|
let h = call("read_file", "a.rs");
|
||||||
|
assert_eq!(run.observe(h, "read_file", false), 1);
|
||||||
|
assert_eq!(run.observe(h, "read_file", false), 2);
|
||||||
|
assert_eq!(run.observe(h, "read_file", false), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn any_change_in_the_arguments_restarts_the_run() {
|
||||||
|
let mut run = IdenticalToolCallRun::default();
|
||||||
|
run.observe(call("read_file", "a.rs"), "read_file", false);
|
||||||
|
run.observe(call("read_file", "a.rs"), "read_file", false);
|
||||||
|
assert_eq!(
|
||||||
|
run.observe(call("read_file", "b.rs"), "read_file", false),
|
||||||
|
1,
|
||||||
|
"a different argument is a different action"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_batches_cannot_collide_by_concatenation() {
|
||||||
|
// Unseparated these would hash identically.
|
||||||
|
assert_ne!(
|
||||||
|
hash_batch([("ab", "cd")]),
|
||||||
|
hash_batch([("a", "b"), ("c", "d")])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_ops_share_one_run_however_they_are_spelled() {
|
||||||
|
let mut run = IdenticalToolCallRun::default();
|
||||||
|
assert_eq!(run.observe(call("bash", "true"), "bash", true), 1);
|
||||||
|
assert_eq!(
|
||||||
|
run.observe(call("bash", " TRUE "), "bash", true),
|
||||||
|
2,
|
||||||
|
"re-spelling a no-op must not reset the tighter ceiling"
|
||||||
|
);
|
||||||
|
assert_eq!(run.hard_stop_threshold(), MAX_CONSECUTIVE_TRUE_NOOPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_real_call_after_a_noop_run_restores_the_ordinary_ceiling() {
|
||||||
|
let mut run = IdenticalToolCallRun::default();
|
||||||
|
run.observe(call("bash", "true"), "bash", true);
|
||||||
|
assert_eq!(run.hard_stop_threshold(), MAX_CONSECUTIVE_TRUE_NOOPS);
|
||||||
|
run.observe(call("read_file", "a.rs"), "read_file", false);
|
||||||
|
assert_eq!(
|
||||||
|
run.hard_stop_threshold(),
|
||||||
|
MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_ordinary_repeated_call_is_halted_at_its_ceiling_and_not_before() {
|
||||||
|
let mut run = IdenticalToolCallRun::default();
|
||||||
|
let h = call("read_file", "a.rs");
|
||||||
|
for expected in 1..MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS {
|
||||||
|
let len = run.observe(h, "read_file", false);
|
||||||
|
assert_eq!(len, expected);
|
||||||
|
assert!(len < run.hard_stop_threshold(), "must not halt early");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
run.observe(h, "read_file", false),
|
||||||
|
run.hard_stop_threshold(),
|
||||||
|
"the turn halts on the 16th identical call"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_repeated_no_op_is_halted_far_sooner_and_without_a_nudge() {
|
||||||
|
let mut run = IdenticalToolCallRun::default();
|
||||||
|
let h = call("bash", "true");
|
||||||
|
let mut len = 0;
|
||||||
|
while len < run.hard_stop_threshold() {
|
||||||
|
len = run.observe(h, "bash", true);
|
||||||
|
}
|
||||||
|
assert_eq!(len, MAX_CONSECUTIVE_TRUE_NOOPS);
|
||||||
|
assert!(
|
||||||
|
len < NUDGE_AFTER_IDENTICAL_TOOL_CALLS,
|
||||||
|
"documented: a no-op run is halted before any nudge could fire"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_nudge_lands_with_budget_left_to_act_on_it() {
|
||||||
|
let mut run = IdenticalToolCallRun::default();
|
||||||
|
let h = call("read_file", "a.rs");
|
||||||
|
let mut len = 0;
|
||||||
|
while len < NUDGE_AFTER_IDENTICAL_TOOL_CALLS {
|
||||||
|
len = run.observe(h, "read_file", false);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
len < run.hard_stop_threshold(),
|
||||||
|
"a warning the model cannot act on is not a warning"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn work_interleaved_with_repeats_is_never_halted() {
|
||||||
|
let mut run = IdenticalToolCallRun::default();
|
||||||
|
for i in 0..MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS * 2 {
|
||||||
|
let h = if i % 2 == 0 {
|
||||||
|
call("read_file", "a.rs")
|
||||||
|
} else {
|
||||||
|
call("read_file", "b.rs")
|
||||||
|
};
|
||||||
|
let len = run.observe(h, "read_file", false);
|
||||||
|
assert!(
|
||||||
|
len < run.hard_stop_threshold(),
|
||||||
|
"alternating calls are progress, not a loop"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_a_bare_true_counts_as_a_no_op() {
|
||||||
|
assert!(command_is_true("true"));
|
||||||
|
assert!(command_is_true(" true "));
|
||||||
|
assert!(command_is_true("TRUE"));
|
||||||
|
assert!(!command_is_true("true && make"));
|
||||||
|
assert!(!command_is_true("truely"));
|
||||||
|
assert!(!command_is_true(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
//! Swarm mode: a standing instruction to split work across a fleet.
|
||||||
|
//!
|
||||||
|
//! The `agent_swarm` tool works with the mode off; what the mode adds is the
|
||||||
|
//! doctrine — decompose finely, give every member a disjoint scope, do not do
|
||||||
|
//! the work yourself. Kept as pure state so the turn loop decides when to
|
||||||
|
//! inject and the session decides when to persist.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Why the mode is on, which is what decides when it turns off.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SwarmTrigger {
|
||||||
|
/// `/swarm on` (or a bare `/swarm` toggle): stays until switched off.
|
||||||
|
Manual,
|
||||||
|
/// `/swarm <task>`: armed for exactly that turn.
|
||||||
|
Task,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session's swarm-mode state. `None` means off.
|
||||||
|
///
|
||||||
|
/// Deliberately NOT persisted, unlike `/goal` and `/graph`: those drive
|
||||||
|
/// autonomous multi-turn work that is stranded if it is lost, whereas this is
|
||||||
|
/// a prompt hint whose worst-case recovery is typing `/swarm on` again. The
|
||||||
|
/// injected doctrine IS durable (it rides the conversation), so a resumed
|
||||||
|
/// session can read as "off" with the instruction still in context — which is
|
||||||
|
/// exactly why an explicit `/swarm off` always retracts (see
|
||||||
|
/// [`SessionActor::apply_swarm_mode`]) rather than trusting a remembered flag
|
||||||
|
/// that a restore, a compaction or a fork can each falsify.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub struct SwarmMode {
|
||||||
|
trigger: Option<SwarmTrigger>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SwarmMode {
|
||||||
|
pub fn is_active(self) -> bool {
|
||||||
|
self.trigger.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn trigger(self) -> Option<SwarmTrigger> {
|
||||||
|
self.trigger
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arms the mode, and does NOTHING if it is already armed.
|
||||||
|
///
|
||||||
|
/// Total no-op, not just "don't re-inject": overwriting the trigger would
|
||||||
|
/// let the `/swarm <task>` shorthand downgrade a standing `/swarm on` into
|
||||||
|
/// a per-turn mode, which then disarms itself at that turn's end — the
|
||||||
|
/// user's deliberate choice, silently undone.
|
||||||
|
///
|
||||||
|
/// Returns whether the caller should inject the enter reminder.
|
||||||
|
pub fn enter(&mut self, trigger: SwarmTrigger) -> bool {
|
||||||
|
if self.trigger.is_some() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.trigger = Some(trigger);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disarms. Returns whether the mode had been armed — which is what an
|
||||||
|
/// AUTOMATIC expiry keys its retraction off. An explicit `/swarm off` must
|
||||||
|
/// retract regardless (see the type docs).
|
||||||
|
pub fn exit(&mut self) -> bool {
|
||||||
|
self.trigger.take().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a turn ending now should disarm the mode.
|
||||||
|
///
|
||||||
|
/// Only the `Task` trigger auto-exits: `/swarm on` is a standing choice the
|
||||||
|
/// user made and a turn boundary is not a reason to undo it.
|
||||||
|
pub fn expires_at_turn_end(self) -> bool {
|
||||||
|
self.trigger == Some(SwarmTrigger::Task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The doctrine injected when the mode is armed.
|
||||||
|
///
|
||||||
|
/// Deliberately short: it is re-read on every turn it is live, and the tool's
|
||||||
|
/// own description already carries the mechanics.
|
||||||
|
pub const SWARM_ENTER_REMINDER: &str = "\
|
||||||
|
Swarm mode is on. Explore only as far as you must to identify the work, then \
|
||||||
|
split it: use the agent_swarm tool with one item per independent scope rather \
|
||||||
|
than doing the work yourself. Decompose finely — do not try to conserve \
|
||||||
|
members. Every member must own a disjoint scope; members share one working \
|
||||||
|
tree, so two members told to touch the same file will corrupt each other. \
|
||||||
|
Read-only scopes may overlap. If the work genuinely does not split, say so and \
|
||||||
|
carry on alone.";
|
||||||
|
|
||||||
|
/// Injected when the mode is switched off mid-conversation, so the earlier
|
||||||
|
/// doctrine does not keep steering the model.
|
||||||
|
/// Deliberately as emphatic as the enter doctrine it revokes: a one-line
|
||||||
|
/// "mode is off" is the weaker of the two texts in context and the likelier to
|
||||||
|
/// be summarised away, leaving the fan-out directives still steering.
|
||||||
|
pub const SWARM_EXIT_REMINDER: &str = "\
|
||||||
|
Swarm mode is off. The swarm instructions above no longer apply — you are not \
|
||||||
|
required to split work across subagents, and you should not decompose a task \
|
||||||
|
just because they said to. Decide how to approach each new request from the \
|
||||||
|
request itself. Delegate only where it clearly helps.";
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fresh_session_has_the_mode_off() {
|
||||||
|
let mode = SwarmMode::default();
|
||||||
|
assert!(!mode.is_active());
|
||||||
|
assert!(!mode.expires_at_turn_end());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arming_asks_for_the_doctrine_once_not_once_per_command() {
|
||||||
|
let mut mode = SwarmMode::default();
|
||||||
|
assert!(mode.enter(SwarmTrigger::Manual), "first arm injects");
|
||||||
|
assert!(
|
||||||
|
!mode.enter(SwarmTrigger::Manual),
|
||||||
|
"a repeated /swarm on must not stack a second copy"
|
||||||
|
);
|
||||||
|
assert!(mode.is_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_task_trigger_expires_at_a_turn_boundary() {
|
||||||
|
let mut manual = SwarmMode::default();
|
||||||
|
manual.enter(SwarmTrigger::Manual);
|
||||||
|
assert!(
|
||||||
|
!manual.expires_at_turn_end(),
|
||||||
|
"/swarm on is a standing choice, not a per-turn one"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut task = SwarmMode::default();
|
||||||
|
task.enter(SwarmTrigger::Task);
|
||||||
|
assert!(task.expires_at_turn_end());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exiting_retracts_exactly_once() {
|
||||||
|
let mut mode = SwarmMode::default();
|
||||||
|
mode.enter(SwarmTrigger::Manual);
|
||||||
|
assert!(mode.exit(), "the live doctrine must be retracted");
|
||||||
|
assert!(!mode.is_active());
|
||||||
|
assert!(
|
||||||
|
!mode.exit(),
|
||||||
|
"a second /swarm off has nothing left to retract"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/swarm <task>` under a standing `/swarm on` must seed the turn and
|
||||||
|
/// nothing more — it must not convert the standing mode into a per-turn
|
||||||
|
/// one that disarms itself when that turn ends.
|
||||||
|
#[test]
|
||||||
|
fn the_task_shorthand_never_downgrades_a_standing_mode() {
|
||||||
|
let mut mode = SwarmMode::default();
|
||||||
|
mode.enter(SwarmTrigger::Manual);
|
||||||
|
assert!(
|
||||||
|
!mode.enter(SwarmTrigger::Task),
|
||||||
|
"the doctrine is already in the conversation"
|
||||||
|
);
|
||||||
|
assert_eq!(mode.trigger(), Some(SwarmTrigger::Manual));
|
||||||
|
assert!(
|
||||||
|
!mode.expires_at_turn_end(),
|
||||||
|
"the user's standing /swarm on must survive the turn"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An automatic expiry has nothing to retract once the mode is already
|
||||||
|
/// off; only an explicit `/swarm off` retracts unconditionally, and that
|
||||||
|
/// rule lives at the call site, not here.
|
||||||
|
#[test]
|
||||||
|
fn exit_reports_whether_it_actually_disarmed_something() {
|
||||||
|
let mut armed = SwarmMode::default();
|
||||||
|
armed.enter(SwarmTrigger::Task);
|
||||||
|
assert!(armed.exit());
|
||||||
|
|
||||||
|
let mut idle = SwarmMode::default();
|
||||||
|
assert!(!idle.exit());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,6 +108,10 @@ pub struct WebFetchToolConfig {
|
|||||||
/// default allowlist. An explicit empty list blocks all fetches.
|
/// default allowlist. An explicit empty list blocks all fetches.
|
||||||
/// Resolution: TOML > remote settings > built-in defaults.
|
/// Resolution: TOML > remote settings > built-in defaults.
|
||||||
pub allowed_domains: Option<Vec<String>>,
|
pub allowed_domains: Option<Vec<String>>,
|
||||||
|
/// Allow fetches to explicit loopback hosts only (`localhost` /
|
||||||
|
/// `127.0.0.0/8` / `::1`). Private and metadata ranges stay blocked.
|
||||||
|
/// Resolution: TOML > `KIGI_WEB_FETCH_ALLOW_LOCAL` env > false.
|
||||||
|
pub allow_local: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebFetchToolConfig {
|
impl WebFetchToolConfig {
|
||||||
@@ -137,10 +141,15 @@ impl WebFetchToolConfig {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.or_else(|| remote_domains.map(|d| d.to_vec()));
|
.or_else(|| remote_domains.map(|d| d.to_vec()));
|
||||||
|
|
||||||
|
let allow_local = self
|
||||||
|
.allow_local
|
||||||
|
.or_else(|| kigi_config::env_bool("KIGI_WEB_FETCH_ALLOW_LOCAL"));
|
||||||
|
|
||||||
kigi_tools::implementations::kigi::web_fetch::WebFetchParams {
|
kigi_tools::implementations::kigi::web_fetch::WebFetchParams {
|
||||||
proxy_endpoint,
|
proxy_endpoint,
|
||||||
allowed_domains,
|
allowed_domains,
|
||||||
context_window_tokens,
|
context_window_tokens,
|
||||||
|
allow_local,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,6 +493,7 @@ mod tests {
|
|||||||
let local = WebFetchToolConfig {
|
let local = WebFetchToolConfig {
|
||||||
proxy_endpoint: Some("https://toml-proxy.example.com".to_owned()),
|
proxy_endpoint: Some("https://toml-proxy.example.com".to_owned()),
|
||||||
allowed_domains: Some(vec!["toml.example.com".to_owned()]),
|
allowed_domains: Some(vec!["toml.example.com".to_owned()]),
|
||||||
|
allow_local: Some(true),
|
||||||
};
|
};
|
||||||
let params = local.resolve_params(
|
let params = local.resolve_params(
|
||||||
Some("https://remote-proxy.example.com"),
|
Some("https://remote-proxy.example.com"),
|
||||||
@@ -498,6 +508,7 @@ mod tests {
|
|||||||
params.allowed_domains,
|
params.allowed_domains,
|
||||||
Some(vec!["toml.example.com".to_owned()])
|
Some(vec!["toml.example.com".to_owned()])
|
||||||
);
|
);
|
||||||
|
assert!(params.allow_local(), "the opt-in must reach the tool");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -524,6 +535,7 @@ mod tests {
|
|||||||
let params = local.resolve_params(None, None, None);
|
let params = local.resolve_params(None, None, None);
|
||||||
assert!(params.proxy_endpoint.is_none());
|
assert!(params.proxy_endpoint.is_none());
|
||||||
assert!(params.allowed_domains.is_none());
|
assert!(params.allowed_domains.is_none());
|
||||||
|
assert!(!params.allow_local(), "local access is off by default");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -531,6 +543,7 @@ mod tests {
|
|||||||
let local = WebFetchToolConfig {
|
let local = WebFetchToolConfig {
|
||||||
proxy_endpoint: None,
|
proxy_endpoint: None,
|
||||||
allowed_domains: Some(vec![]),
|
allowed_domains: Some(vec![]),
|
||||||
|
allow_local: None,
|
||||||
};
|
};
|
||||||
let params = local.resolve_params(None, Some(&["remote.example.com".to_owned()]), None);
|
let params = local.resolve_params(None, Some(&["remote.example.com".to_owned()]), None);
|
||||||
assert_eq!(params.allowed_domains, Some(vec![]));
|
assert_eq!(params.allowed_domains, Some(vec![]));
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ pub mod run;
|
|||||||
pub mod schedule;
|
pub mod schedule;
|
||||||
pub mod tool;
|
pub mod tool;
|
||||||
|
|
||||||
pub use tool::AgentSwarmTool;
|
pub use tool::{AGENT_SWARM_TOOL_NAME, AgentSwarmTool};
|
||||||
|
|||||||
@@ -341,7 +341,10 @@ fn build_request(pending: &Pending, config: &SwarmRunConfig) -> SubagentRequest
|
|||||||
SubagentRequest {
|
SubagentRequest {
|
||||||
id: uuid::Uuid::now_v7().to_string(),
|
id: uuid::Uuid::now_v7().to_string(),
|
||||||
prompt: pending.spec.prompt.clone(),
|
prompt: pending.spec.prompt.clone(),
|
||||||
description: config.description.clone(),
|
// The ITEM, not the swarm's description: every member would otherwise
|
||||||
|
// render as an identical subagent block and the user could not tell
|
||||||
|
// which one is running, or which one failed.
|
||||||
|
description: pending.spec.item.clone(),
|
||||||
subagent_type: config.subagent_type.clone(),
|
subagent_type: config.subagent_type.clone(),
|
||||||
parent_session_id: config.parent_session_id.clone(),
|
parent_session_id: config.parent_session_id.clone(),
|
||||||
parent_prompt_id: config.parent_prompt_id.clone(),
|
parent_prompt_id: config.parent_prompt_id.clone(),
|
||||||
@@ -690,6 +693,41 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Each member must be identifiable while it runs: the TUI renders one
|
||||||
|
/// subagent block per member from this description, so a shared one leaves
|
||||||
|
/// the user staring at N identical rows.
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn each_member_is_labelled_with_its_own_item() {
|
||||||
|
let backend = Arc::new(FakeBackend::default());
|
||||||
|
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let recorder = seen.clone();
|
||||||
|
let specs = specs(&["a.rs", "b.rs"]);
|
||||||
|
let config = SwarmRunConfig {
|
||||||
|
description: "review files".into(),
|
||||||
|
..config(None)
|
||||||
|
};
|
||||||
|
// `build_request` is the only place the label is set, so assert on it
|
||||||
|
// directly rather than through the backend's prompt log.
|
||||||
|
for (index, spec) in specs.iter().enumerate() {
|
||||||
|
let pending = Pending {
|
||||||
|
index,
|
||||||
|
spec: spec.clone(),
|
||||||
|
attempts: 0,
|
||||||
|
not_before: None,
|
||||||
|
};
|
||||||
|
recorder
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(build_request(&pending, &config).description);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
*seen.lock().unwrap(),
|
||||||
|
vec!["a.rs".to_string(), "b.rs".to_string()],
|
||||||
|
"each member must carry its own item, not the swarm description"
|
||||||
|
);
|
||||||
|
drop(backend);
|
||||||
|
}
|
||||||
|
|
||||||
/// Dropping the runner is what a send-now interrupt does; the members must
|
/// Dropping the runner is what a send-now interrupt does; the members must
|
||||||
/// be cancelled rather than silently detached onto the user's tree.
|
/// be cancelled rather than silently detached onto the user's tree.
|
||||||
#[tokio::test(start_paused = true)]
|
#[tokio::test(start_paused = true)]
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ For a single item, use the subagent (task) tool instead. To continue members fro
|
|||||||
swarm, pass `resume_agent_ids` mapping the agent_id values from that swarm's result to a \
|
swarm, pass `resume_agent_ids` mapping the agent_id values from that swarm's result to a \
|
||||||
follow-up prompt.";
|
follow-up prompt.";
|
||||||
|
|
||||||
|
/// Registry name, so gating code matches on one definition rather than a
|
||||||
|
/// literal that can drift from the tool id.
|
||||||
|
pub const AGENT_SWARM_TOOL_NAME: &str = "agent_swarm";
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct AgentSwarmTool;
|
pub struct AgentSwarmTool;
|
||||||
|
|
||||||
@@ -66,14 +70,14 @@ impl kigi_tool_runtime::Tool for AgentSwarmTool {
|
|||||||
type Output = ToolOutput;
|
type Output = ToolOutput;
|
||||||
|
|
||||||
fn id(&self) -> kigi_tool_protocol::ToolId {
|
fn id(&self) -> kigi_tool_protocol::ToolId {
|
||||||
kigi_tool_protocol::ToolId::new("agent_swarm").expect("valid tool id")
|
kigi_tool_protocol::ToolId::new(AGENT_SWARM_TOOL_NAME).expect("valid tool id")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn description(
|
fn description(
|
||||||
&self,
|
&self,
|
||||||
_ctx: &::kigi_tool_runtime::ListToolsContext,
|
_ctx: &::kigi_tool_runtime::ListToolsContext,
|
||||||
) -> kigi_tool_types::ToolDescription {
|
) -> kigi_tool_types::ToolDescription {
|
||||||
kigi_tool_types::ToolDescription::new("agent_swarm", DESCRIPTION)
|
kigi_tool_types::ToolDescription::new(AGENT_SWARM_TOOL_NAME, DESCRIPTION)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn capabilities(&self) -> kigi_tool_protocol::ToolCapabilities {
|
fn capabilities(&self) -> kigi_tool_protocol::ToolCapabilities {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ pub mod todo;
|
|||||||
pub mod update_goal;
|
pub mod update_goal;
|
||||||
pub mod web_fetch;
|
pub mod web_fetch;
|
||||||
pub mod web_search;
|
pub mod web_search;
|
||||||
pub use agent_swarm::AgentSwarmTool;
|
pub use agent_swarm::{AGENT_SWARM_TOOL_NAME, AgentSwarmTool};
|
||||||
pub use ask_user_question::AskUserQuestionTool;
|
pub use ask_user_question::AskUserQuestionTool;
|
||||||
pub use bash::BashTool;
|
pub use bash::BashTool;
|
||||||
pub use deploy_app::{AppBuilderDeployerConfig, DEPLOY_APP_TOOL_NAME};
|
pub use deploy_app::{AppBuilderDeployerConfig, DEPLOY_APP_TOOL_NAME};
|
||||||
|
|||||||
@@ -155,6 +155,9 @@ impl WebFetchClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Before any egress: the service must not see this.
|
||||||
|
ssrf::check_ssrf(&url, self.params.allow_local()).await?;
|
||||||
|
|
||||||
// Kimi fetch service first (OAuth sessions); local pipeline is the
|
// Kimi fetch service first (OAuth sessions); local pipeline is the
|
||||||
// fallback on any service failure (kimi-cli fetch.py `__call__`).
|
// fallback on any service failure (kimi-cli fetch.py `__call__`).
|
||||||
if let Some(service_url) = self.params.service_url.clone() {
|
if let Some(service_url) = self.params.service_url.clone() {
|
||||||
@@ -182,10 +185,15 @@ impl WebFetchClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ssrf::check_ssrf(&url).await?;
|
|
||||||
|
|
||||||
let http = self.http.get_or_rebuild()?;
|
let http = self.http.get_or_rebuild()?;
|
||||||
let result = match fetch_url(&http, &url, self.params.max_content_length()).await {
|
let result = match fetch_url(
|
||||||
|
&http,
|
||||||
|
&url,
|
||||||
|
self.params.max_content_length(),
|
||||||
|
self.params.allow_local(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(e @ WebFetchError::HttpRequest(_)) => {
|
Err(e @ WebFetchError::HttpRequest(_)) => {
|
||||||
self.http.invalidate();
|
self.http.invalidate();
|
||||||
@@ -380,6 +388,8 @@ fn validate_url(raw: &str) -> Result<Url, WebFetchError> {
|
|||||||
|
|
||||||
if let Some(host) = parsed.host_str()
|
if let Some(host) = parsed.host_str()
|
||||||
&& host.split('.').count() < 2
|
&& host.split('.').count() < 2
|
||||||
|
// `localhost` is single-label; SSRF still gates it on allow_local.
|
||||||
|
&& !ssrf::is_explicit_local_host(host)
|
||||||
{
|
{
|
||||||
return Err(WebFetchError::SingleLabelHost {
|
return Err(WebFetchError::SingleLabelHost {
|
||||||
host: host.to_string(),
|
host: host.to_string(),
|
||||||
@@ -390,9 +400,16 @@ fn validate_url(raw: &str) -> Result<Url, WebFetchError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn upgrade_to_https(url: &mut Url) {
|
fn upgrade_to_https(url: &mut Url) {
|
||||||
if url.scheme() == "http" {
|
if url.scheme() != "http" {
|
||||||
let _ = url.set_scheme("https");
|
return;
|
||||||
}
|
}
|
||||||
|
// Local dev servers rarely serve TLS; SSRF still gates them.
|
||||||
|
if let Some(host) = url.host_str()
|
||||||
|
&& ssrf::is_explicit_local_host(host)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _ = url.set_scheme("https");
|
||||||
}
|
}
|
||||||
|
|
||||||
enum FetchResult {
|
enum FetchResult {
|
||||||
@@ -409,15 +426,21 @@ enum FetchResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch a URL with manual same-host redirect handling.
|
/// Fetch a URL with manual same-host redirect handling.
|
||||||
|
///
|
||||||
|
/// Every hop is re-checked, so a rebinding name cannot pass.
|
||||||
|
/// Partial: reqwest hides the peer IP of the live connection.
|
||||||
async fn fetch_url(
|
async fn fetch_url(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
url: &Url,
|
url: &Url,
|
||||||
max_content_length: usize,
|
max_content_length: usize,
|
||||||
|
allow_local: bool,
|
||||||
) -> Result<FetchResult, WebFetchError> {
|
) -> Result<FetchResult, WebFetchError> {
|
||||||
let mut current_url = url.clone();
|
let mut current_url = url.clone();
|
||||||
let mut hops = 0;
|
let mut hops = 0;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
ssrf::check_ssrf(¤t_url, allow_local).await?;
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
.get(current_url.as_str())
|
.get(current_url.as_str())
|
||||||
.header(USER_AGENT, USER_AGENT_STRING)
|
.header(USER_AGENT, USER_AGENT_STRING)
|
||||||
@@ -439,10 +462,13 @@ async fn fetch_url(
|
|||||||
|
|
||||||
if let Some(location) = resp.headers().get("location") {
|
if let Some(location) = resp.headers().get("location") {
|
||||||
let location_str = location.to_str().unwrap_or("");
|
let location_str = location.to_str().unwrap_or("");
|
||||||
let next_url = current_url
|
let mut next_url = current_url
|
||||||
.join(location_str)
|
.join(location_str)
|
||||||
.map_err(|e| WebFetchError::InvalidRedirect(format!("{e}")))?;
|
.map_err(|e| WebFetchError::InvalidRedirect(format!("{e}")))?;
|
||||||
if is_same_host(¤t_url, &next_url) {
|
if is_same_host(¤t_url, &next_url) {
|
||||||
|
// An absolute `http://` Location would downgrade the hop.
|
||||||
|
upgrade_to_https(&mut next_url);
|
||||||
|
// check_ssrf runs at the top of the next iteration.
|
||||||
current_url = next_url;
|
current_url = next_url;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -479,13 +505,11 @@ async fn fetch_url(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Exact host equality, no `www.` stripping.
|
||||||
|
///
|
||||||
|
/// A `www` sibling has separate records, so it is cross-host.
|
||||||
fn is_same_host(a: &Url, b: &Url) -> bool {
|
fn is_same_host(a: &Url, b: &Url) -> bool {
|
||||||
fn strip_www(h: &str) -> &str {
|
a.host_str() == b.host_str()
|
||||||
h.strip_prefix("www.").unwrap_or(h)
|
|
||||||
}
|
|
||||||
let host_a = a.host_str().unwrap_or("");
|
|
||||||
let host_b = b.host_str().unwrap_or("");
|
|
||||||
strip_www(host_a) == strip_www(host_b)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn require_media_session_folder(session_folder: Option<&Path>) -> Result<&Path, WebFetchError> {
|
fn require_media_session_folder(session_folder: Option<&Path>) -> Result<&Path, WebFetchError> {
|
||||||
@@ -963,6 +987,78 @@ mod tests {
|
|||||||
assert!(matches!(err, WebFetchError::ServiceUnavailable(_)), "{err}");
|
assert!(matches!(err, WebFetchError::ServiceUnavailable(_)), "{err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A blocked target must never reach the remote fetch service.
|
||||||
|
///
|
||||||
|
/// It egresses elsewhere, so posting leaks an internal URL.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_blocked_url_never_reaches_the_fetch_service() {
|
||||||
|
use wiremock::matchers::{method, path};
|
||||||
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/fetch"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
let provider = crate::types::api_key_provider::test_support::fixed_provider("t");
|
||||||
|
let params = WebFetchParams {
|
||||||
|
service_url: Some(format!("{}/fetch", server.uri())),
|
||||||
|
..WebFetchParams::default()
|
||||||
|
};
|
||||||
|
let client = WebFetchClient::new(¶ms, Some(provider)).unwrap();
|
||||||
|
|
||||||
|
let Err(err) = client
|
||||||
|
.fetch(
|
||||||
|
"http://localhost:8080/admin?token=secret",
|
||||||
|
"c",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
panic!("a loopback target must be blocked");
|
||||||
|
};
|
||||||
|
assert!(matches!(err, WebFetchError::SsrfBlocked { .. }), "{err}");
|
||||||
|
assert!(
|
||||||
|
server.received_requests().await.unwrap().is_empty(),
|
||||||
|
"the internal URL must never be posted anywhere"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `fetch_url` gates its own target, not trusting its caller.
|
||||||
|
///
|
||||||
|
/// Only hop one is covered: same-host hops share one verdict,
|
||||||
|
/// so rebinding between them needs a live resolver to observe.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_url_blocks_a_loopback_target_without_allow_local() {
|
||||||
|
use wiremock::matchers::{method, path};
|
||||||
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/x"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_string("<p>local</p>"))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
let url = Url::parse(&format!("{}/x", server.uri())).unwrap();
|
||||||
|
let http = HttpClient::new(&WebFetchParams::default())
|
||||||
|
.unwrap()
|
||||||
|
.get_or_rebuild()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let Err(err) = fetch_url(&http, &url, 1_000_000, false).await else {
|
||||||
|
panic!("a blocked host must not be fetched");
|
||||||
|
};
|
||||||
|
assert!(matches!(err, WebFetchError::SsrfBlocked { .. }), "{err}");
|
||||||
|
assert!(
|
||||||
|
server.received_requests().await.unwrap().is_empty(),
|
||||||
|
"a blocked host must not be contacted at all"
|
||||||
|
);
|
||||||
|
|
||||||
|
let ok = fetch_url(&http, &url, 1_000_000, true).await.unwrap();
|
||||||
|
assert!(matches!(ok, FetchResult::Content { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
fn test_converter() -> htmd::HtmlToMarkdown {
|
fn test_converter() -> htmd::HtmlToMarkdown {
|
||||||
htmd::HtmlToMarkdown::builder()
|
htmd::HtmlToMarkdown::builder()
|
||||||
.skip_tags(vec![
|
.skip_tags(vec![
|
||||||
@@ -1024,9 +1120,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_url_rejects_single_label_hosts() {
|
fn validate_url_rejects_single_label_hosts() {
|
||||||
assert!(validate_url("http://localhost:8080/foo").is_err());
|
|
||||||
assert!(validate_url("http://intranet/foo").is_err());
|
assert!(validate_url("http://intranet/foo").is_err());
|
||||||
assert!(validate_url("http://metadata/computeMetadata").is_err());
|
assert!(validate_url("http://metadata/computeMetadata").is_err());
|
||||||
|
assert!(
|
||||||
|
validate_url("http://localhost:8080/foo").is_ok(),
|
||||||
|
"localhost reaches the SSRF gate, which blocks it unless opted in"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1068,6 +1167,19 @@ mod tests {
|
|||||||
assert_eq!(url.scheme(), "https");
|
assert_eq!(url.scheme(), "https");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upgrading a local host breaks the only target `allow_local` opens.
|
||||||
|
#[test]
|
||||||
|
fn upgrade_to_https_skips_explicit_local_hosts() {
|
||||||
|
for raw in ["http://127.0.0.1:8080/", "http://localhost:3000/"] {
|
||||||
|
let mut url = Url::parse(raw).unwrap();
|
||||||
|
upgrade_to_https(&mut url);
|
||||||
|
assert_eq!(url.scheme(), "http", "{raw}");
|
||||||
|
}
|
||||||
|
let mut public = Url::parse("http://example.com/").unwrap();
|
||||||
|
upgrade_to_https(&mut public);
|
||||||
|
assert_eq!(public.scheme(), "https");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn same_host_exact_match() {
|
fn same_host_exact_match() {
|
||||||
let a = Url::parse("https://example.com/a").unwrap();
|
let a = Url::parse("https://example.com/a").unwrap();
|
||||||
@@ -1075,12 +1187,24 @@ mod tests {
|
|||||||
assert!(is_same_host(&a, &b));
|
assert!(is_same_host(&a, &b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An absolute `http://` Location must not downgrade a followed hop.
|
||||||
#[test]
|
#[test]
|
||||||
fn same_host_www_stripping() {
|
fn same_host_redirect_location_reupgrades_http() {
|
||||||
|
let origin = Url::parse("https://example.com/start").unwrap();
|
||||||
|
let mut next = origin.join("http://example.com/next").unwrap();
|
||||||
|
assert_eq!(next.scheme(), "http");
|
||||||
|
assert!(is_same_host(&origin, &next));
|
||||||
|
upgrade_to_https(&mut next);
|
||||||
|
assert_eq!(next.as_str(), "https://example.com/next");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `www` sibling is a separate name, with separate records.
|
||||||
|
#[test]
|
||||||
|
fn www_subdomain_is_cross_host() {
|
||||||
let a = Url::parse("https://example.com/a").unwrap();
|
let a = Url::parse("https://example.com/a").unwrap();
|
||||||
let c = Url::parse("https://www.example.com/a").unwrap();
|
let c = Url::parse("https://www.example.com/a").unwrap();
|
||||||
assert!(is_same_host(&a, &c));
|
assert!(!is_same_host(&a, &c));
|
||||||
assert!(is_same_host(&c, &a));
|
assert!(!is_same_host(&c, &a));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -47,12 +47,20 @@ pub struct WebFetchParams {
|
|||||||
/// on any failure (kimi-cli `tools/web/fetch.py FetchURL.__call__`).
|
/// on any failure (kimi-cli `tools/web/fetch.py FetchURL.__call__`).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub service_url: Option<String>,
|
pub service_url: Option<String>,
|
||||||
|
/// Opt-in for loopback targets; off means no local access.
|
||||||
|
#[serde(default)]
|
||||||
|
pub allow_local: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
register_resource!("kigi", "WebFetch", WebFetchParams);
|
register_resource!("kigi", "WebFetch", WebFetchParams);
|
||||||
|
|
||||||
// Keep defaults here so call-sites don't have to manage unwrapping.
|
// Keep defaults here so call-sites don't have to manage unwrapping.
|
||||||
impl WebFetchParams {
|
impl WebFetchParams {
|
||||||
|
/// From config or `KIGI_WEB_FETCH_ALLOW_LOCAL`, never tool input.
|
||||||
|
pub fn allow_local(&self) -> bool {
|
||||||
|
self.allow_local.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cache_ttl_secs(&self) -> Duration {
|
pub fn cache_ttl_secs(&self) -> Duration {
|
||||||
Duration::from_secs(self.cache_ttl_secs.unwrap_or(15 * 60))
|
Duration::from_secs(self.cache_ttl_secs.unwrap_or(15 * 60))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ pub mod domain;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
mod http;
|
mod http;
|
||||||
pub(crate) mod overflow;
|
pub(crate) mod overflow;
|
||||||
mod ssrf;
|
pub mod ssrf;
|
||||||
|
|
||||||
pub use client::WebFetchClient;
|
pub use client::WebFetchClient;
|
||||||
pub use config::WebFetchParams;
|
pub use config::WebFetchParams;
|
||||||
|
|||||||
@@ -1,85 +1,143 @@
|
|||||||
//! SSRF (Server-Side Request Forgery) protection for `web_fetch`.
|
//! SSRF protection for `web_fetch`.
|
||||||
//!
|
//!
|
||||||
//! Validates that resolved IP addresses are not in private, link-local, or
|
//! Non-public targets are blocked: loopback, RFC 1918, link-local,
|
||||||
//! cloud metadata ranges before allowing outbound HTTP requests.
|
//! CGNAT, TEST-NET, reserved, ULA. Loopback is opt-in via
|
||||||
|
//! `[toolset.web_fetch] allow_local` or `KIGI_WEB_FETCH_ALLOW_LOCAL`,
|
||||||
|
//! and even then only for a literal local host.
|
||||||
//!
|
//!
|
||||||
//! Reference: [IANA IPv4 Special-Purpose Address Registry](https://www.iana.org/assignments/iana-ipv4-special-registry/)
|
//! Reference: [IANA IPv4 Special-Purpose Address Registry](https://www.iana.org/assignments/iana-ipv4-special-registry/)
|
||||||
|
|
||||||
use std::net::IpAddr;
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||||
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use super::error::WebFetchError;
|
use super::error::WebFetchError;
|
||||||
|
|
||||||
/// Returns `true` if an IP address is in a private, link-local, or cloud
|
/// Hosts allowed to reach loopback when local access is on.
|
||||||
/// metadata range that should be blocked to prevent SSRF attacks.
|
|
||||||
///
|
///
|
||||||
/// **Allowed:** loopback (`127.x` / `::1`) for local development.
|
/// Names that merely RESOLVE to loopback are excluded: DNS rebinding.
|
||||||
/// **Blocked:** RFC 1918, link-local, CGNAT/cloud metadata, unspecified.
|
pub fn is_explicit_local_host(host: &str) -> bool {
|
||||||
pub(crate) fn is_blocked_ip(ip: &IpAddr) -> bool {
|
let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
|
||||||
|
let host = host
|
||||||
|
.strip_prefix('[')
|
||||||
|
.and_then(|h| h.strip_suffix(']'))
|
||||||
|
.unwrap_or(&host);
|
||||||
|
// Drop an IPv6 zone id such as `fe80::1%lo0`.
|
||||||
|
let host = host.split('%').next().unwrap_or(host);
|
||||||
|
|
||||||
|
if host == "localhost" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
host.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an IP is not globally routable.
|
||||||
|
pub fn is_non_public_ip(ip: &IpAddr) -> bool {
|
||||||
match ip {
|
match ip {
|
||||||
IpAddr::V4(v4) => {
|
IpAddr::V4(v4) => is_non_public_ipv4(*v4),
|
||||||
let octets = v4.octets();
|
IpAddr::V6(v6) => is_non_public_ipv6(*v6),
|
||||||
// Loopback (127.0.0.0/8) — allowed for local dev servers.
|
}
|
||||||
if octets[0] == 127 {
|
}
|
||||||
return false;
|
|
||||||
}
|
fn is_non_public_ipv4(ip: Ipv4Addr) -> bool {
|
||||||
// RFC 1918: 10.0.0.0/8 — private network.
|
ip.is_loopback()
|
||||||
if octets[0] == 10 {
|
|| ip.is_private()
|
||||||
return true;
|
|| ip.is_link_local()
|
||||||
}
|
|| ip.is_unspecified()
|
||||||
// RFC 1918: 172.16.0.0/12 — private network.
|
|| ip.is_multicast()
|
||||||
if octets[0] == 172 && (16..=31).contains(&octets[1]) {
|
|| ip.is_broadcast()
|
||||||
return true;
|
// "This network" (RFC 1122) 0.0.0.0/8
|
||||||
}
|
|| ipv4_in_cidr(ip, [0, 0, 0, 0], 8)
|
||||||
// RFC 1918: 192.168.0.0/16 — private network.
|
// CGNAT (RFC 6598) — some clouds serve metadata here
|
||||||
if octets[0] == 192 && octets[1] == 168 {
|
|| ipv4_in_cidr(ip, [100, 64, 0, 0], 10)
|
||||||
return true;
|
// IETF Protocol Assignments (RFC 6890)
|
||||||
}
|
|| ipv4_in_cidr(ip, [192, 0, 0, 0], 24)
|
||||||
// RFC 3927: 169.254.0.0/16 — link-local.
|
// TEST-NET-1 (RFC 5737)
|
||||||
// Includes AWS/GCP/Azure metadata endpoint 169.254.169.254.
|
|| ipv4_in_cidr(ip, [192, 0, 2, 0], 24)
|
||||||
if octets[0] == 169 && octets[1] == 254 {
|
// Benchmarking (RFC 2544)
|
||||||
return true;
|
|| ipv4_in_cidr(ip, [198, 18, 0, 0], 15)
|
||||||
}
|
// TEST-NET-2 / TEST-NET-3
|
||||||
// RFC 6598: 100.64.0.0/10 — CGNAT / shared address space.
|
|| ipv4_in_cidr(ip, [198, 51, 100, 0], 24)
|
||||||
// Used by some cloud providers for internal metadata services.
|
|| ipv4_in_cidr(ip, [203, 0, 113, 0], 24)
|
||||||
if octets[0] == 100 && (64..=127).contains(&octets[1]) {
|
// Reserved (RFC 6890)
|
||||||
return true;
|
|| ipv4_in_cidr(ip, [240, 0, 0, 0], 4)
|
||||||
}
|
}
|
||||||
if v4.is_unspecified() {
|
|
||||||
return true;
|
fn ipv4_in_cidr(ip: Ipv4Addr, base: [u8; 4], prefix: u8) -> bool {
|
||||||
}
|
debug_assert!(prefix <= 32, "IPv4 prefix out of range");
|
||||||
false
|
let ip = u32::from(ip);
|
||||||
}
|
let base = u32::from(Ipv4Addr::from(base));
|
||||||
|
let mask = if prefix == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
u32::MAX << (32 - prefix)
|
||||||
|
};
|
||||||
|
(ip & mask) == (base & mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_non_public_ipv6(ip: Ipv6Addr) -> bool {
|
||||||
|
// Identity wins: `::1` is not judged as `0.0.0.1`.
|
||||||
|
if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if let Some(v4) = embedded_ipv4(ip) {
|
||||||
|
return is_non_public_ipv4(v4);
|
||||||
|
}
|
||||||
|
let seg = ip.segments();
|
||||||
|
ip.is_unique_local()
|
||||||
|
|| ip.is_unicast_link_local()
|
||||||
|
// Deprecated site-local (RFC 3879) fec0::/10
|
||||||
|
|| (seg[0] & 0xffc0) == 0xfec0
|
||||||
|
// Documentation (RFC 3849) 2001:db8::/32
|
||||||
|
|| (seg[0] == 0x2001 && seg[1] == 0x0db8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// IPv4 reachable through a known IPv6 wrapper, if any.
|
||||||
|
///
|
||||||
|
/// Covers mapped, compatible, well-known NAT64, and 6to4. Not complete:
|
||||||
|
/// network-specific NAT64 prefixes (RFC 6052) cannot be enumerated.
|
||||||
|
fn embedded_ipv4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
|
||||||
|
let seg = ip.segments();
|
||||||
|
let embedded = |hi: u16, lo: u16| Ipv4Addr::from(u32::from(hi) << 16 | u32::from(lo));
|
||||||
|
|
||||||
|
if seg[0] == 0x0064 && seg[1] == 0xff9b && seg[2..6] == [0, 0, 0, 0] {
|
||||||
|
return Some(embedded(seg[6], seg[7]));
|
||||||
|
}
|
||||||
|
if seg[0] == 0x2002 {
|
||||||
|
return Some(embedded(seg[1], seg[2]));
|
||||||
|
}
|
||||||
|
// Covers `::ffff:a.b.c.d` and the deprecated `::a.b.c.d`.
|
||||||
|
ip.to_ipv4()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loopback including IPv4-mapped forms like `::ffff:127.0.0.1`.
|
||||||
|
///
|
||||||
|
/// `IpAddr::is_loopback` is false for mapped addresses, so the opt-in path
|
||||||
|
/// cannot use it directly.
|
||||||
|
fn is_loopback_addr(ip: &IpAddr) -> bool {
|
||||||
|
match ip {
|
||||||
|
IpAddr::V4(v4) => v4.is_loopback(),
|
||||||
IpAddr::V6(v6) => {
|
IpAddr::V6(v6) => {
|
||||||
// ::1 — loopback, allowed for local dev.
|
v6.is_loopback() || v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback())
|
||||||
if v6.is_loopback() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if v6.is_unspecified() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// IPv4-mapped IPv6 (::ffff:x.x.x.x) — delegate to v4 checks.
|
|
||||||
if let Some(v4) = v6.to_ipv4_mapped() {
|
|
||||||
return is_blocked_ip(&IpAddr::V4(v4));
|
|
||||||
}
|
|
||||||
let segments = v6.segments();
|
|
||||||
// RFC 4291: fe80::/10 — link-local unicast.
|
|
||||||
if segments[0] & 0xffc0 == 0xfe80 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// RFC 4193: fc00::/7 — unique local address (ULA).
|
|
||||||
if segments[0] & 0xfe00 == 0xfc00 {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve hostname via DNS and verify none of the resolved addresses are
|
/// Dual gate: loopback opens only for an explicit local host.
|
||||||
/// in blocked private/link-local ranges.
|
///
|
||||||
pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> {
|
/// Private and link-local never open through this flag.
|
||||||
|
/// Shared with the hook runner: one policy, every outbound URL.
|
||||||
|
pub fn is_blocked_for_host(ip: &IpAddr, host: &str, allow_local: bool) -> bool {
|
||||||
|
if !is_non_public_ip(ip) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
!(allow_local && is_loopback_addr(ip) && is_explicit_local_host(host))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies no resolved address is blocked by the SSRF policy.
|
||||||
|
///
|
||||||
|
/// `allow_local` is config-only so the model cannot flip it.
|
||||||
|
pub(crate) async fn check_ssrf(url: &Url, allow_local: bool) -> Result<(), WebFetchError> {
|
||||||
let host = url
|
let host = url
|
||||||
.host_str()
|
.host_str()
|
||||||
.ok_or_else(|| WebFetchError::SingleLabelHost {
|
.ok_or_else(|| WebFetchError::SingleLabelHost {
|
||||||
@@ -87,7 +145,7 @@ pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||||
if is_blocked_ip(&ip) {
|
if is_blocked_for_host(&ip, host, allow_local) {
|
||||||
return Err(WebFetchError::SsrfBlocked {
|
return Err(WebFetchError::SsrfBlocked {
|
||||||
host: host.to_string(),
|
host: host.to_string(),
|
||||||
ip,
|
ip,
|
||||||
@@ -112,7 +170,7 @@ pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> {
|
|||||||
|
|
||||||
addrs
|
addrs
|
||||||
.iter()
|
.iter()
|
||||||
.find(|addr| is_blocked_ip(&addr.ip()))
|
.find(|addr| is_blocked_for_host(&addr.ip(), host, allow_local))
|
||||||
.map_or(Ok(()), |addr| {
|
.map_or(Ok(()), |addr| {
|
||||||
Err(WebFetchError::SsrfBlocked {
|
Err(WebFetchError::SsrfBlocked {
|
||||||
host: host.to_string(),
|
host: host.to_string(),
|
||||||
@@ -127,86 +185,201 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocks_rfc1918_10x() {
|
fn blocks_rfc1918_10x() {
|
||||||
assert!(is_blocked_ip(&"10.0.0.1".parse().unwrap()));
|
assert!(is_non_public_ip(&"10.0.0.1".parse().unwrap()));
|
||||||
assert!(is_blocked_ip(&"10.255.255.255".parse().unwrap()));
|
assert!(is_non_public_ip(&"10.255.255.255".parse().unwrap()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocks_rfc1918_172x() {
|
fn blocks_rfc1918_172x() {
|
||||||
assert!(is_blocked_ip(&"172.16.0.1".parse().unwrap()));
|
assert!(is_non_public_ip(&"172.16.0.1".parse().unwrap()));
|
||||||
assert!(is_blocked_ip(&"172.31.255.255".parse().unwrap()));
|
assert!(is_non_public_ip(&"172.31.255.255".parse().unwrap()));
|
||||||
assert!(!is_blocked_ip(&"172.15.0.1".parse().unwrap()));
|
assert!(!is_non_public_ip(&"172.15.0.1".parse().unwrap()));
|
||||||
assert!(!is_blocked_ip(&"172.32.0.1".parse().unwrap()));
|
assert!(!is_non_public_ip(&"172.32.0.1".parse().unwrap()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocks_rfc1918_192168() {
|
fn blocks_rfc1918_192168() {
|
||||||
assert!(is_blocked_ip(&"192.168.0.1".parse().unwrap()));
|
assert!(is_non_public_ip(&"192.168.0.1".parse().unwrap()));
|
||||||
assert!(is_blocked_ip(&"192.168.255.255".parse().unwrap()));
|
assert!(is_non_public_ip(&"192.168.255.255".parse().unwrap()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocks_link_local() {
|
fn blocks_link_local() {
|
||||||
assert!(is_blocked_ip(&"169.254.0.1".parse().unwrap()));
|
assert!(is_non_public_ip(&"169.254.0.1".parse().unwrap()));
|
||||||
assert!(is_blocked_ip(&"169.254.169.254".parse().unwrap()));
|
assert!(is_non_public_ip(&"169.254.169.254".parse().unwrap()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocks_cgnat_cloud_metadata() {
|
fn blocks_cgnat_cloud_metadata() {
|
||||||
assert!(is_blocked_ip(&"100.64.0.1".parse().unwrap()));
|
assert!(is_non_public_ip(&"100.64.0.1".parse().unwrap()));
|
||||||
assert!(is_blocked_ip(&"100.127.255.255".parse().unwrap()));
|
assert!(is_non_public_ip(&"100.127.255.255".parse().unwrap()));
|
||||||
assert!(!is_blocked_ip(&"100.63.0.1".parse().unwrap()));
|
assert!(!is_non_public_ip(&"100.63.0.1".parse().unwrap()));
|
||||||
assert!(!is_blocked_ip(&"100.128.0.1".parse().unwrap()));
|
assert!(!is_non_public_ip(&"100.128.0.1".parse().unwrap()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocks_unspecified() {
|
fn blocks_unspecified() {
|
||||||
assert!(is_blocked_ip(&"0.0.0.0".parse().unwrap()));
|
assert!(is_non_public_ip(&"0.0.0.0".parse().unwrap()));
|
||||||
assert!(is_blocked_ip(&"::".parse().unwrap()));
|
assert!(is_non_public_ip(&"::".parse().unwrap()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn allows_loopback() {
|
fn blocks_loopback_by_default() {
|
||||||
assert!(!is_blocked_ip(&"127.0.0.1".parse().unwrap()));
|
for ip in ["127.0.0.1", "127.0.0.2", "::1", "::ffff:127.0.0.1"] {
|
||||||
assert!(!is_blocked_ip(&"127.0.0.2".parse().unwrap()));
|
let ip: IpAddr = ip.parse().unwrap();
|
||||||
assert!(!is_blocked_ip(&"::1".parse().unwrap()));
|
assert!(is_non_public_ip(&ip), "{ip} must not be public");
|
||||||
|
assert!(
|
||||||
|
is_blocked_for_host(&ip, "localhost", false),
|
||||||
|
"{ip} must be blocked without allow_local"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allow_local_opens_loopback_only_for_an_explicit_local_host() {
|
||||||
|
for (ip, host) in [
|
||||||
|
("127.0.0.1", "localhost"),
|
||||||
|
("127.0.0.1", "127.0.0.1"),
|
||||||
|
("::1", "::1"),
|
||||||
|
("::ffff:127.0.0.1", "localhost"),
|
||||||
|
] {
|
||||||
|
let ip: IpAddr = ip.parse().unwrap();
|
||||||
|
assert!(!is_blocked_for_host(&ip, host, true), "{ip} via {host}");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
is_blocked_for_host(&"127.0.0.1".parse().unwrap(), "evil.example.com", true),
|
||||||
|
"a public name resolving to loopback is DNS rebinding"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allow_local_never_opens_private_or_link_local() {
|
||||||
|
for ip in ["10.0.0.1", "169.254.169.254", "192.168.1.1"] {
|
||||||
|
let ip: IpAddr = ip.parse().unwrap();
|
||||||
|
assert!(
|
||||||
|
is_blocked_for_host(&ip, "localhost", true),
|
||||||
|
"{ip} must stay blocked even with allow_local"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocks_test_net_and_reserved_ranges() {
|
||||||
|
for ip in [
|
||||||
|
"0.0.0.1",
|
||||||
|
"192.0.0.1",
|
||||||
|
"192.0.2.1",
|
||||||
|
"198.18.0.1",
|
||||||
|
"198.19.255.255",
|
||||||
|
"198.51.100.1",
|
||||||
|
"203.0.113.1",
|
||||||
|
"240.0.0.1",
|
||||||
|
] {
|
||||||
|
assert!(is_non_public_ip(&ip.parse().unwrap()), "{ip}");
|
||||||
|
}
|
||||||
|
// Neighbours of every range above must stay reachable.
|
||||||
|
for ip in [
|
||||||
|
"1.0.0.1",
|
||||||
|
"192.0.1.1",
|
||||||
|
"192.0.3.1",
|
||||||
|
"198.17.255.255",
|
||||||
|
"198.20.0.1",
|
||||||
|
"198.51.101.1",
|
||||||
|
"203.0.114.1",
|
||||||
|
"223.255.255.255",
|
||||||
|
] {
|
||||||
|
assert!(!is_non_public_ip(&ip.parse().unwrap()), "{ip}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A v6 record can smuggle v4 through four wrapper prefixes.
|
||||||
|
#[test]
|
||||||
|
fn blocks_ipv4_smuggled_through_ipv6_wrappers() {
|
||||||
|
for ip in [
|
||||||
|
"64:ff9b::a9fe:a9fe",
|
||||||
|
"64:ff9b::7f00:1",
|
||||||
|
"2002:7f00:1::",
|
||||||
|
"::7f00:1",
|
||||||
|
"::a00:1",
|
||||||
|
"fec0::1",
|
||||||
|
"2001:db8::1",
|
||||||
|
] {
|
||||||
|
assert!(is_non_public_ip(&ip.parse().unwrap()), "{ip}");
|
||||||
|
}
|
||||||
|
for ip in ["64:ff9b::808:808", "2002:808:808::", "2001:db9::1"] {
|
||||||
|
assert!(!is_non_public_ip(&ip.parse().unwrap()), "{ip}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_local_host_tolerates_brackets_dots_and_zone_ids() {
|
||||||
|
for host in [
|
||||||
|
"localhost",
|
||||||
|
"LOCALHOST.",
|
||||||
|
"127.0.0.1",
|
||||||
|
"127.1.2.3",
|
||||||
|
"::1",
|
||||||
|
"[::1]",
|
||||||
|
"::1%lo0",
|
||||||
|
] {
|
||||||
|
assert!(is_explicit_local_host(host), "{host}");
|
||||||
|
}
|
||||||
|
for host in [
|
||||||
|
"example.com",
|
||||||
|
"notlocalhost",
|
||||||
|
"localhost.evil.com",
|
||||||
|
"10.0.0.1",
|
||||||
|
] {
|
||||||
|
assert!(!is_explicit_local_host(host), "{host}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn allows_public_ips() {
|
fn allows_public_ips() {
|
||||||
assert!(!is_blocked_ip(&"1.1.1.1".parse().unwrap()));
|
for ip in [
|
||||||
assert!(!is_blocked_ip(&"8.8.8.8".parse().unwrap()));
|
"1.1.1.1",
|
||||||
assert!(!is_blocked_ip(&"142.250.80.46".parse().unwrap()));
|
"8.8.8.8",
|
||||||
|
"142.250.80.46",
|
||||||
|
// Global unicast v6: guards the new masks against over-matching.
|
||||||
|
"2606:4700::1111",
|
||||||
|
"2001:4860:4860::8888",
|
||||||
|
] {
|
||||||
|
assert!(!is_non_public_ip(&ip.parse().unwrap()), "{ip}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocks_ipv6_link_local() {
|
fn blocks_ipv6_link_local() {
|
||||||
assert!(is_blocked_ip(&"fe80::1".parse().unwrap()));
|
assert!(is_non_public_ip(&"fe80::1".parse().unwrap()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocks_ipv6_unique_local() {
|
fn blocks_ipv6_unique_local() {
|
||||||
assert!(is_blocked_ip(&"fc00::1".parse().unwrap()));
|
assert!(is_non_public_ip(&"fc00::1".parse().unwrap()));
|
||||||
assert!(is_blocked_ip(&"fd00::1".parse().unwrap()));
|
assert!(is_non_public_ip(&"fd00::1".parse().unwrap()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn blocks_ipv4_mapped_ipv6_private() {
|
fn blocks_ipv4_mapped_ipv6_private() {
|
||||||
assert!(is_blocked_ip(&"::ffff:10.0.0.1".parse::<IpAddr>().unwrap()));
|
assert!(is_non_public_ip(
|
||||||
assert!(is_blocked_ip(
|
&"::ffff:10.0.0.1".parse::<IpAddr>().unwrap()
|
||||||
|
));
|
||||||
|
assert!(is_non_public_ip(
|
||||||
&"::ffff:192.168.1.1".parse::<IpAddr>().unwrap()
|
&"::ffff:192.168.1.1".parse::<IpAddr>().unwrap()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn allows_ipv4_mapped_ipv6_public() {
|
fn allows_ipv4_mapped_ipv6_public() {
|
||||||
assert!(!is_blocked_ip(&"::ffff:8.8.8.8".parse::<IpAddr>().unwrap()));
|
assert!(!is_non_public_ip(
|
||||||
|
&"::ffff:8.8.8.8".parse::<IpAddr>().unwrap()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ssrf_blocks_ip_literal_private() {
|
async fn ssrf_blocks_ip_literal_private() {
|
||||||
let url = Url::parse("https://10.0.0.1/secret").unwrap();
|
let url = Url::parse("https://10.0.0.1/secret").unwrap();
|
||||||
let result = check_ssrf(&url).await;
|
let result = check_ssrf(&url, false).await;
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert!(result.unwrap_err().to_string().contains("private"));
|
assert!(result.unwrap_err().to_string().contains("private"));
|
||||||
}
|
}
|
||||||
@@ -214,7 +387,19 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ssrf_allows_ip_literal_public() {
|
async fn ssrf_allows_ip_literal_public() {
|
||||||
let url = Url::parse("https://1.1.1.1/").unwrap();
|
let url = Url::parse("https://1.1.1.1/").unwrap();
|
||||||
let result = check_ssrf(&url).await;
|
let result = check_ssrf(&url, false).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ssrf_blocks_loopback_literal_by_default() {
|
||||||
|
let url = Url::parse("http://127.0.0.1:8080/").unwrap();
|
||||||
|
assert!(check_ssrf(&url, false).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ssrf_allows_loopback_literal_when_opted_in() {
|
||||||
|
let url = Url::parse("http://127.0.0.1:8080/").unwrap();
|
||||||
|
assert!(check_ssrf(&url, true).await.is_ok());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,8 +225,16 @@ timeout_secs = 1800 # seconds to wait when enabled (default:
|
|||||||
[toolset.web_fetch]
|
[toolset.web_fetch]
|
||||||
proxy_endpoint = "https://proxy.example.com" # egress proxy URL
|
proxy_endpoint = "https://proxy.example.com" # egress proxy URL
|
||||||
allowed_domains = ["docs.rs", "x.ai"] # override the built-in allowlist
|
allowed_domains = ["docs.rs", "x.ai"] # override the built-in allowlist
|
||||||
|
allow_local = false # true = reach localhost / 127.0.0.0/8 / ::1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`allow_local` opens **loopback only**, and only when the URL names it
|
||||||
|
explicitly (`http://127.0.0.1:8080/`, `http://localhost:3000/`). A public
|
||||||
|
domain whose DNS record points at loopback stays blocked — that is DNS
|
||||||
|
rebinding, not local development. Private, link-local, CGNAT and cloud
|
||||||
|
metadata ranges are never reachable, with or without this flag. Precedence:
|
||||||
|
user config → `KIGI_WEB_FETCH_ALLOW_LOCAL` → off.
|
||||||
|
|
||||||
`[toolset.ask_user_question]` is honored across **requirements.toml**, **managed
|
`[toolset.ask_user_question]` is honored across **requirements.toml**, **managed
|
||||||
config**, and **user `config.toml`**. Precedence: requirements → env
|
config**, and **user `config.toml`**. Precedence: requirements → env
|
||||||
(`KIGI_ASK_USER_QUESTION_TIMEOUT_ENABLED` /
|
(`KIGI_ASK_USER_QUESTION_TIMEOUT_ENABLED` /
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ use super::session::lifecycle::{
|
|||||||
clear_startup_actions, dispatch_agent_type_mismatch_answered, dispatch_exit_session,
|
clear_startup_actions, dispatch_agent_type_mismatch_answered, dispatch_exit_session,
|
||||||
dispatch_new_session, dispatch_new_session_inner, dispatch_new_session_with_id,
|
dispatch_new_session, dispatch_new_session_inner, dispatch_new_session_with_id,
|
||||||
dispatch_new_worktree_session, dispatch_trust_folder, open_new_session_question,
|
dispatch_new_worktree_session, dispatch_trust_folder, open_new_session_question,
|
||||||
|
skip_picker_and_create_session,
|
||||||
};
|
};
|
||||||
use super::session::load::{
|
use super::session::load::{
|
||||||
dispatch_cycle_session_source_filter, dispatch_load_session, dispatch_pick_content_session,
|
dispatch_cycle_session_source_filter, dispatch_load_session, dispatch_pick_content_session,
|
||||||
@@ -749,7 +750,12 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
|||||||
};
|
};
|
||||||
let Some(session_id) = agent.session.session_id.clone() else {
|
let Some(session_id) = agent.session.session_id.clone() else {
|
||||||
agent.session.deferred_model_switch = Some((model_id, effort));
|
agent.session.deferred_model_switch = Some((model_id, effort));
|
||||||
return vec![];
|
// No session bound: with a create in flight this is a no-op
|
||||||
|
// and `SessionCreated` applies the stash; with none in flight
|
||||||
|
// (project question pending — only a plain prompt opens it) a
|
||||||
|
// switch would dangle forever, so start the session like the
|
||||||
|
// QueueCommand arm does for queued slash work.
|
||||||
|
return skip_picker_and_create_session(app, id);
|
||||||
};
|
};
|
||||||
agent.session.model_switch_pending = true;
|
agent.session.model_switch_pending = true;
|
||||||
vec![Effect::SwitchModel {
|
vec![Effect::SwitchModel {
|
||||||
|
|||||||
@@ -1519,12 +1519,20 @@ pub(in crate::app::dispatch) fn set_default_model(
|
|||||||
effort: None,
|
effort: None,
|
||||||
prev_model_id: prev_id.clone(),
|
prev_model_id: prev_id.clone(),
|
||||||
});
|
});
|
||||||
} else if let Some(agent) = app.agents.get_mut(&aid) {
|
} else {
|
||||||
// No session id yet — stash for
|
if let Some(agent) = app.agents.get_mut(&aid) {
|
||||||
// `EventLoop::on_session_created` to apply once the session
|
// No session id yet — stash for
|
||||||
// id materialises. Mirrors the deferred-switch handling in
|
// `EventLoop::on_session_created` to apply once the session
|
||||||
// `Action::SwitchModel`.
|
// id materialises. Mirrors the deferred-switch handling in
|
||||||
agent.session.deferred_model_switch = Some((new_id, None));
|
// `Action::SwitchModel`.
|
||||||
|
agent.session.deferred_model_switch = Some((new_id, None));
|
||||||
|
}
|
||||||
|
// With no create in flight (project question pending), the stash
|
||||||
|
// would dangle forever — start the session it drains into. No-op
|
||||||
|
// when a create is already pending.
|
||||||
|
effects.extend(
|
||||||
|
crate::app::dispatch::session::lifecycle::skip_picker_and_create_session(app, aid),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
effects
|
effects
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -481,7 +481,7 @@ fn session_failed_clears_flag_no_fetches() {
|
|||||||
assert!(!app.agents[&id].pending_extensions_fetch);
|
assert!(!app.agents[&id].pending_extensions_fetch);
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn switch_model_without_session_does_nothing() {
|
fn switch_model_without_session_starts_the_session() {
|
||||||
let mut app = test_app_with_agent();
|
let mut app = test_app_with_agent();
|
||||||
let id = AgentId(0);
|
let id = AgentId(0);
|
||||||
app.agents.get_mut(&id).unwrap().session.session_id = None;
|
app.agents.get_mut(&id).unwrap().session.session_id = None;
|
||||||
@@ -493,7 +493,14 @@ fn switch_model_without_session_does_nothing() {
|
|||||||
},
|
},
|
||||||
&mut app,
|
&mut app,
|
||||||
);
|
);
|
||||||
assert!(effects.is_empty());
|
// No create was in flight, so the switch starts the session its stash
|
||||||
|
// drains into (the stash alone dangled forever — the /model-in-Downloads
|
||||||
|
// silent no-op). `model_switch_pending` flips on SessionCreated.
|
||||||
|
assert!(
|
||||||
|
effects
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, Effect::CreateSession { .. }))
|
||||||
|
);
|
||||||
assert!(!app.agents[&id].session.model_switch_pending);
|
assert!(!app.agents[&id].session.model_switch_pending);
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
@@ -668,11 +675,17 @@ fn switch_model_deferred_when_no_session_id() {
|
|||||||
},
|
},
|
||||||
&mut app,
|
&mut app,
|
||||||
);
|
);
|
||||||
assert!(effects.is_empty());
|
// Stashed for SessionCreated — and the session it drains into is started
|
||||||
|
// (no create was in flight; a bare stash never drained).
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.agents[&id].session.deferred_model_switch,
|
app.agents[&id].session.deferred_model_switch,
|
||||||
Some((model_id, None))
|
Some((model_id, None))
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
effects
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, Effect::CreateSession { .. }))
|
||||||
|
);
|
||||||
assert!(!app.agents[&id].session.model_switch_pending);
|
assert!(!app.agents[&id].session.model_switch_pending);
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -977,6 +977,77 @@ fn switch_model_pending_lifecycle() {
|
|||||||
assert!(!app.agents[&id].session.model_switch_pending);
|
assert!(!app.agents[&id].session.model_switch_pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A model switch with no session AND no create in flight (the project-picker
|
||||||
|
/// question is pending; only a plain prompt can open it) must start the
|
||||||
|
/// deferred session itself, or the stashed switch dangles forever with zero
|
||||||
|
/// feedback — `/model X eff` in `~/Downloads` looked like "the model never
|
||||||
|
/// changes". Mirrors the `QueueCommand` arm: queued slash work bypasses the
|
||||||
|
/// picker and creates the session so the stash drains.
|
||||||
|
#[test]
|
||||||
|
fn switch_model_without_session_creates_the_deferred_session() {
|
||||||
|
let mut app = test_app_with_agent();
|
||||||
|
let id = AgentId(0);
|
||||||
|
app.agents.get_mut(&id).unwrap().session.session_id = None;
|
||||||
|
// Harness cwd is `/tmp` (a non-project dir); arm the picker gate the way
|
||||||
|
// startup leaves it (the harness pre-marks it shown for other tests).
|
||||||
|
app.project_picker_shown = false;
|
||||||
|
assert!(app.needs_project_picker());
|
||||||
|
|
||||||
|
let model_id = acp::ModelId::new(std::sync::Arc::from("kigi-4.5"));
|
||||||
|
let effects = dispatch(
|
||||||
|
Action::SwitchModel {
|
||||||
|
model_id: model_id.clone(),
|
||||||
|
effort: None,
|
||||||
|
},
|
||||||
|
&mut app,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.agents[&id].session.deferred_model_switch,
|
||||||
|
Some((model_id, None)),
|
||||||
|
"switch must stay stashed for SessionCreated to apply"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
effects
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, Effect::CreateSession { .. })),
|
||||||
|
"sessionless switch must start the session the stash drains into"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same stash path while a create IS in flight (`mcp_init_progress` set):
|
||||||
|
/// no duplicate `CreateSession` — the pending create applies the stash.
|
||||||
|
#[test]
|
||||||
|
fn switch_model_with_create_in_flight_does_not_duplicate_create() {
|
||||||
|
let mut app = test_app_with_agent();
|
||||||
|
let id = AgentId(0);
|
||||||
|
{
|
||||||
|
let agent = app.agents.get_mut(&id).unwrap();
|
||||||
|
agent.session.session_id = None;
|
||||||
|
agent.mcp_init_progress = Some(crate::app::agent_view::McpInitProgress {
|
||||||
|
total: 0,
|
||||||
|
connected: 0,
|
||||||
|
started_at: std::time::Instant::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let effects = dispatch(
|
||||||
|
Action::SwitchModel {
|
||||||
|
model_id: acp::ModelId::new(std::sync::Arc::from("kigi-4.5")),
|
||||||
|
effort: None,
|
||||||
|
},
|
||||||
|
&mut app,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(app.agents[&id].session.deferred_model_switch.is_some());
|
||||||
|
assert!(
|
||||||
|
!effects
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, Effect::CreateSession { .. })),
|
||||||
|
"an in-flight create must not be duplicated"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_deferred_switch_means_no_extra_effect() {
|
fn no_deferred_switch_means_no_extra_effect() {
|
||||||
// When there is no deferred model switch, SessionCreated should
|
// When there is no deferred model switch, SessionCreated should
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ pub(crate) fn effort_description(level: ReasoningEffort) -> &'static str {
|
|||||||
ReasoningEffort::High => "Heavy reasoning",
|
ReasoningEffort::High => "Heavy reasoning",
|
||||||
ReasoningEffort::Xhigh => "Extra-heavy reasoning",
|
ReasoningEffort::Xhigh => "Extra-heavy reasoning",
|
||||||
ReasoningEffort::Max => "Maximum reasoning",
|
ReasoningEffort::Max => "Maximum reasoning",
|
||||||
ReasoningEffort::Ultra => "Ultra reasoning",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -325,6 +325,9 @@ fn build_web_fetch_config() -> kigi_tools::implementations::kigi::web_fetch::Web
|
|||||||
if let Ok(proxy) = std::env::var("KIGI_WEB_FETCH_PROXY") {
|
if let Ok(proxy) = std::env::var("KIGI_WEB_FETCH_PROXY") {
|
||||||
params.proxy_endpoint = Some(proxy);
|
params.proxy_endpoint = Some(proxy);
|
||||||
}
|
}
|
||||||
|
if kigi_config::env_bool("KIGI_WEB_FETCH_ALLOW_LOCAL") == Some(true) {
|
||||||
|
params.allow_local = Some(true);
|
||||||
|
}
|
||||||
WebFetchConfig::Enabled { params }
|
WebFetchConfig::Enabled { params }
|
||||||
}
|
}
|
||||||
#[cfg(any(test, feature = "test-support"))]
|
#[cfg(any(test, feature = "test-support"))]
|
||||||
|
|||||||
Reference in New Issue
Block a user