Compare commits

...
44 Commits
Author SHA1 Message Date
雷电芽衣 0fb971e9aa chore(legal): regenerate THIRD-PARTY-NOTICES for the 0.1.15 dependency set
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 6s
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish GitHub Release (push) Canceled after 0s
`cargo about generate about.hbs -o THIRD-PARTY-NOTICES.md` on the 0.1.15
lock: kigi crates at 0.1.15, chacha20 0.10.2.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

-375
View File
@@ -1,375 +0,0 @@
name: Release
# PRD F8, Gitea edition. On a v* tag push, build the single-file kigi binary
# for the five supported targets, package kigi-<version>-<target-triple>.
# {tar.gz|zip} (binary + LICENSE + NOTICE + THIRD-PARTY-NOTICES), and publish
# them as a Gitea Release alongside SHA256SUMS. install.sh, install.ps1 and
# auto_update::release_asset_name() all resolve these exact names — keep the
# naming in lockstep with them.
#
# RUNNERS. Gitea has no hosted runners; every label below must be a runner you
# registered yourself (docs/RELEASE.md has the register commands):
#
# ubuntu-latest Linux x86_64, container mode — the label the Dokploy box's
# runner already advertises. Builds both Linux targets;
# aarch64 is a cross-compile, so this leg installs the
# aarch64 GNU toolchain. Because the image is a bare
# runner-image, the Linux legs provision their own apt
# packages and rustup (see the provision steps below).
# Registering a dedicated `linux-x86_64:docker://...` runner
# instead is fine — swap the three labels back if you do.
# macos-arm64 Apple Silicon Mac, host mode. Builds both Darwin targets;
# x86_64 is a cross-compile against the same SDK. macOS
# CANNOT be built anywhere else — Apple's SDK licence
# restricts it to Apple hardware.
# windows-x86_64 Windows x86_64, host mode, with VS 2022 Build Tools
# (C++ workload). Needed because the tree links ring,
# mimalloc, zstd-sys and libz-sys against a static MSVC CRT
# (-C target-feature=+crt-static).
#
# SHELLS. Every step names its shell explicitly and the Windows leg never uses
# bash. On a normal Windows box `bash` resolves to WSL (not Git Bash), and
# `pwsh` (PowerShell 7) is usually absent — so Windows steps use `powershell`,
# which is the built-in 5.1. Keep those steps 5.1-compatible: no `&&`, no
# ternary, no null-coalescing.
#
# Note: because this file exists, Gitea ignores .github/workflows entirely.
#
# PUBLISH ORDER. The release is created as a draft and only made public once
# SHA256SUMS is attached, so `releases/latest` never hands the installer a
# release whose checksum manifest is still missing.
on:
push:
tags: ["v*"]
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
# Matches the version pinned by the dotslash launcher in bin/protoc. That
# launcher is not used here: it has no Windows entry and needs a `cargo
# install dotslash` first, so every platform fetches protoc directly.
PROTOC_VERSION: "29.3"
jobs:
prepare:
name: prepare draft release
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.draft.outputs.release_id }}
steps:
- uses: actions/checkout@v4
- name: Check tag matches workspace version
shell: bash
run: |
tag_version="${GITHUB_REF_NAME#v}"
cargo_version="$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1)"
if [ "$tag_version" != "$cargo_version" ]; then
echo "Tag $GITHUB_REF_NAME does not match workspace version $cargo_version" >&2
exit 1
fi
- name: Create or reuse draft release
id: draft
shell: bash
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -eu
# The runner-image is bare; jq and curl are both used below.
if ! command -v jq >/dev/null || ! command -v curl >/dev/null; then
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
export DEBIAN_FRONTEND=noninteractive
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq --no-install-recommends jq curl ca-certificates
fi
# Idempotent so a re-run of a failed tag build reuses the same draft
# instead of erroring on a duplicate tag_name.
existing="$(curl -sS -H "Authorization: token $TOKEN" \
"$API/releases/tags/$GITHUB_REF_NAME")"
release_id="$(printf '%s' "$existing" | jq -r '.id // empty')"
if [ -z "$release_id" ]; then
prerelease=false
case "$GITHUB_REF_NAME" in *-*) prerelease=true ;; esac
created="$(curl -fsS -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\",\"draft\":true,\"prerelease\":$prerelease}" \
"$API/releases")"
release_id="$(printf '%s' "$created" | jq -r '.id')"
echo "Created draft release $release_id (prerelease=$prerelease)"
else
echo "Reusing release $release_id for $GITHUB_REF_NAME"
fi
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
build:
name: build (${{ matrix.target }})
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-gnu
runner: ubuntu-latest
protoc: linux-x86_64
ext: tar.gz
linux: true
- target: aarch64-unknown-linux-gnu
runner: ubuntu-latest
protoc: linux-x86_64
ext: tar.gz
linux: true
cross_gnu: true
- target: aarch64-apple-darwin
runner: macos-arm64
protoc: osx-aarch_64
ext: tar.gz
- target: x86_64-apple-darwin
runner: macos-arm64
protoc: osx-aarch_64
ext: tar.gz
- target: x86_64-pc-windows-msvc
runner: windows-x86_64
protoc: win64
ext: zip
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
# ── linux container provisioning ──────────────────────────────────────
# The Linux legs run in a bare gitea runner-image, which ships git and
# node for the JavaScript actions but no compiler, no unzip (protoc) and
# no rustup. Host-mode macOS and Windows already have their toolchains
# installed, so this step is Linux-only.
- name: Provision Linux build deps
if: matrix.linux
shell: bash
run: |
set -eu
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
export DEBIAN_FRONTEND=noninteractive
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq --no-install-recommends \
build-essential pkg-config ca-certificates curl unzip tar jq
# ── protoc ────────────────────────────────────────────────────────────
- name: Install protoc (unix)
if: matrix.ext == 'tar.gz'
shell: bash
run: |
set -eu
url="https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-${{ matrix.protoc }}.zip"
curl -fsSL -o protoc.zip "$url"
rm -rf "$HOME/protoc"
unzip -q -o protoc.zip -d "$HOME/protoc"
chmod +x "$HOME/protoc/bin/protoc"
echo "$HOME/protoc/bin" >> "$GITHUB_PATH"
- name: Install protoc (windows)
if: matrix.ext == 'zip'
shell: powershell
run: |
$ErrorActionPreference = "Stop"
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v$env:PROTOC_VERSION/protoc-$env:PROTOC_VERSION-${{ matrix.protoc }}.zip"
Invoke-WebRequest -Uri $url -OutFile protoc.zip
$dest = Join-Path $env:USERPROFILE "protoc"
if (Test-Path $dest) { Remove-Item $dest -Recurse -Force }
Expand-Archive protoc.zip -DestinationPath $dest -Force
Add-Content -Path $env:GITHUB_PATH -Value (Join-Path $dest "bin")
# ── toolchain ─────────────────────────────────────────────────────────
# rust-toolchain.toml pins the channel (1.97.0) and preinstalls both
# Linux targets; `rustup show` materialises exactly that.
- name: Install toolchain (unix)
if: matrix.ext == 'tar.gz'
shell: bash
run: |
set -eu
# No-op on the macOS host runner, which has rustup installed already.
# `--default-toolchain none` leaves the channel choice to
# rust-toolchain.toml, which `rustup show` then materialises.
if ! command -v rustup >/dev/null; then
curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal --default-toolchain none
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.cargo/bin:$PATH"
fi
rustup show
rustup target add ${{ matrix.target }}
- name: Install toolchain (windows)
if: matrix.ext == 'zip'
shell: powershell
run: |
$ErrorActionPreference = "Stop"
rustup show
rustup target add ${{ matrix.target }}
# ring, mimalloc, zstd-sys and libz-sys are all cc-rs builds, so the
# cross target needs its own C toolchain — cc-rs picks these up from the
# target-suffixed vars.
- name: Install aarch64 cross toolchain
if: matrix.cross_gnu
shell: bash
run: |
set -eu
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu
{
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc"
echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc"
echo "CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++"
echo "AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar"
} >> "$GITHUB_ENV"
# ── build ─────────────────────────────────────────────────────────────
- name: Build kigi (unix)
if: matrix.ext == 'tar.gz'
shell: bash
run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
- name: Build kigi (windows)
if: matrix.ext == 'zip'
shell: powershell
run: |
$ErrorActionPreference = "Stop"
cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# ── package, upload, checksum ─────────────────────────────────────────
# Archives go straight to the release rather than through the artifact
# store: they are large, and the release is the only place install.sh
# can read them from anyway. Only the checksum line travels as an
# artifact, for the publish job to concatenate.
- name: Package and upload (unix)
if: matrix.ext == 'tar.gz'
shell: bash
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
run: |
set -eu
version="${GITHUB_REF_NAME#v}"
archive="kigi-${version}-${{ matrix.target }}.tar.gz"
staging="$(mktemp -d)"
cp "target/${{ matrix.target }}/release-dist/kigi" "$staging/kigi"
cp LICENSE "$staging/"
[ -f NOTICE ] && cp NOTICE "$staging/"
for f in THIRD-PARTY-NOTICES THIRD-PARTY-NOTICES.md; do
[ -f "$f" ] && cp "$f" "$staging/"
done
tar -C "$staging" -czf "$archive" .
curl -fsS -X POST \
-H "Authorization: token $TOKEN" \
-F "attachment=@${archive}" \
"$API/releases/$RELEASE_ID/assets?name=${archive}" > /dev/null
echo "Uploaded $archive"
if command -v sha256sum >/dev/null; then
sha256sum "$archive" > "checksum-${{ matrix.target }}.txt"
else
shasum -a 256 "$archive" > "checksum-${{ matrix.target }}.txt"
fi
cat "checksum-${{ matrix.target }}.txt"
- name: Package and upload (windows)
if: matrix.ext == 'zip'
shell: powershell
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
run: |
$ErrorActionPreference = "Stop"
$version = $env:GITHUB_REF_NAME.TrimStart("v")
$archive = "kigi-$version-${{ matrix.target }}.zip"
$staging = Join-Path $env:RUNNER_TEMP "staging"
if (Test-Path $staging) { Remove-Item $staging -Recurse -Force }
New-Item -ItemType Directory -Force -Path $staging | Out-Null
Copy-Item "target/${{ matrix.target }}/release-dist/kigi.exe" (Join-Path $staging "kigi.exe")
Copy-Item LICENSE $staging
if (Test-Path NOTICE) { Copy-Item NOTICE $staging }
foreach ($f in @("THIRD-PARTY-NOTICES", "THIRD-PARTY-NOTICES.md")) {
if (Test-Path $f) { Copy-Item $f $staging }
}
Compress-Archive -Path (Join-Path $staging "*") -DestinationPath $archive -Force
# System32\curl.exe, not PowerShell's `curl` alias for
# Invoke-WebRequest, which cannot do multipart uploads.
$api = "$env:API/releases/$env:RELEASE_ID/assets?name=$archive"
& "$env:SystemRoot\System32\curl.exe" -fsS -X POST `
-H "Authorization: token $env:TOKEN" `
-F "attachment=@$archive" $api
if ($LASTEXITCODE -ne 0) { throw "asset upload failed ($LASTEXITCODE)" }
Write-Host "Uploaded $archive"
# sha256sum's exact format, so the publish job can just concatenate.
# ASCII keeps the BOM out — a BOM would corrupt the first hash.
$hash = (Get-FileHash -Algorithm SHA256 -Path $archive).Hash.ToLowerInvariant()
"$hash $archive" | Out-File -FilePath "checksum-${{ matrix.target }}.txt" -Encoding ascii
Get-Content "checksum-${{ matrix.target }}.txt"
- uses: actions/upload-artifact@v3
with:
name: checksum-${{ matrix.target }}
path: checksum-${{ matrix.target }}.txt
if-no-files-found: error
publish:
name: publish release
needs: [prepare, build]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v3
with:
path: checksums
- name: Assemble SHA256SUMS
shell: bash
run: |
set -eu
# download-artifact v3 nests each artifact in its own directory. The
# Windows line arrives CRLF-terminated; strip CR so every line is LF
# for install.sh's `read -r hash name`.
find checksums -name 'checksum-*.txt' -exec cat {} + \
| tr -d '\r' | grep -v '^$' | sort -k2 > SHA256SUMS
lines="$(wc -l < SHA256SUMS)"
if [ "$lines" -ne 5 ]; then
echo "expected 5 checksum lines, got $lines" >&2
cat SHA256SUMS >&2
exit 1
fi
cat SHA256SUMS
- name: Attach SHA256SUMS and publish
shell: bash
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
run: |
set -eu
curl -fsS -X POST \
-H "Authorization: token $TOKEN" \
-F "attachment=@SHA256SUMS" \
"$API/releases/$RELEASE_ID/assets?name=SHA256SUMS" > /dev/null
# Flip the draft public only now that every asset install.sh looks
# for is attached.
curl -fsS -X PATCH \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d '{"draft":false}' \
"$API/releases/$RELEASE_ID" > /dev/null
echo "Published $GITHUB_REF_NAME"
+23
View File
@@ -14,6 +14,22 @@ official `kimi` CLI: binary `kigi`, config dir `~/.kigi`
Never read or write `~/.kimi` (except the explicit one-time read-only
import) or any `KIMI_*` env var.
Upstream sync record (update on every sync): fork baseline is upstream
snapshot `8adf901` (2026-07-16, ≈v0.2.102); bug fixes evaluated and
selectively ported through upstream `77cd7eb` (2026-08-25, ≈v1.0.10) in
the 0.1.13 cycle and through `3794978` (2026-09-09, ≈v1.0.24) in the
0.1.15 cycle. Upstream is daily "Synced from monorepo" snapshots —
the commit BODIES carry per-change bullet lists, and
`crates/codegen/xai-grok-shell/changelogs/` maps releases to dates;
start any future sync from those two, not from raw diffs.
Known limitations left by the 0.1.15 sync (fix before relying on them):
the Windows frame writer writes through `std::io::stderr`, so a console
that stops reading still blocks exit-path prints behind std's stderr lock
(unix silences fd 2 after a timed-out writer join); the bwrap command
builder re-checks `Path::exists("/data")` and can omit the devbox bind on
a stat error that the profile classification treated as present.
## Hard constraints
- **Zero egress**: only `auth.kimi.com`, `api.kimi.com`, `api.moonshot.cn`,
@@ -195,6 +211,13 @@ client-side, no backend surface.
(`MAX_SWARM_RUNTIME`) are bounded: the swarm blocks the caller's turn,
so every wait needs a ceiling. Stragglers at the deadline are reported
as still-running WITH their ids.
- Underneath every fan-out (swarm, graph, raw Task) sits the
session-scoped spawn bound: `handle_subagent_request` holds one permit
from the parent session's semaphore for the whole child run
(kigi-tools `task/admission`: 32 default via
`KIGI_MAX_CONCURRENT_SUBAGENTS`, queue|fail via
`KIGI_SUBAGENT_LIMIT_BEHAVIOR`), so wide fan-outs queue instead of
exhausting file descriptors.
- `ToolKind::AgentSwarm` is its own variant because `TemplateRenderer`'s
`by_kind` map holds ONE tool name per kind — sharing `Task` would
silently redirect `${{ tools.by_kind.task }}` in other tools' prompts.
Generated
+88 -85
View File
@@ -228,7 +228,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -251,7 +251,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -1626,9 +1626,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.1"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -1810,7 +1810,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@@ -2654,7 +2654,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -2980,7 +2980,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3958,7 +3958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b305d85504de270ad3525d726a6b69cc59ee7b2269b014387651107ab9f0755b"
dependencies = [
"bstr",
"hashbrown 0.17.1",
"hashbrown 0.15.5",
]
[[package]]
@@ -4497,9 +4497,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.15"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
@@ -5442,7 +5442,7 @@ dependencies = [
[[package]]
name = "kigi-acp-lib"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5456,7 +5456,7 @@ dependencies = [
[[package]]
name = "kigi-agent"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -5486,7 +5486,7 @@ dependencies = [
[[package]]
name = "kigi-agent-lifecycle"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"async-trait",
"tokio",
@@ -5495,7 +5495,7 @@ dependencies = [
[[package]]
name = "kigi-auth"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"async-trait",
"http 1.4.2",
@@ -5508,7 +5508,7 @@ dependencies = [
[[package]]
name = "kigi-bin"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"anyhow",
"clap",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]]
name = "kigi-chat-state"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"indexmap",
"kigi-compaction",
@@ -5560,7 +5560,7 @@ dependencies = [
[[package]]
name = "kigi-codebase-graph"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"ahash",
"clap",
@@ -5596,7 +5596,7 @@ dependencies = [
[[package]]
name = "kigi-compaction"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"anyhow",
"async-trait",
@@ -5609,7 +5609,7 @@ dependencies = [
[[package]]
name = "kigi-config"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"base64",
"blake3",
@@ -5632,7 +5632,7 @@ dependencies = [
[[package]]
name = "kigi-config-types"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"agent-client-protocol",
"indexmap",
@@ -5646,7 +5646,7 @@ dependencies = [
[[package]]
name = "kigi-crash-handler"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"backtrace",
"libc",
@@ -5657,7 +5657,7 @@ dependencies = [
[[package]]
name = "kigi-env"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"tracing",
"url",
@@ -5665,7 +5665,7 @@ dependencies = [
[[package]]
name = "kigi-fast-worktree"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"anyhow",
"bytes",
@@ -5697,7 +5697,7 @@ dependencies = [
[[package]]
name = "kigi-file-utils"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"anyhow",
"aws-config",
@@ -5721,7 +5721,7 @@ dependencies = [
[[package]]
name = "kigi-fsnotify"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"criterion",
"dunce",
@@ -5742,7 +5742,7 @@ dependencies = [
[[package]]
name = "kigi-gix-status"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"gix",
"kigi-test-utils",
@@ -5752,7 +5752,7 @@ dependencies = [
[[package]]
name = "kigi-hooks"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"fastrand",
"kigi-config",
@@ -5771,7 +5771,7 @@ dependencies = [
[[package]]
name = "kigi-hooks-plugins-types"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"serde",
"serde_json",
@@ -5779,7 +5779,7 @@ dependencies = [
[[package]]
name = "kigi-http"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"kigi-auth",
"kigi-log",
@@ -5794,7 +5794,7 @@ dependencies = [
[[package]]
name = "kigi-hunk-tracker"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"chrono",
"dunce",
@@ -5815,14 +5815,14 @@ dependencies = [
[[package]]
name = "kigi-interjection-core"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"serde",
]
[[package]]
name = "kigi-log"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"anyhow",
"chrono",
@@ -5840,7 +5840,7 @@ dependencies = [
[[package]]
name = "kigi-markdown"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"anstyle",
"anstyle-lossy",
@@ -5864,14 +5864,14 @@ dependencies = [
[[package]]
name = "kigi-markdown-core"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "kigi-mcp"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5908,7 +5908,7 @@ dependencies = [
[[package]]
name = "kigi-memory"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"anyhow",
"arc-swap",
@@ -5942,7 +5942,7 @@ dependencies = [
[[package]]
name = "kigi-mermaid"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"fontdb",
"image",
@@ -5960,7 +5960,7 @@ dependencies = [
[[package]]
name = "kigi-models"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"kigi-env",
"serde",
@@ -5970,7 +5970,7 @@ dependencies = [
[[package]]
name = "kigi-pager-minimal"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"chrono",
"crossterm",
@@ -5987,7 +5987,7 @@ dependencies = [
[[package]]
name = "kigi-pager-pty-harness"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -6012,7 +6012,7 @@ dependencies = [
[[package]]
name = "kigi-pager-render"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"agent-client-protocol",
"anstyle",
@@ -6064,7 +6064,7 @@ dependencies = [
[[package]]
name = "kigi-paths"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"camino",
"serde",
@@ -6074,7 +6074,7 @@ dependencies = [
[[package]]
name = "kigi-prompt-queue"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"serde",
"serde_json",
@@ -6082,7 +6082,7 @@ dependencies = [
[[package]]
name = "kigi-proto-build"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"anyhow",
"pbjson-build",
@@ -6093,7 +6093,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-inline"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"ansi-width",
"anstyle-parse 0.2.7",
@@ -6110,7 +6110,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-textarea"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"arboard",
"chrono",
@@ -6131,7 +6131,7 @@ dependencies = [
[[package]]
name = "kigi-sampler"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"async-openai",
"async-stream",
@@ -6141,6 +6141,7 @@ dependencies = [
"futures-util",
"indexmap",
"kigi-sampling-types",
"kigi-secrets",
"kigi-test-support",
"kigi-version",
"reqwest 0.12.28",
@@ -6154,7 +6155,7 @@ dependencies = [
[[package]]
name = "kigi-sampling-types"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"assert_matches",
"async-openai",
@@ -6171,7 +6172,7 @@ dependencies = [
[[package]]
name = "kigi-sandbox"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"anyhow",
"chrono",
@@ -6186,13 +6187,14 @@ dependencies = [
"serde",
"serde_json",
"serial_test",
"tempfile",
"toml",
"tracing",
]
[[package]]
name = "kigi-secrets"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"regex",
"serde_json",
@@ -6230,7 +6232,7 @@ dependencies = [
[[package]]
name = "kigi-shell"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6293,6 +6295,7 @@ dependencies = [
"kigi-sampler",
"kigi-sampling-types",
"kigi-sandbox",
"kigi-secrets",
"kigi-shared",
"kigi-shell-base",
"kigi-sqlite-journal",
@@ -6367,7 +6370,7 @@ dependencies = [
[[package]]
name = "kigi-shell-base"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"anyhow",
"chrono",
@@ -6392,7 +6395,7 @@ dependencies = [
[[package]]
name = "kigi-sqlite-journal"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"libc",
"rusqlite",
@@ -6403,7 +6406,7 @@ dependencies = [
[[package]]
name = "kigi-subagent-resolution"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"kigi-sampling-types",
"kigi-tool-types",
@@ -6418,7 +6421,7 @@ dependencies = [
[[package]]
name = "kigi-system-power"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"windows-sys 0.59.0",
"zbus",
@@ -6426,7 +6429,7 @@ dependencies = [
[[package]]
name = "kigi-test-support"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6448,7 +6451,7 @@ dependencies = [
[[package]]
name = "kigi-test-utils"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"runfiles",
"tracing",
@@ -6457,11 +6460,11 @@ dependencies = [
[[package]]
name = "kigi-token-estimation"
version = "0.1.12"
version = "0.1.15"
[[package]]
name = "kigi-tool-protocol"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"kigi-tool-types",
"serde",
@@ -6472,7 +6475,7 @@ dependencies = [
[[package]]
name = "kigi-tool-runtime"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"anyhow",
"async-trait",
@@ -6490,7 +6493,7 @@ dependencies = [
[[package]]
name = "kigi-tool-types"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"minijinja",
"schemars 1.2.1",
@@ -6500,7 +6503,7 @@ dependencies = [
[[package]]
name = "kigi-tools"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"anyhow",
"arc-swap",
@@ -6577,7 +6580,7 @@ dependencies = [
[[package]]
name = "kigi-tools-api"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"kigi-proto-build",
"kigi-tool-protocol",
@@ -6590,11 +6593,11 @@ dependencies = [
[[package]]
name = "kigi-tracing-macros"
version = "0.1.12"
version = "0.1.15"
[[package]]
name = "kigi-tty-utils"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"libc",
"nix 0.30.1",
@@ -6604,7 +6607,7 @@ dependencies = [
[[package]]
name = "kigi-tui"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"agent-client-protocol",
"ansi-to-tui",
@@ -6691,7 +6694,7 @@ dependencies = [
[[package]]
name = "kigi-update"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"anyhow",
"dunce",
@@ -6720,14 +6723,14 @@ dependencies = [
[[package]]
name = "kigi-version"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"semver",
]
[[package]]
name = "kigi-workspace"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6806,7 +6809,7 @@ dependencies = [
[[package]]
name = "kigi-workspace-types"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"base64",
"chrono",
@@ -7305,7 +7308,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7576,7 +7579,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -7997,7 +8000,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@@ -8840,7 +8843,7 @@ dependencies = [
[[package]]
name = "ptyctl"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -8858,7 +8861,7 @@ dependencies = [
[[package]]
name = "ptyctl-cli"
version = "0.1.12"
version = "0.1.15"
dependencies = [
"anyhow",
"axum",
@@ -8992,7 +8995,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -9617,7 +9620,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -9687,7 +9690,7 @@ dependencies = [
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -10536,7 +10539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -10937,7 +10940,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -11882,7 +11885,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -12414,15 +12417,15 @@ dependencies = [
[[package]]
name = "webbrowser"
version = "1.2.1"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72"
checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94"
dependencies = [
"core-foundation 0.10.1",
"jni",
"log",
"ndk-context",
"objc2 0.6.4",
"objc2-app-kit",
"objc2-foundation 0.3.2",
"url",
"web-sys",
@@ -12561,7 +12564,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
+1 -1
View File
@@ -76,7 +76,7 @@ members = [
]
[workspace.package]
version = "0.1.12"
version = "0.1.15"
edition = "2024"
license = "Apache-2.0"
+1 -1
View File
@@ -1,7 +1,7 @@
# Security Policy
Kigi is a community build; report vulnerabilities on the
[Kigi issue tracker](https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/issues).
[Kigi issue tracker](https://github.com/ZacharyZhang-NY/Kigi-CLI/issues).
Issues there are public, so please leave out any detail that would make a
report directly exploitable — open with the affected area and impact, and a
maintainer will follow up for the rest.
+1511 -81
View File
File diff suppressed because it is too large. Load diff
@@ -252,11 +252,20 @@ pub fn by_name(name: &str) -> Option<AgentDefinition> {
by_name_with_home(name, dirs::home_dir().as_deref(), kigi.as_deref())
}
/// An agent name is one file stem: a separator or `..` would resolve a file outside the searched directory with that directory's trust scope.
fn is_single_path_component(name: &str) -> bool {
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\'])
}
fn by_name_with_home(
name: &str,
home: Option<&Path>,
kigi_home: Option<&Path>,
) -> Option<AgentDefinition> {
if !is_single_path_component(name) {
tracing::warn!(name, "rejected agent name: not a single path component");
return None;
}
// Check built-ins first — type-safe via BuiltinAgentName strum enum.
// Legacy pre-rebrand agent types (persisted in old session files) are
// mapped onto their current names first; see `canonical_agent_type`.
@@ -298,6 +307,10 @@ fn by_name_in_cwd_with_home(
home: Option<&Path>,
kigi_home: Option<&Path>,
) -> Option<AgentDefinition> {
if !is_single_path_component(name) {
tracing::warn!(name, "rejected agent name: not a single path component");
return None;
}
if let Some(def) = load_project_definition_by_name(name, cwd) {
return Some(def);
}
@@ -827,6 +840,30 @@ mod tests {
assert!(def.is_none());
}
/// `../outside` must not load a file beyond the agents directory under that directory's scope.
#[test]
fn by_name_rejects_names_that_leave_the_agents_directory() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project");
let agents_dir = project.join(".kigi").join("agents");
fs::create_dir_all(&agents_dir).unwrap();
write_agent_file(
&project,
"outside.md",
"outside",
"Lives outside the agents dir",
);
write_agent_file(&agents_dir, "inside.md", "inside", "Lives inside");
assert!(by_name_in_cwd_with_home("../../outside", &project, None, None).is_none());
assert!(by_name_in_cwd_with_home("..", &project, None, None).is_none());
assert!(by_name_in_cwd_with_home("a/b", &project, None, None).is_none());
assert!(by_name_with_home("../../outside", None, None).is_none());
assert_eq!(
by_name_in_cwd_with_home("inside", &project, None, None).map(|d| d.name),
Some("inside".to_string())
);
}
#[test]
fn test_discover_finds_md_files() {
let tmp = tempfile::tempdir().unwrap();
+5
View File
@@ -17,6 +17,11 @@ pub enum AgentBuildError {
#[error("IO error during agent construction: {0}")]
IoError(#[from] std::io::Error),
/// The per-session runtime could not be built — fd or thread
/// exhaustion (EMFILE/EAGAIN), not a definition problem.
#[error("failed to build the session runtime: {0}")]
RuntimeBuild(std::io::Error),
/// Carries template line numbers and surrounding context.
#[error("template rendering error: {0}")]
MiniJinjaError(#[from] minijinja::Error),
@@ -32,5 +32,5 @@ fn get_global_gitignore_path() -> Option<PathBuf> {
git2::Config::open_default()
.ok()
.and_then(|cfg| cfg.get_path("core.excludesFile").ok())
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".gitignore")))
.or_else(|| std::env::home_dir().map(|h| h.join(".gitignore")))
}
+80 -29
View File
@@ -146,27 +146,33 @@ async fn run_setup_command(json: bool) {
.expect("setup report has no non-serializable values");
println!("{out}");
if !report.configured {
eprintln!(
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one."
kigi_tui::best_effort_stderr::eprint_line(
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one.",
);
}
}
Err(e) => {
eprintln!("Couldn't fetch managed configuration. {e}");
kigi_tui::best_effort_stderr::eprint_line(&format!(
"Couldn't fetch managed configuration. {e}"
));
std::process::exit(1);
}
}
return;
}
match managed_config::run_setup().await {
SetupOutcome::Installed => eprintln!("Applied managed configuration."),
SetupOutcome::Installed => {
kigi_tui::best_effort_stderr::eprint_line("Applied managed configuration.")
}
SetupOutcome::NothingConfigured => {
eprintln!(
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one."
kigi_tui::best_effort_stderr::eprint_line(
"Your team doesn't have a managed configuration yet. Ask a team admin to provision one.",
);
}
SetupOutcome::Failed(e) => {
eprintln!("Couldn't apply managed configuration. {e}");
kigi_tui::best_effort_stderr::eprint_line(&format!(
"Couldn't apply managed configuration. {e}"
));
std::process::exit(1);
}
}
@@ -208,8 +214,8 @@ async fn run_leader_mgmt(args: LeaderMgmtArgs) -> Result<()> {
println!("{info:#?}");
} else {
print_leader_descriptor(&descriptor);
eprintln!(
" (detailed info unavailable — leader does not advertise control capabilities)"
kigi_tui::best_effort_stderr::eprint_line(
" (detailed info unavailable — leader does not advertise control capabilities)",
);
}
client.cancel();
@@ -220,7 +226,7 @@ async fn run_leader_mgmt(args: LeaderMgmtArgs) -> Result<()> {
async fn kill_leaders() -> Result<()> {
let leaders = kigi_shell::leader::discover_leaders().await;
if leaders.is_empty() {
eprintln!("No leader candidates found.");
kigi_tui::best_effort_stderr::eprint_line("No leader candidates found.");
return Ok(());
}
let mut killed = 0u32;
@@ -231,7 +237,9 @@ async fn kill_leaders() -> Result<()> {
};
if !kigi_shell::util::is_kigi_process(pid) {
if let Some(ref lock) = d.lock_path {
eprintln!(" PID {pid} is not a kigi process, removing stale lock");
kigi_tui::best_effort_stderr::eprint_line(&format!(
" PID {pid} is not a kigi process, removing stale lock"
));
let _ = std::fs::remove_file(lock);
cleaned += 1;
}
@@ -240,19 +248,23 @@ async fn kill_leaders() -> Result<()> {
}
continue;
}
eprintln!(" Killing leader PID {pid}");
kigi_tui::best_effort_stderr::eprint_line(&format!(" Killing leader PID {pid}"));
if let Err(e) = kigi_shell::util::kill_process_by_pid(pid) {
eprintln!(" warning: failed to terminate PID {pid}: {e}");
kigi_tui::best_effort_stderr::eprint_line(&format!(
" warning: failed to terminate PID {pid}: {e}"
));
continue;
}
killed += 1;
}
if killed > 0 {
eprintln!("Killed {killed} leader process(es).");
kigi_tui::best_effort_stderr::eprint_line(&format!("Killed {killed} leader process(es)."));
} else if cleaned > 0 {
eprintln!("No live leader processes found (cleaned up {cleaned} stale lock(s)).");
kigi_tui::best_effort_stderr::eprint_line(&format!(
"No live leader processes found (cleaned up {cleaned} stale lock(s))."
));
} else {
eprintln!("No live leader processes found.");
kigi_tui::best_effort_stderr::eprint_line("No live leader processes found.");
}
Ok(())
}
@@ -295,7 +307,7 @@ fn print_leader_descriptor(d: &LeaderDescriptor) {
.map(|p| p.display().to_string())
.unwrap_or_else(|| "?".into());
let state = format!("{:?}", d.classification);
eprintln!(" PID {pid} ({state}) -- {sock}");
kigi_tui::best_effort_stderr::eprint_line(&format!(" PID {pid} ({state}) -- {sock}"));
}
fn leader_descriptor_json(d: &LeaderDescriptor) -> serde_json::Value {
serde_json::json!(
@@ -1221,15 +1233,34 @@ fn main() {
"Found crashed sessions from a previous run"
);
}
let runtime = tokio::runtime::Builder::new_multi_thread()
// Capped workers: tokio's per-core default pins 100+ thread slots on
// many-core shared hosts and later spawns die with EAGAIN.
let runtime = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(kigi_tty_utils::runtime::capped_worker_threads().get())
.enable_all()
.build()
.unwrap_or_else(|e| panic!("failed to start tokio runtime: {e}"));
{
Ok(runtime) => runtime,
Err(e) => {
// A host out of threads (RLIMIT_NPROC / pids.max) is an
// environment problem, not a bug — report it, don't panic.
kigi_tty_utils::restore_native_stderr();
kigi_tui::best_effort_stderr::eprint_line(&format!(
"Error: failed to start the async runtime: {e}"
));
kigi_tui::best_effort_stderr::eprint_line(
"The host may be out of threads (RLIMIT_NPROC or the cgroup pids ceiling); \
close other processes or raise the limit, then retry.",
);
std::process::exit(1);
}
};
let result = run_and_shutdown(runtime, async_main(), RUNTIME_SHUTDOWN_GRACE);
kigi_log::debug_log::flush();
if let Err(e) = result {
kigi_tty_utils::restore_native_stderr();
eprintln!("Error: {e:#}");
// fd 2 may be a closed pane: one attempt, never a panic under panic = "abort".
kigi_tui::best_effort_stderr::eprint_line(&format!("Error: {e:#}"));
std::process::exit(1);
}
}
@@ -1580,16 +1611,25 @@ async fn async_main() -> Result<()> {
let result = kigi_tui::app::run(args, bg_update_rx).await;
kigi_sandbox::flush();
match result {
Ok(true) => {
Ok(exit) if exit.quit_for_update => {
let adopted = bg_update_wait.lock().await.take();
if finish_update_on_exit(adopted, &update_config).await {
eprintln!("Update installed. Run `kigi` to start.");
} else {
eprintln!("Update did not complete. Run `kigi update` to retry.");
let installed =
finish_update_on_exit(adopted, &update_config, exit.terminal_reading).await;
// A terminal that stopped reading at teardown would block these prints.
if exit.terminal_reading {
if installed {
kigi_tui::best_effort_stderr::eprint_line(
"Update installed. Run `kigi` to start.",
);
} else {
kigi_tui::best_effort_stderr::eprint_line(
"Update did not complete. Run `kigi update` to retry.",
);
}
}
Ok(())
}
Ok(false) => Ok(()),
Ok(_) => Ok(()),
Err(e) => Err(e),
}
}
@@ -1605,10 +1645,15 @@ async fn async_main() -> Result<()> {
async fn finish_update_on_exit(
adopted: Option<tokio::task::JoinHandle<std::io::Result<std::process::ExitStatus>>>,
update_config: &UpdateConfig,
terminal_reading: bool,
) -> bool {
let run_blocking = |reason: Option<String>| async move {
// The blocking updater prints and its child inherits stderr: not on a stalled terminal.
if !terminal_reading {
return false;
}
if let Some(reason) = reason {
eprintln!("{reason}");
kigi_tui::best_effort_stderr::eprint_line(&reason);
}
auto_update::run_update_if_available(
auto_update::UpdateRunMode::Blocking,
@@ -1620,7 +1665,11 @@ async fn finish_update_on_exit(
};
match adopted {
Some(handle) => {
eprintln!("Waiting for the update download to finish...");
if terminal_reading {
kigi_tui::best_effort_stderr::eprint_line(
"Waiting for the update download to finish...",
);
}
match handle.await {
Ok(Ok(status)) if status.success() => true,
Ok(Ok(status)) => {
@@ -1793,7 +1842,9 @@ async fn signal_leaders_to_relaunch(installed_version: &str) {
to_version,
..
})) => {
eprintln!(" ↻ Relaunching shared session (leader {from_version} → {to_version})…");
kigi_tui::best_effort_stderr::eprint_line(&format!(
" ↻ Relaunching shared session (leader {from_version} → {to_version})…"
));
}
Ok(Ok(kigi_shell::leader::ControlPayload::RelaunchDeclined { reason })) => {
tracing::debug!(% reason, "Leader declined relaunch");
@@ -390,14 +390,24 @@ impl ChatStateActor {
});
}
/// Replace the entire conversation, persist, re-estimate `total_tokens`,
/// and emit reset + token-update events.
///
/// Compaction replaces carry the provider-side overhead forward as a
/// *ratio* (`base_estimate × provider_total ÷ estimate_at_last_response`,
/// capped at the pre-compaction total; `base_estimate` when that estimate is
/// 0) so the reseed neither springs back nor over-counts (see
/// `COMPACTION.md`).
/// Reseed after a rewrite: the raw estimate scaled by the provider-confirmed ratio, capped at the pre-rewrite total (see COMPACTION.md).
pub(super) fn reseed_total_tokens(&self, base_estimate: u64) -> u64 {
let pre_replace_total = self.state.total_tokens;
let mut estimated_tokens =
if pre_replace_total > 0 && self.state.estimate_at_last_response > 0 {
let ratio = pre_replace_total as f64 / self.state.estimate_at_last_response as f64;
(base_estimate as f64 * ratio).round() as u64
} else {
base_estimate
};
// Over-counting fires premature compaction; an under-count self-heals on the next response.
if pre_replace_total > 0 {
estimated_tokens = estimated_tokens.min(pre_replace_total);
}
estimated_tokens
}
/// Replace the conversation, persist, reseed `total_tokens` and emit reset + token events; `is_compaction` only marks the turn capture.
pub(super) fn replace_conversation(
&mut self,
items: Vec<ConversationItem>,
@@ -407,28 +417,16 @@ impl ChatStateActor {
if is_compaction && let Some(cap) = &mut self.state.turn_capture {
cap.compaction_occurred = true;
}
let pre_replace_total = self.state.total_tokens;
// `harness_trace_buffer` / `harness_trace_turns` intentionally untouched:
// the planner/verifier subagents ran, so their sealed trace turns survive
// a conversation replace (same intent as the `TruncateToPromptIndex` arm).
self.persistence.replace_history(&items);
let base_estimate = super::state::estimate_conversation_tokens(&items);
let mut estimated_tokens =
if is_compaction && pre_replace_total > 0 && self.state.estimate_at_last_response > 0 {
let ratio = pre_replace_total as f64 / self.state.estimate_at_last_response as f64;
(base_estimate as f64 * ratio).round() as u64
} else {
base_estimate
};
// Compaction must never appear to increase usage.
if is_compaction && pre_replace_total > 0 {
estimated_tokens = estimated_tokens.min(pre_replace_total);
}
let estimated_tokens = self.reseed_total_tokens(base_estimate);
self.state.conversation = items;
self.state.estimated_tokens_since_model = 0;
self.state.total_tokens = estimated_tokens;
self.state.estimate_at_last_response =
super::state::estimate_conversation_tokens(&self.state.conversation);
self.state.estimate_at_last_response = base_estimate;
self.rebase_turn_capture_offset();
self.send_event(ChatStateEvent::ConversationReset {
new_len: self.state.conversation.len(),
@@ -68,10 +68,10 @@ impl ChatStateActor {
self.state.conversation.truncate(truncate_at);
self.state.prompt_texts.truncate(target_prompt_index);
self.state.prompt_index = target_prompt_index;
self.state.total_tokens =
super::state::estimate_conversation_tokens(&self.state.conversation);
let base_estimate = super::state::estimate_conversation_tokens(&self.state.conversation);
self.state.total_tokens = self.reseed_total_tokens(base_estimate);
self.state.estimated_tokens_since_model = 0;
self.state.estimate_at_last_response = self.state.total_tokens;
self.state.estimate_at_last_response = base_estimate;
self.persistence.replace_history(&self.state.conversation);
@@ -68,12 +68,10 @@ pub fn estimate_item_tokens(item: &ConversationItem) -> u64 {
kigi_token_estimation::estimate_tokens(&b.text_summary())
}
ConversationItem::Reasoning(r) => {
// Summary + content text follow the standard bytes-per-token
// estimate; encrypted blobs are base64 and don't survive
// tokenization 1:1, so estimate at len/4 as well.
// Text and ciphertext are the same reasoning twice: the larger wins (base64 is 4/3 over raw).
let text_bytes = kigi_sampling_types::reasoning_item_text(r).len();
let enc_bytes = r.encrypted_content.as_deref().map(str::len).unwrap_or(0);
((text_bytes + enc_bytes) as u64) / kigi_token_estimation::BYTES_PER_TOKEN
(text_bytes.max(enc_bytes * 3 / 4) as u64) / kigi_token_estimation::BYTES_PER_TOKEN
}
}
}
@@ -658,22 +658,66 @@ async fn compaction_reseed_without_provider_count_matches_plain_estimate() {
}
#[tokio::test]
async fn non_compaction_replace_does_not_carry_overhead() {
async fn non_compaction_replace_carries_confirmed_total() {
// A rewind/mode-switch replace scales from the provider-confirmed count, not the raw estimate.
let h = TestHarness::new();
h.handle
.push_user_message(ConversationItem::user("x".repeat(4000)));
h.handle.record_token_usage(51_000);
h.handle.record_token_usage(500);
// Estimate at last response = 1_000, confirmed = 500 → ratio 0.5.
h.handle
.replace_conversation(vec![ConversationItem::user("q".repeat(4000))]);
let total = h.handle.get_total_tokens().await;
assert_eq!(
total, 1_000,
"non-compaction replace (e.g. rewind) keeps the plain estimate"
total, 500,
"same-size replace carries the provider-confirmed count"
);
}
#[tokio::test]
async fn replace_never_increases_total_tokens() {
// A growing replace is capped at the confirmed total; the under-count self-heals on the next usage.
let h = TestHarness::new();
h.handle
.push_user_message(ConversationItem::user("x".repeat(4000)));
h.handle.record_token_usage(1_500);
h.handle
.replace_conversation(vec![ConversationItem::user("q".repeat(40_000))]);
assert_eq!(h.handle.get_total_tokens().await, 1_500);
}
#[tokio::test]
async fn truncate_scales_from_confirmed_total() {
// Rewind path: TruncateToPromptIndex must use the same carry as replace_conversation.
let h = TestHarness::new();
h.handle
.push_user_message(ConversationItem::user("x".repeat(4000)));
h.handle.increment_prompt_index();
h.handle
.push_user_message(ConversationItem::user("y".repeat(4000)));
h.handle.increment_prompt_index();
h.handle.record_token_usage(1_000);
// Estimate at last response = 2_000, confirmed = 1_000 → ratio 0.5.
h.handle.truncate_to_prompt_index(1).await;
// Keeps the first user item (raw estimate 1_000) → scaled to 500.
assert_eq!(h.handle.get_total_tokens().await, 500);
}
#[test]
fn reasoning_estimate_takes_the_larger_of_text_and_ciphertext() {
// "thinking for r1" is 15 bytes; 800 base64 bytes are ~600 raw bytes.
let encrypted = reasoning_sibling("r1", Some(&"A".repeat(800)));
assert_eq!(crate::estimate_conversation_tokens(&[encrypted]), 150);
let plain = reasoning_sibling("r1", None);
assert_eq!(crate::estimate_conversation_tokens(&[plain]), 3);
}
#[tokio::test]
async fn flush_calls_persistence_flush() {
let mut h = TestHarness::new();
@@ -668,6 +668,11 @@ pub struct RemoteSettings {
/// remote settings verbatim-input flag; env (`KIGI_COMPACTION_VERBATIM_INPUT`) and config override it. `None` = default (true).
#[serde(default)]
pub compaction_verbatim_input: Option<bool>,
/// Compaction summarizer `tool_choice` (`auto` | `none`) from remote
/// settings. Env (`KIGI_COMPACTION_TOOL_CHOICE`) and config override it.
/// Some backends reject `tool_choice: "none"` with tools attached.
#[serde(default)]
pub compaction_tool_choice: Option<String>,
/// Master switch for jemalloc heap sampling + threshold dumps.
/// `Some(true)` enables, `Some(false)` kill-switch, `None` = client default off.
#[serde(default)]
+11 -13
View File
@@ -78,11 +78,10 @@ pub fn detect_windows_shell() -> &'static WindowsShell {
kigi_tty_utils::detach_std_command(&mut cmd);
cmd.arg("pwsh.exe").stdin(std::process::Stdio::null());
cmd.output()
} {
if output.status.success() {
tracing::info!("Windows shell: pwsh");
return WindowsShell::Pwsh;
}
} && output.status.success()
{
tracing::info!("Windows shell: pwsh");
return WindowsShell::Pwsh;
}
// powershell.exe (Windows PowerShell 5.1).
@@ -131,14 +130,13 @@ fn find_git_bash() -> Option<String> {
kigi_tty_utils::detach_std_command(&mut cmd);
cmd.arg("bash.exe").stdin(std::process::Stdio::null());
cmd.output()
} {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let line = line.trim();
if line.to_ascii_lowercase().contains("git") {
return Some(line.to_string());
}
} && output.status.success()
{
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let line = line.trim();
if line.to_ascii_lowercase().contains("git") {
return Some(line.to_string());
}
}
}
@@ -616,7 +616,7 @@ mod win {
}
let signal = exception_to_signal(exception_code);
let si_code = exception_code as i32;
let si_code = exception_code;
// ExceptionInformation[1] holds the faulting address for ACCESS_VIOLATION.
let si_addr = if exception_code == EXCEPTION_ACCESS_VIOLATION
+1 -1
View File
@@ -21,7 +21,7 @@ pub struct KigiEndpoints {
pub const PRODUCTION_ENDPOINTS: KigiEndpoints = KigiEndpoints {
coding_api_base_url: "https://api.kimi.com/coding/v1",
oauth_host: "https://auth.kimi.com",
update_base_url: "https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/Kigi-CLI/releases",
update_base_url: "https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases",
upgrade_page_url: "https://www.kimi.com/code/",
};
@@ -76,6 +76,7 @@ mod tests {
assert_eq!(std::fs::read(&dest).unwrap(), data);
}
#[cfg(unix)]
#[test]
fn test_clone_preserves_permissions() {
use std::os::unix::fs::PermissionsExt;
@@ -341,12 +341,14 @@ pub fn resolve_kigi_home() -> Result<PathBuf> {
if let Ok(v) = std::env::var("KIGI_SHARE_DIR") {
return Ok(PathBuf::from(v));
}
let home =
PathBuf::from(std::env::var("HOME").context("neither $KIGI_SHARE_DIR nor $HOME is set")?);
// Canonicalize the home dir so worktree paths share the same physical .kigi
// tree as trust/hooks even when it is symlinked. The dunce canonicalization
// must stay in sync with kigi_config::default_kigi_home();
// home resolution deliberately differs ($HOME here vs std::env::home_dir()).
// Standard home-dir lookup (`USERPROFILE`/Known Folders on Windows, where
// `HOME` is usually unset outside Git Bash) — the same resolution as
// kigi_config::default_kigi_home(), so worktree paths land in the same
// physical .kigi tree as trust/hooks.
let home = std::env::home_dir()
.context("neither $KIGI_SHARE_DIR nor a home directory could be resolved")?;
// Canonicalize so a symlinked home still maps to one physical tree; the
// dunce canonicalization must stay in sync with default_kigi_home().
Ok(dunce::canonicalize(&home).unwrap_or(home).join(".kigi"))
}
@@ -37,6 +37,7 @@ pub(crate) fn execute_plan(plan: WorktreePlan) -> Result<CreateWorktreeResult> {
#[cfg(test)]
mod tests {
#[cfg(unix)]
use super::*;
use crate::{IgnoredFilesMode, WorkingTreeMode, WorktreeBuilder};
use kigi_test_utils::git::{git_commit_all, init_git_repo};
@@ -89,10 +89,10 @@ fn is_platform_system_dir(cwd: &Path) -> bool {
#[cfg(target_os = "windows")]
fn is_platform_system_dir(cwd: &Path) -> bool {
if let Ok(temp) = std::env::var("TEMP").or_else(|_| std::env::var("TMP")) {
if cwd.starts_with(&temp) {
return true;
}
if let Ok(temp) = std::env::var("TEMP").or_else(|_| std::env::var("TMP"))
&& cwd.starts_with(&temp)
{
return true;
}
let path_lower = cwd.to_string_lossy().to_lowercase();
@@ -103,7 +103,7 @@ fn is_platform_system_dir(cwd: &Path) -> bool {
return true;
}
if cwd.parent().map_or(false, |p| p.parent().is_none()) && cwd.to_string_lossy().len() <= 3 {
if cwd.parent().is_some_and(|p| p.parent().is_none()) && cwd.to_string_lossy().len() <= 3 {
return true;
}
+8 -2
View File
@@ -971,7 +971,7 @@ pub(crate) fn start_with_timeout(
let progress_for_thread = progress.clone();
let thread = std::thread::spawn(move || {
let watcher_loop = move || {
let update_stage = |stage: &'static str| {
if let Ok(mut p) = progress_for_thread.lock() {
p.set_stage(stage);
@@ -1351,7 +1351,13 @@ pub(crate) fn start_with_timeout(
let _ = ready_tx.send(Err(Box::new(e)));
}
}
});
};
// Recoverable spawn: a host out of threads must surface as an error the
// caller can degrade on, not a `std::thread::spawn` panic.
let thread = std::thread::Builder::new()
.name("fsnotify-watcher".into())
.spawn(watcher_loop)
.map_err(|e| crate::FsNotifyError::WatcherStart(Box::new(e)))?;
// Wait for watcher to be ready (with timeout)
if let Ok(mut p) = progress.lock() {
@@ -318,6 +318,7 @@ mod tests {
assert!(matches!(state, FileContentState::TooLarge { .. }));
}
#[cfg(unix)]
#[tokio::test]
async fn test_read_file_bounded_symlink() {
let dir = tempfile::tempdir().unwrap();
+1
View File
@@ -150,6 +150,7 @@ fn write_private(path: &Path, contents: &str) -> std::io::Result<()> {
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use std::time::Instant;
/// Write an executable `#!/bin/sh` fake `mmdc` and return (dir-guard, path).
@@ -179,9 +179,12 @@ fn reap_process_group(_child: &Child) {
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use std::process::Stdio;
#[cfg(unix)]
use std::time::Instant;
#[cfg(unix)]
fn detached(mut cmd: Command) -> Command {
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
@@ -214,6 +214,7 @@ pub fn open_url_if_safe(url: &str, filter: SchemeFilter) -> bool {
mod tests {
use super::*;
#[cfg(not(target_os = "windows"))]
#[test]
fn open_path_command_passes_path_as_a_single_arg() {
// Path with spaces must be one argument, never shell-interpolated.
@@ -146,6 +146,10 @@ impl TermWriter {
pub fn writer_sync(&self) -> &WriterSync {
&self.sync
}
/// A cloneable handle onto this writer's queue for out-of-band escapes.
pub fn escape_writer(&self) -> EscapeWriter {
EscapeWriter::new(self.tx.clone(), self.sync.clone())
}
}
impl Write for TermWriter {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
@@ -166,6 +170,56 @@ impl Drop for TermWriter {
let _ = self.flush();
}
}
/// Out-of-band escapes ride the frame queue (an inline stderr write deadlocks on a stalled tty); drop every clone before `join_within`.
#[derive(Clone)]
pub struct EscapeWriter {
tx: mpsc::Sender<Vec<u8>>,
sync: WriterSync,
}
impl EscapeWriter {
pub fn new(tx: mpsc::Sender<Vec<u8>>, sync: WriterSync) -> Self {
Self { tx, sync }
}
/// No writer thread behind it: sends are dropped. For tests and views built before the terminal exists.
pub fn disconnected() -> Self {
let (tx, _rx) = mpsc::channel::<Vec<u8>>();
Self {
tx,
sync: WriterSync::new(),
}
}
/// Never blocks; covered by [`WriterSync::wait_drained`].
pub fn emit(&self, bytes: impl Into<Vec<u8>>) {
let data: Vec<u8> = bytes.into();
if data.is_empty() {
return;
}
self.sync.mark_queued();
let _ = self.tx.send(data);
}
/// Winapi-only commands run synchronously: a console API call, not a tty write.
pub fn emit_command(&self, command: impl crossterm::Command) {
#[cfg(windows)]
if !command.is_ansi_code_supported() {
let _ = command.execute_winapi();
return;
}
let mut ansi = String::new();
if command.write_ansi(&mut ansi).is_ok() {
self.emit(ansi);
}
}
}
/// Outcome of [`WriterThread::join_within`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriterJoin {
/// The thread drained its queue and exited.
Joined,
/// Still running at the deadline (tty blocked, or a sender alive); detached.
TimedOut,
}
/// Teardown waits this long for the writer thread before detaching it, so a stalled tty cannot hang `/quit`.
pub const WRITER_JOIN_GRACE: Duration = Duration::from_secs(2);
/// Handle for the background writer thread.
///
/// Joining ensures all queued frames have been written to the terminal
@@ -174,19 +228,45 @@ pub struct WriterThread {
handle: Option<std::thread::JoinHandle<()>>,
}
impl WriterThread {
/// Block until the writer thread has processed all pending frames and
/// exited. The [`mpsc::Sender`] must be dropped *before* calling this,
/// otherwise the thread will never see the channel close.
pub fn join(mut self) {
if let Some(h) = self.handle.take() {
let _ = h.join();
/// Wait up to `grace` for the thread to drain and exit, else detach it; every sender (`EscapeWriter` clones too) must be dropped first.
pub fn join_within(mut self, grace: Duration) -> WriterJoin {
let Some(handle) = self.handle.take() else {
return WriterJoin::Joined;
};
if wait_finished(&handle, grace) {
let _ = handle.join();
WriterJoin::Joined
} else {
tracing::warn!(
grace_ms = grace.as_millis() as u64,
"term-writer thread still running at teardown; detaching"
);
WriterJoin::TimedOut
}
}
#[cfg(test)]
fn for_test(handle: std::thread::JoinHandle<()>) -> Self {
Self {
handle: Some(handle),
}
}
}
fn wait_finished(handle: &std::thread::JoinHandle<()>, grace: Duration) -> bool {
let deadline = Instant::now() + grace;
while !handle.is_finished() {
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(1));
}
true
}
impl Drop for WriterThread {
fn drop(&mut self) {
if let Some(h) = self.handle.take() {
let _ = h.join();
if let Some(handle) = self.handle.take()
&& wait_finished(&handle, WRITER_JOIN_GRACE)
{
let _ = handle.join();
}
}
}
@@ -197,7 +277,7 @@ impl Drop for WriterThread {
/// `BufWriter`. The [`WriterSync`] must be shared with every [`TermWriter`]
/// built on the sender so [`WriterSync::wait_drained`] tracks the queue.
/// Drop the sender to signal the thread to exit, then call
/// [`WriterThread::join`] to wait for it.
/// [`WriterThread::join_within`] to wait for it.
pub fn spawn_writer_thread() -> (mpsc::Sender<Vec<u8>>, WriterSync, WriterThread) {
let (tx, rx) = mpsc::channel::<Vec<u8>>();
let sync = WriterSync::new();
@@ -570,4 +650,47 @@ mod tests {
"Round-tripped bytes do not decode to original UTF-8 string"
);
}
/// Frames and escapes share one queue, so the writer sees them in call order.
#[test]
#[cfg(not(windows))]
fn escapes_share_the_frame_queue_in_call_order() {
let (tx, rx) = mpsc::channel::<Vec<u8>>();
let sync = WriterSync::new();
let mut frames = TermWriter::new(tx, sync.clone());
let escapes = frames.escape_writer();
frames.write_all(b"frame-1").unwrap();
frames.flush().unwrap();
escapes.emit("\x1b[?1000h");
frames.write_all(b"frame-2").unwrap();
frames.flush().unwrap();
escapes.emit_command(crossterm::event::DisableMouseCapture);
let payloads: Vec<Vec<u8>> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
assert_eq!(payloads.len(), 4);
assert_eq!(payloads[0], b"frame-1");
assert_eq!(payloads[1], b"\x1b[?1000h");
assert_eq!(payloads[2], b"frame-2");
assert!(payloads[3].starts_with(b"\x1b["), "{:?}", payloads[3]);
assert!(!sync.is_drained(), "four payloads queued, none written");
}
/// A writer thread parked in a blocked tty write must not hang teardown.
#[test]
fn join_within_times_out_on_a_stuck_writer_thread() {
let (release_tx, release_rx) = mpsc::channel::<()>();
let handle = std::thread::spawn(move || {
let _ = release_rx.recv();
});
let started = Instant::now();
let outcome = WriterThread::for_test(handle).join_within(Duration::from_millis(50));
assert_eq!(outcome, WriterJoin::TimedOut);
assert!(started.elapsed() < Duration::from_secs(2));
let _ = release_tx.send(());
}
#[test]
fn join_within_joins_a_finished_writer_thread() {
let handle = std::thread::spawn(|| {});
assert_eq!(
WriterThread::for_test(handle).join_within(Duration::from_secs(5)),
WriterJoin::Joined
);
}
}
@@ -10,6 +10,7 @@
//! portable re-injection exists (TIOCSTI is blocked); accepted loss.
use std::io::Write;
#[cfg(unix)]
use std::time::Duration;
/// Bounds the reply buffer against terminals that stream without a terminator.
+1
View File
@@ -18,6 +18,7 @@ eventsource-stream = { workspace = true }
futures-util = { workspace = true }
indexmap = { workspace = true, features = ["serde"] }
reqwest = { workspace = true, features = ["stream"] }
kigi-secrets = { path = "../kigi-secrets" }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt", "macros", "time", "sync"] }
+49 -6
View File
@@ -193,6 +193,16 @@ fn extract_context_total(value: &serde_json::Value) -> Option<u32> {
Some(i.saturating_add(o))
}
/// The endpoint as the HTTP client parses it, minus userinfo, sensitive query values and fragment; an unparseable one is not echoed.
fn redacted_endpoint(endpoint: &str) -> String {
match reqwest::Url::parse(endpoint) {
Ok(mut url) => {
kigi_secrets::redact_url(&mut url);
url.to_string()
}
Err(_) => "<unparseable endpoint>".to_owned(),
}
}
/// Record `success=false` + `error` on the active inference span when a stream
/// request fails before any response (transport/connect/TLS errors). Without
/// this the `#[instrument]` span closes with both fields Empty, so an outage
@@ -806,7 +816,7 @@ impl SamplingClient {
let server_message_lower = server_message.to_lowercase();
let mut context_parts = vec![server_message.to_string()];
context_parts.push(format!("\nRequest URL: {}", endpoint));
context_parts.push(format!("\nRequest URL: {}", redacted_endpoint(endpoint)));
// Show headers if error mentions headers
if server_message_lower.contains("header") {
@@ -1006,7 +1016,8 @@ impl SamplingClient {
let endpoint = self.endpoint("chat/completions");
let server_message = response.text().await.unwrap_or_default();
return Err(SamplingError::Auth(format!(
"Unauthorized (401) from {endpoint}: {server_message}"
"Unauthorized (401) from {}: {server_message}",
redacted_endpoint(&endpoint)
)));
}
@@ -1196,7 +1207,8 @@ impl SamplingClient {
let endpoint = self.endpoint("responses");
let server_message = parse_error_bytes(bytes.as_ref());
return Err(SamplingError::Auth(format!(
"Unauthorized (401) from {endpoint}: {server_message}"
"Unauthorized (401) from {}: {server_message}",
redacted_endpoint(&endpoint)
)));
}
@@ -1363,7 +1375,8 @@ impl SamplingClient {
let endpoint = self.endpoint("responses");
let server_message = response.text().await.unwrap_or_default();
return Err(SamplingError::Auth(format!(
"Unauthorized (401) from {endpoint}: {server_message}"
"Unauthorized (401) from {}: {server_message}",
redacted_endpoint(&endpoint)
)));
}
let model_metadata = extract_model_metadata(response.headers());
@@ -1542,7 +1555,8 @@ impl SamplingClient {
let endpoint = self.endpoint("messages");
let server_message = parse_error_bytes(bytes.as_ref());
return Err(SamplingError::Auth(format!(
"Unauthorized (401) from {endpoint}: {server_message}"
"Unauthorized (401) from {}: {server_message}",
redacted_endpoint(&endpoint)
)));
}
@@ -1657,7 +1671,8 @@ impl SamplingClient {
let endpoint = self.endpoint("messages");
let server_message = response.text().await.unwrap_or_default();
return Err(SamplingError::Auth(format!(
"Unauthorized (401) from {endpoint}: {server_message}"
"Unauthorized (401) from {}: {server_message}",
redacted_endpoint(&endpoint)
)));
}
let model_metadata = extract_model_metadata(response.headers());
@@ -3048,3 +3063,31 @@ mod tests {
));
}
}
#[cfg(test)]
mod redacted_endpoint_tests {
/// Every spelling the client accepts (LF inside, one slash, no slash) loses its userinfo.
#[test]
fn redacted_endpoint_drops_userinfo_in_every_accepted_spelling() {
for spelling in [
"http://u:TEST_PASSWORD@127.0.0.1:1/v1/chat/completions",
"http://u:TEST PASSWORD@127.0.0.1:1/v1/chat/completions",
"http://u:TEST_PASSWORD\n@127.0.0.1:1/v1/chat/completions",
"http://u:TEST_PASSWORD\r\n@127.0.0.1:1/v1/chat/completions",
"http:/u:TEST_PASSWORD@127.0.0.1:1/v1/chat/completions",
"http:u:TEST_PASSWORD@127.0.0.1:1/v1/chat/completions",
"http:\\u:TEST_PASSWORD@127.0.0.1:1/v1/chat/completions",
] {
let redacted = super::redacted_endpoint(spelling);
assert_eq!(
redacted, "http://127.0.0.1:1/v1/chat/completions",
"{spelling:?}"
);
}
let gateway = super::redacted_endpoint(
"http://127.0.0.1:1/?access_token=TEST_QUERY_TOKEN&route=x#frag",
);
assert!(!gateway.contains("TEST_QUERY_TOKEN"), "{gateway}");
assert!(gateway.contains("route=x"), "{gateway}");
}
}
+47 -7
View File
@@ -5,8 +5,11 @@
//!
//! # Retry behavior summary
//!
//! **Retried** (up to [`DEFAULT_MAX_RETRIES`] = 15, ~6 min with 30s backoff cap):
//! - 500, 502, 503, 504, 520 (server errors)
//! **Retried** (up to [`DEFAULT_MAX_RETRIES`] = 15, ~6 min with the
//! [`MAX_RETRY_BACKOFF`] cap — a server `Retry-After` is clamped to it too):
//! - Any 5xx except Cloudflare 525/526 — covers the Cloudflare edge pages
//! (520-524 origin unreachable/timed out, 530 edge 1xxx) and upstream
//! overload (529); see `is_retryable_api_status`
//! - Connection errors (timeout, refused, reset)
//! - `EventStreamError` / `StreamError` (mid-stream failures)
//! - `EmptyResponse` (model returned no content/tool calls)
@@ -77,16 +80,35 @@ pub fn doom_loop_backoff(retry_count: u32) -> Duration {
Duration::from_millis(hasher.finish() % 251)
}
/// Exponential backoff (2s, 4s, 8s, ..., capped 30s) with +/-20% jitter
/// to prevent thundering-herd retry storms.
/// Longest single wait on the generic retry path — the exponential-backoff
/// ceiling, and the clamp for a server `Retry-After`. Cloudflare answers 52x
/// with `Retry-After: 60`-`120`; honoring that verbatim across the retry
/// budget would stall a turn ~28 min instead of the ~6 min budget. The 429
/// path deliberately waits the full `Retry-After` instead, bounded by
/// [`RATE_LIMIT_RETRY_THRESHOLD`] attempts (and by the parse-level 120s cap
/// on the header).
pub const MAX_RETRY_BACKOFF: Duration = Duration::from_secs(30);
/// Exponential backoff (2s, 4s, 8s, ..., capped at [`MAX_RETRY_BACKOFF`])
/// with +/-20% jitter to prevent thundering-herd retry storms.
pub fn retry_backoff_with_jitter(retry_count: u32) -> Duration {
let shift = retry_count.saturating_sub(1);
let base_ms = 2000u64
.checked_shl(shift)
.unwrap_or(u64::MAX)
.min(MAX_RETRY_BACKOFF.as_millis() as u64);
jittered(Duration::from_millis(base_ms))
}
/// +/-20% jitter around `base`, de-syncing clients that failed at the
/// same instant (e.g. a mass Cloudflare 52x event during an origin outage).
fn jittered(base: Duration) -> Duration {
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicU64, Ordering};
static JITTER_SEQ: AtomicU64 = AtomicU64::new(0);
let shift = retry_count.saturating_sub(1);
let base_ms = 2000u64.checked_shl(shift).unwrap_or(u64::MAX).min(30_000);
let base_ms = base.as_millis() as u64;
let jitter_range = base_ms / 5;
let mut hasher = std::hash::DefaultHasher::new();
JITTER_SEQ.fetch_add(1, Ordering::Relaxed).hash(&mut hasher);
@@ -210,9 +232,11 @@ pub fn classify_error(
if next_attempt >= max_retries {
return RetryDecision::Fatal(clone_error(err));
}
// A server `Retry-After` is clamped: Cloudflare 52x pages ask for
// 60-120s, which across the budget would stall a turn ~28 min.
let backoff = err
.retry_after()
.map(Duration::from_secs)
.map(|secs| jittered(Duration::from_secs(secs).min(MAX_RETRY_BACKOFF)))
.unwrap_or_else(|| retry_backoff_with_jitter(next_attempt));
if next_attempt == 1 {
return RetryDecision::RetryWithClientRebuild { backoff };
@@ -595,6 +619,22 @@ mod tests {
}
}
#[test]
fn classify_5xx_retry_after_is_clamped_to_backoff_cap() {
// Cloudflare answers 52x with Retry-After: 60-120; honoring it
// verbatim across the retry budget would stall a turn ~28 min.
let err = api_err_with_retry_after(StatusCode::from_u16(522).unwrap(), 120);
match classify_error(&err, 1, 15, RATE_LIMIT_RETRY_THRESHOLD) {
RetryDecision::Retry { backoff } => {
assert!(
backoff <= MAX_RETRY_BACKOFF.mul_f64(1.2),
"52x Retry-After must clamp to ~MAX_RETRY_BACKOFF, got {backoff:?}"
);
}
other => panic!("expected Retry, got {other:?}"),
}
}
#[test]
fn classify_5xx_first_retry_rebuilds_client() {
let err = api_err(StatusCode::INTERNAL_SERVER_ERROR, "boom");
@@ -55,6 +55,15 @@ enum BlockType {
Thinking,
}
/// Executable after a `max_tokens` stop: every call's arguments parse or are empty (zero-arg convention).
fn tool_calls_complete(tool_calls: &[ToolCall]) -> bool {
!tool_calls.is_empty()
&& tool_calls.iter().all(|tc| {
tc.arguments.trim().is_empty()
|| serde_json::from_str::<serde::de::IgnoredAny>(&tc.arguments).is_ok()
})
}
/// Transform a raw Anthropic Messages API stream into a stream of
/// [`SamplingEvent`]s.
///
@@ -454,11 +463,18 @@ pub fn stream_messages<'a>(
}
if final_stop_reason == Some(StopReason::Length) {
yield SamplingEvent::Failed {
request_id: request_id.clone(),
error: SamplingErrorInfo::from(&SamplingError::MaxTokensTruncation),
};
return;
if !tool_calls_complete(&assistant_tool_calls) {
yield SamplingEvent::Failed {
request_id: request_id.clone(),
error: SamplingErrorInfo::from(&SamplingError::MaxTokensTruncation),
};
return;
}
tracing::info!(
request_id = %request_id,
tool_calls = assistant_tool_calls.len(),
"max_tokens stop after complete tool calls; completing the turn so they run"
);
}
// Build the final response
@@ -653,3 +653,90 @@ async fn pure_cache_hit_with_zero_uncached_still_emits_usage() {
assert_eq!(usage.cached_prompt_tokens, 2500);
assert_eq!(usage.total_tokens, 2501);
}
fn tool_use_start(index: u32, id: &str) -> MessageStreamEvent {
MessageStreamEvent::ContentBlockStart {
index,
content_block: ContentBlock::ToolUse {
id: id.into(),
name: "do_thing".into(),
input: serde_json::json!({}),
},
}
}
fn arg_delta(index: u32, partial_json: &str) -> MessageStreamEvent {
MessageStreamEvent::ContentBlockDelta {
index,
delta: StreamDelta::InputJsonDelta {
partial_json: partial_json.into(),
},
}
}
async fn max_tokens_after(events: Vec<MessageStreamEvent>) -> Vec<SamplingEvent> {
let mut all = vec![Ok(message_start())];
all.extend(events.into_iter().map(Ok));
all.push(Ok(message_delta_with_stop(messages::StopReason::MaxTokens)));
all.push(Ok(MessageStreamEvent::MessageStop));
let raw = stream::iter(all).boxed();
collect(stream_messages(raw, None, rid(), Duration::from_secs(60))).await
}
/// Complete tool calls before a max_tokens stop complete the turn as ToolCalls.
#[tokio::test]
async fn max_tokens_after_complete_tool_calls_completes_as_tool_calls() {
let evs = max_tokens_after(vec![
tool_use_start(0, "call_done"),
arg_delta(0, "{\"x\": 1}"),
block_stop(0),
])
.await;
match evs.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
assert_eq!(response.stop_reason, Some(StopReason::ToolCalls));
assert_eq!(response.tool_calls().len(), 1);
assert_eq!(response.tool_calls()[0].arguments.as_ref(), "{\"x\": 1}");
}
other => panic!("expected Completed(ToolCalls), got {other:?}"),
}
}
/// A tool_use block with no argument deltas is the zero-argument convention, not truncation.
#[tokio::test]
async fn max_tokens_after_tool_call_without_arguments_completes() {
let evs = max_tokens_after(vec![tool_use_start(0, "call_no_args"), block_stop(0)]).await;
match evs.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
assert_eq!(response.stop_reason, Some(StopReason::ToolCalls));
assert_eq!(response.tool_calls()[0].arguments.as_ref(), "");
}
other => panic!("expected Completed(ToolCalls), got {other:?}"),
}
}
/// A call cut mid-arguments is truncation: the turn fails so the caller can compact.
#[tokio::test]
async fn max_tokens_with_truncated_tool_arguments_fails() {
let evs = max_tokens_after(vec![
tool_use_start(0, "call_ok"),
arg_delta(0, "{}"),
block_stop(0),
tool_use_start(1, "call_cut"),
arg_delta(1, "{\"x\": \"trunc"),
block_stop(1),
])
.await;
assert!(
!evs.iter()
.any(|e| matches!(e, SamplingEvent::Completed { .. })),
"a cut tool call must not complete: {evs:?}"
);
match evs.last().unwrap() {
SamplingEvent::Failed { error, .. } => assert_eq!(
error.kind,
crate::events::SamplingErrorKind::MaxTokensTruncation
),
other => panic!("expected Failed(MaxTokensTruncation), got {other:?}"),
}
}
@@ -239,9 +239,7 @@ impl SamplingError {
SamplingError::InvalidConfiguration(_) => false,
SamplingError::Http(err) => is_retryable_reqwest(err),
SamplingError::Serialization(_) => false,
SamplingError::Api { status, .. } => {
matches!(status.as_u16(), 429 | 500 | 502 | 503 | 504 | 520)
}
SamplingError::Api { status, .. } => is_retryable_api_status(status.as_u16()),
SamplingError::EventStreamError(_) => true,
SamplingError::StreamError { .. } => true,
SamplingError::IdleTimeout { .. } => false,
@@ -342,6 +340,15 @@ pub fn try_parse_stream_error(data: &str) -> Option<SamplingError> {
})
}
/// 429 and any 5xx except Cloudflare 525/526 (origin TLS handshake /
/// invalid certificate — a broken origin certificate never clears on its
/// own). The 5xx sweep covers the Cloudflare edge pages (520-524 origin
/// unreachable/timed out, 530 edge 1xxx) and upstream overload (529),
/// which an outage serves in place of the API's own statuses.
pub fn is_retryable_api_status(status: u16) -> bool {
status == 429 || ((500..=599).contains(&status) && !matches!(status, 525 | 526))
}
/// True when an error message indicates a context-window overflow. Backends report
/// this inconsistently with no stable error code, so we match the message text; it's
/// deterministic (re-sending the same payload always fails), so callers must not retry.
@@ -352,6 +359,7 @@ pub fn is_context_length_error(message: &str) -> bool {
|| m.contains("maximum prompt length")
|| m.contains("maximum context length")
|| m.contains("context_length_exceeded")
|| (m.contains("current message") && m.contains("exceeds budget"))
}
/// Decide whether a [`reqwest::Error`] is worth retrying.
@@ -378,6 +386,27 @@ pub fn is_retryable_reqwest(err: &reqwest::Error) -> bool {
mod tests {
use super::*;
#[test]
fn retryable_api_status_covers_edge_5xx_and_excludes_broken_tls() {
for status in [
429u16, 500, 502, 503, 504, 520, 521, 522, 523, 524, 529, 530, 599,
] {
assert!(is_retryable_api_status(status), "should retry {status}");
}
// Cloudflare 525/526: origin TLS handshake / invalid certificate —
// a broken origin certificate never clears on its own.
for status in [525u16, 526, 400, 401, 403, 404, 408, 422, 200] {
assert!(!is_retryable_api_status(status), "must not retry {status}");
}
let api = SamplingError::Api {
status: StatusCode::from_u16(522).unwrap(),
message: "cloudflare: connection timed out".into(),
model_metadata: None,
retry_after_secs: None,
};
assert!(api.is_retryable(), "522 edge page must be retryable");
}
#[test]
fn context_length_error_matches_backend_variants() {
for msg in [
@@ -387,6 +416,7 @@ mod tests {
"This model's maximum context length is 200000 tokens",
"invalid_request_error: prompt is too long: 300000 tokens > 200000 maximum",
"error type: context_length_exceeded",
"the current message exceeds budget: 120000 < 250000",
] {
assert!(is_context_length_error(msg), "should match: {msg}");
}
+1
View File
@@ -51,6 +51,7 @@ enforce = []
[dev-dependencies]
serial_test = { workspace = true }
tempfile = { workspace = true }
# Compiles the hand-rolled macOS deny-glob regex in tests to assert it matches
# EXACTLY the set globset (the Linux backend) matches — the cross-platform parity
# guard for the glob dialect.
@@ -14,9 +14,19 @@
//! cargo run -p kigi-sandbox --example sandbox_smoke_test -- read-only
//! ```
// Kernel enforcement is unix-only (Seatbelt / Landlock); Windows builds a
// stub so `--all-targets` stays green everywhere.
#[cfg(unix)]
use kigi_sandbox::{ProfileName, SandboxManager};
#[cfg(unix)]
use std::path::Path;
#[cfg(not(unix))]
fn main() {
eprintln!("sandbox_smoke_test exercises kernel enforcement and only runs on Unix.");
}
#[cfg(unix)]
fn main() {
let profile_name = std::env::args()
.nth(1)
@@ -121,6 +131,7 @@ fn main() {
println!("\n✅ Smoke test complete");
}
#[cfg(unix)]
fn test_read(label: &str, path: &Path) {
if path.is_file() {
match std::fs::read(path) {
@@ -150,6 +161,7 @@ fn test_read(label: &str, path: &Path) {
}
}
#[cfg(unix)]
fn test_write(label: &str, path: &Path) {
match std::fs::write(path, b"sandbox-test") {
Ok(()) => {
+210 -74
View File
@@ -1,91 +1,130 @@
//! Per-child seccomp network filter. No-op on non-Linux.
/// Install seccomp BPF filter blocking network syscalls.
#[cfg(target_os = "linux")]
mod bpf {
use libc::sock_filter;
pub(super) const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000;
pub(super) const SECCOMP_RET_ERRNO: u32 = 0x0005_0000;
pub(super) const EPERM_VAL: u32 = 1;
#[cfg(target_arch = "x86_64")]
pub(super) const X32_SYSCALL_BIT: u32 = 0x4000_0000;
/// `seccomp_data` field offsets.
pub(super) const OFF_NR: u32 = 0;
pub(super) const OFF_ARCH: u32 = 4;
#[cfg(target_arch = "x86_64")]
pub(super) const EXPECTED_ARCH: u32 = 0xc000_003e; // AUDIT_ARCH_X86_64
#[cfg(target_arch = "aarch64")]
pub(super) const EXPECTED_ARCH: u32 = 0xc000_00b7; // AUDIT_ARCH_AARCH64
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
pub(super) const EXPECTED_ARCH: u32 = 0;
pub(super) fn stmt(code: u32, k: u32) -> sock_filter {
sock_filter {
code: code as u16,
jt: 0,
jf: 0,
k,
}
}
pub(super) fn jump(code: u32, k: u32, jt: u8, jf: u8) -> sock_filter {
sock_filter {
code: code as u16,
jt,
jf,
k,
}
}
/// Reject a foreign-arch (and, on x86_64, x32) syscall number before
/// matching `nr`, so a syscall reached through another table cannot
/// alias past the deny list.
pub(super) fn push_arch_nr_gate(f: &mut Vec<sock_filter>) {
use libc::{BPF_ABS, BPF_JEQ, BPF_JMP, BPF_K, BPF_LD, BPF_RET, BPF_W};
f.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFF_ARCH));
f.push(jump(BPF_JMP | BPF_JEQ | BPF_K, EXPECTED_ARCH, 1, 0));
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
f.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFF_NR));
#[cfg(target_arch = "x86_64")]
{
f.push(jump(
BPF_JMP | libc::BPF_JSET | BPF_K,
X32_SYSCALL_BIT,
0,
1,
));
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
}
}
}
/// Syscalls that open or send on a socket, including the `io_uring` and
/// `sendmmsg` paths that bypass the classic entry points.
#[cfg(target_os = "linux")]
fn child_network_blocked_syscalls() -> [u32; 11] {
use libc::{
SYS_accept, SYS_accept4, SYS_bind, SYS_connect, SYS_io_uring_enter, SYS_io_uring_register,
SYS_io_uring_setup, SYS_listen, SYS_sendmmsg, SYS_sendmsg, SYS_sendto,
};
[
SYS_connect as u32,
SYS_bind as u32,
SYS_sendto as u32,
SYS_sendmsg as u32,
SYS_sendmmsg as u32,
SYS_listen as u32,
SYS_accept as u32,
SYS_accept4 as u32,
SYS_io_uring_setup as u32,
SYS_io_uring_enter as u32,
SYS_io_uring_register as u32,
]
}
#[cfg(target_os = "linux")]
fn build_child_network_filter() -> Vec<libc::sock_filter> {
use bpf::{EPERM_VAL, SECCOMP_RET_ALLOW, SECCOMP_RET_ERRNO, jump, stmt};
use libc::{BPF_JEQ, BPF_JMP, BPF_K, BPF_RET};
let blocked = child_network_blocked_syscalls();
let mut f = Vec::with_capacity(blocked.len() + 10);
bpf::push_arch_nr_gate(&mut f);
let n = blocked.len();
for (i, &sys) in blocked.iter().enumerate() {
let remaining = n - i - 1;
f.push(jump(BPF_JMP | BPF_JEQ | BPF_K, sys, remaining as u8 + 1, 0));
}
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW));
f.push(stmt(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
f
}
/// Built once: `pre_exec` runs after `fork`, where allocating is unsafe.
#[cfg(target_os = "linux")]
pub fn prebuilt_child_network_filter() -> &'static [libc::sock_filter] {
static FILTER: std::sync::OnceLock<Vec<libc::sock_filter>> = std::sync::OnceLock::new();
FILTER.get_or_init(build_child_network_filter)
}
/// Install the seccomp BPF filter blocking network syscalls.
///
/// # Safety
///
/// Must be called in a `pre_exec` context (after `fork`, before `exec`).
#[cfg(target_os = "linux")]
pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
use libc::{
BPF_ABS, BPF_JEQ, BPF_JMP, BPF_K, BPF_LD, BPF_RET, BPF_W, PR_SET_NO_NEW_PRIVS,
PR_SET_SECCOMP, SECCOMP_MODE_FILTER, SYS_accept, SYS_accept4, SYS_bind, SYS_connect,
SYS_listen, SYS_sendmsg, SYS_sendto, prctl, sock_filter, sock_fprog,
};
const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000;
const SECCOMP_RET_ERRNO: u32 = 0x0005_0000;
// libc::EPERM
const EPERM_VAL: u32 = 1;
macro_rules! bpf_stmt {
($code:expr, $k:expr) => {
sock_filter {
code: $code as u16,
jt: 0,
jf: 0,
k: $k as u32,
}
};
}
macro_rules! bpf_jump {
($code:expr, $k:expr, $jt:expr, $jf:expr) => {
sock_filter {
code: $code as u16,
jt: $jt,
jf: $jf,
k: $k as u32,
}
};
}
// seccomp_data.nr offset
const NR_OFFSET: u32 = 0;
let blocked_syscalls: &[i64] = &[
SYS_connect,
SYS_bind,
SYS_sendto,
SYS_sendmsg,
SYS_listen,
SYS_accept,
SYS_accept4,
];
let mut filter: Vec<sock_filter> = Vec::new();
let total_checks = blocked_syscalls.len();
filter.push(bpf_stmt!(BPF_LD | BPF_W | BPF_ABS, NR_OFFSET));
for (i, &syscall) in blocked_syscalls.iter().enumerate() {
let remaining = total_checks - i - 1;
filter.push(bpf_jump!(
BPF_JMP | BPF_JEQ | BPF_K,
syscall,
// match: jump to ERRNO
remaining as u8 + 1,
// no match: check next
0
));
}
filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ALLOW));
filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
pub unsafe fn install_child_network_filter(filter: &[libc::sock_filter]) -> std::io::Result<()> {
use libc::{PR_SET_NO_NEW_PRIVS, PR_SET_SECCOMP, SECCOMP_MODE_FILTER, prctl, sock_fprog};
let prog = sock_fprog {
len: filter.len() as u16,
filter: filter.as_mut_ptr(),
filter: filter.as_ptr().cast_mut(),
};
// Must set PR_SET_NO_NEW_PRIVS before applying seccomp filter
// SAFETY: prctl with PR_SET_NO_NEW_PRIVS is safe in pre_exec context.
if unsafe { prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
return Err(std::io::Error::last_os_error());
}
// SAFETY: prog is a valid sock_fprog pointing to our filter array.
// SAFETY: prog is a valid sock_fprog pointing to a filter the kernel copies.
if unsafe {
prctl(
PR_SET_SECCOMP,
@@ -98,7 +137,6 @@ pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
{
return Err(std::io::Error::last_os_error());
}
Ok(())
}
@@ -109,3 +147,101 @@ pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
Ok(())
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::bpf::*;
use super::{build_child_network_filter, child_network_blocked_syscalls};
use libc::sock_filter;
/// Minimal classic-BPF interpreter over synthetic `seccomp_data` fields.
fn eval(filter: &[sock_filter], arch: u32, nr: u32) -> u32 {
use libc::{BPF_ABS, BPF_JEQ, BPF_JMP, BPF_JSET, BPF_K, BPF_LD, BPF_RET, BPF_W};
let mut pc = 0usize;
let mut a = 0u32;
for _ in 0..filter.len().saturating_mul(2) {
let insn = &filter[pc];
let op = insn.code as u32;
if op == (BPF_LD | BPF_W | BPF_ABS) {
a = match insn.k {
OFF_NR => nr,
OFF_ARCH => arch,
_ => 0,
};
pc += 1;
} else if op == (BPF_JMP | BPF_JEQ | BPF_K) {
pc = if a == insn.k {
pc + 1 + insn.jt as usize
} else {
pc + 1 + insn.jf as usize
};
} else if op == (BPF_JMP | BPF_JSET | BPF_K) {
pc = if a & insn.k != 0 {
pc + 1 + insn.jt as usize
} else {
pc + 1 + insn.jf as usize
};
} else if op == (BPF_RET | BPF_K) {
return insn.k;
} else {
panic!("unsupported opcode {:#x} at {pc}", insn.code);
}
assert!(pc < filter.len(), "pc out of range");
}
panic!("filter did not RET");
}
fn is_allow(r: u32) -> bool {
r == SECCOMP_RET_ALLOW
}
fn is_eperm(r: u32) -> bool {
r == (SECCOMP_RET_ERRNO | EPERM_VAL)
}
#[test]
fn blocks_every_network_syscall_and_allows_read_and_socket() {
let f = build_child_network_filter();
for sys in child_network_blocked_syscalls() {
assert!(
is_eperm(eval(&f, EXPECTED_ARCH, sys)),
"syscall {sys} must be EPERM"
);
}
assert!(is_allow(eval(&f, EXPECTED_ARCH, libc::SYS_read as u32)));
assert!(is_allow(eval(&f, EXPECTED_ARCH, libc::SYS_socket as u32)));
}
#[test]
fn covers_io_uring_and_sendmmsg() {
let blocked = child_network_blocked_syscalls();
for sys in [
libc::SYS_sendmmsg,
libc::SYS_io_uring_setup,
libc::SYS_io_uring_enter,
libc::SYS_io_uring_register,
] {
assert!(blocked.contains(&(sys as u32)), "{sys} missing");
}
}
#[test]
fn wrong_arch_and_x32_are_denied_before_the_nr_match() {
let f = build_child_network_filter();
assert!(is_eperm(eval(&f, 0xdead_beef, libc::SYS_read as u32)));
#[cfg(target_arch = "x86_64")]
assert!(is_eperm(eval(
&f,
EXPECTED_ARCH,
(libc::SYS_read as u32) | X32_SYSCALL_BIT
)));
}
#[test]
fn prebuilt_filter_is_shared() {
let a = super::prebuilt_child_network_filter();
let b = super::prebuilt_child_network_filter();
assert!(std::ptr::eq(a, b));
assert_eq!(a.len(), build_child_network_filter().len());
}
}
+116 -4
View File
@@ -333,6 +333,27 @@ fn is_devbox_based(profile: &ProfileName, config: &SandboxConfig) -> bool {
_ => false,
}
}
/// Whether an existing `/data` needs the devbox bwrap read-only bind; a filesystem error other than NotFound conservatively requires it.
#[cfg(target_os = "linux")]
pub fn requires_data_write_deny(profile: &ProfileName, workspace: &Path) -> bool {
requires_data_write_deny_for(
profile,
&profiles::load_sandbox_config(workspace),
data_path_requires_bind(Path::new("/data")),
)
}
#[cfg(target_os = "linux")]
fn requires_data_write_deny_for(
profile: &ProfileName,
config: &SandboxConfig,
is_data_present: bool,
) -> bool {
is_devbox_based(profile, config) && is_data_present
}
#[cfg(target_os = "linux")]
fn data_path_requires_bind(path: &Path) -> bool {
path.try_exists().unwrap_or(true)
}
/// Whether kernel read-deny enforcement is required. The single source of truth
/// for this classification so callers (e.g. the shell's fail-closed startup path)
/// cannot drift and silently fail open.
@@ -360,6 +381,23 @@ pub fn requires_read_deny(profile: &ProfileName, workspace: &Path) -> bool {
pub fn requires_read_deny(_profile: &ProfileName, _workspace: &Path) -> bool {
false
}
/// Whether the resolved profile leaves `~/.kigi` read-only outside its
/// state dirs (strict and every profile extending it), so files first use
/// would write there must be minted before enforcement.
#[cfg(all(feature = "enforce", unix))]
pub fn restricts_home_writes(profile: &ProfileName, workspace: &Path) -> bool {
if *profile == ProfileName::Off {
return false;
}
let config = profiles::load_sandbox_config(workspace);
profile
.resolve_profile(workspace, &config)
.is_ok_and(|resolved| !resolved.read_write.contains(&paths::kigi_home()))
}
#[cfg(not(all(feature = "enforce", unix)))]
pub fn restricts_home_writes(_profile: &ProfileName, _workspace: &Path) -> bool {
false
}
/// A profile's resolved bwrap deny plan: read-only mounts (`deny_write`),
/// bound-over unreadable placeholders (`deny_read`), and whether the profile
/// carries deny globs (`has_globs`, so the re-exec proceeds even with zero
@@ -382,7 +420,11 @@ struct BwrapDenyPlan {
#[cfg(all(feature = "enforce", target_os = "linux"))]
fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> {
let config = profiles::load_sandbox_config(workspace);
let deny_write: Vec<String> = if is_devbox_based(profile, &config) {
let deny_write: Vec<String> = if requires_data_write_deny_for(
profile,
&config,
data_path_requires_bind(Path::new("/data")),
) {
vec!["/data".to_string()]
} else {
Vec::new()
@@ -424,7 +466,11 @@ fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyP
#[cfg(all(not(feature = "enforce"), target_os = "linux"))]
fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> {
let config = profiles::load_sandbox_config(workspace);
let deny_write: Vec<String> = if is_devbox_based(profile, &config) {
let deny_write: Vec<String> = if requires_data_write_deny_for(
profile,
&config,
data_path_requires_bind(Path::new("/data")),
) {
vec!["/data".to_string()]
} else {
Vec::new()
@@ -595,6 +641,43 @@ mod tests {
);
}
#[test]
#[cfg(target_os = "linux")]
fn data_write_deny_requires_devbox_and_a_present_data_dir() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".kigi")).unwrap();
std::fs::write(
tmp.path().join(".kigi/sandbox.toml"),
"[profiles.devext]\nextends = \"devbox\"\n\n[profiles.plain]\nextends = \"workspace\"\n",
)
.unwrap();
let config = profiles::load_sandbox_config(tmp.path());
assert!(requires_data_write_deny_for(
&ProfileName::Devbox,
&config,
true
));
assert!(!requires_data_write_deny_for(
&ProfileName::Devbox,
&config,
false
));
assert!(requires_data_write_deny_for(
&ProfileName::Custom("devext".into()),
&config,
true
));
assert!(!requires_data_write_deny_for(
&ProfileName::Custom("plain".into()),
&config,
true
));
assert!(!requires_data_write_deny_for(
&ProfileName::Workspace,
&config,
true
));
}
#[test]
fn configured_profile_is_recorded() {
set_configured_profile("read-only");
assert_eq!(configured_profile_name(), Some("read-only"));
@@ -700,10 +783,11 @@ mod tests {
"devbox-empty",
"[profiles.devempty]\nextends = \"devbox\"\n",
);
assert!(
assert_eq!(
bwrap_reexec_for_profile(&ProfileName::Custom("devempty".to_string()), &ws_empty)
.is_some(),
"devbox-extending custom must compose the /data write-deny re-exec"
Path::new("/data").exists(),
"devbox-extending custom composes the /data write-deny re-exec exactly when /data exists"
);
let _ = std::fs::remove_dir_all(&ws_empty);
let ws_ws = temp_workspace_with_sandbox_toml(
@@ -717,3 +801,31 @@ mod tests {
let _ = std::fs::remove_dir_all(&ws_ws);
}
}
#[cfg(all(test, feature = "enforce", unix))]
mod home_write_tests {
use super::{ProfileName, restricts_home_writes};
#[test]
fn strict_restricts_home_writes_and_workspace_does_not() {
let workspace = std::env::temp_dir();
assert!(restricts_home_writes(&ProfileName::Strict, &workspace));
assert!(!restricts_home_writes(&ProfileName::Workspace, &workspace));
assert!(!restricts_home_writes(&ProfileName::ReadOnly, &workspace));
assert!(!restricts_home_writes(&ProfileName::Off, &workspace));
}
#[test]
fn custom_profile_extending_strict_inherits_the_restriction() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join(".kigi");
std::fs::create_dir_all(&project).unwrap();
std::fs::write(
project.join("sandbox.toml"),
"[profiles.audit-home-write-test]\nextends = \"strict\"\n",
)
.unwrap();
let custom = ProfileName::Custom("audit-home-write-test".to_string());
assert!(restricts_home_writes(&custom, tmp.path()));
}
}
+6 -2
View File
@@ -1,7 +1,7 @@
//! Sandbox event logger.
//!
//! Events (profile applied, violations, bypasses) are buffered in memory and
//! flushed as JSONL to `~/.kigi/sandbox-events.jsonl`.
//! flushed as JSONL to `~/.kigi/sessions/sandbox-events.jsonl`.
use std::path::PathBuf;
use std::sync::Mutex;
@@ -87,8 +87,12 @@ impl SandboxLogger {
Ok(())
}
/// Under `sessions/`: the only home subtree a strict sandbox leaves
/// writable, and this flush runs after enforcement.
fn log_file_path() -> PathBuf {
kigi_config::kigi_home().join("sandbox-events.jsonl")
kigi_config::kigi_home()
.join("sessions")
.join("sandbox-events.jsonl")
}
}
+31
View File
@@ -96,6 +96,37 @@ pub(crate) fn essential_writable_paths(workspace: &Path) -> Vec<PathBuf> {
paths
}
/// Home subtrees the process keeps writing after enforcement: session and
/// worktree state, memory, diagnostics. Landlock cannot carve a child out of
/// a write grant, so the rest of `~/.kigi` (`config.toml`, `auth.json`,
/// `sandbox.toml`, `hooks/`, `bin/`, `vendor/`, `plugin-data/`) stays
/// read-only and a sandboxed child cannot arm the next launch.
#[cfg(all(feature = "enforce", unix))]
const HOME_STATE_DIRS: &[&str] = &[
"run",
"sessions",
"worktrees",
"worktree_pool",
"tasks",
"memory",
"logs",
"debug",
"crash",
"memtrace",
"trace-exports",
];
/// Writable paths for the strict profile: the workspace, the home state
/// subtrees, and temp.
#[cfg(all(feature = "enforce", unix))]
pub(crate) fn essential_writable_paths_strict(workspace: &Path) -> Vec<PathBuf> {
let home = kigi_home();
let mut paths = vec![workspace.to_path_buf()];
paths.extend(HOME_STATE_DIRS.iter().map(|dir| home.join(dir)));
paths.extend(temp_writable_paths());
paths
}
/// Writable directory paths for the read-only profile (minimal: just ~/.kigi + temp).
/// Device files are handled separately via `allow_file` in `to_capability_set_with_config`.
#[cfg(all(feature = "enforce", unix))]
+113 -6
View File
@@ -18,6 +18,7 @@ use crate::paths::kigi_home;
#[cfg(all(feature = "enforce", unix))]
use crate::paths::{
DEVICE_DIRS, DEVICE_FILES, essential_writable_paths, essential_writable_paths_minimal,
essential_writable_paths_strict,
};
/// A resolved sandbox profile ready to be converted to a `CapabilitySet`.
@@ -186,6 +187,32 @@ fn load_config_file(path: &Path) -> Option<SandboxConfig> {
#[cfg(all(feature = "enforce", unix))]
impl ProfileName {
/// Convert this profile into a nono `CapabilitySet` for the given workspace.
/// The directory a `read_write` entry really grants. A symlink under
/// `~/.kigi` is followed only to the default `~/.kigi/sessions` (the
/// `KIGI_SHARE_DIR` override case); any other target would widen the
/// grant to wherever the link points. Missing paths are created.
fn read_write_grant_path(path: &Path, home: &Path) -> Option<PathBuf> {
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.file_type().is_symlink() => {
if path == home || !path.starts_with(home) {
return Some(path.to_path_buf());
}
let real = dunce::canonicalize(path).ok()?;
let default_sessions = kigi_config::default_kigi_home().join("sessions");
(real.is_dir()
&& path.file_name() == Some(std::ffi::OsStr::new("sessions"))
&& real == default_sessions)
.then_some(real)
}
Ok(_) => Some(path.to_path_buf()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
std::fs::create_dir_all(path).ok()?;
Some(path.to_path_buf())
}
Err(_) => None,
}
}
pub fn to_capability_set(&self, workspace: &Path) -> anyhow::Result<CapabilitySet> {
let config = load_sandbox_config(workspace);
self.to_capability_set_with_config(workspace, &config)
@@ -231,13 +258,14 @@ impl ProfileName {
// apply time (it opens an O_PATH fd), but new files within it can
// be created freely after the sandbox is applied. Pre-create
// directories like ~/.kigi/ that may not exist on first run.
let home = kigi_home();
for path in &profile.read_write {
if !path.exists() && std::fs::create_dir_all(path).is_err() {
tracing::warn!(path = ?path, "read_write path does not exist and could not be created, skipping");
let Some(grant) = Self::read_write_grant_path(path, &home) else {
tracing::warn!(path = ?path, "read_write grant skipped");
continue;
}
let Some(path_str) = path.to_str() else {
tracing::warn!(path = ?path, "Skipping non-UTF8 read_write path");
};
let Some(path_str) = grant.to_str() else {
tracing::warn!(path = ?grant, "Skipping non-UTF8 read_write path");
continue;
};
caps = caps.allow_path(path_str, AccessMode::ReadWrite)?;
@@ -380,12 +408,15 @@ impl ProfileName {
.chain(std::iter::once(home.join("Library")))
.filter(|p| p.exists())
.chain(std::iter::once(workspace.to_path_buf()))
// Read-only: config, auth and global hooks are read after
// enforcement; only sessions/ below stays writable.
.chain(std::iter::once(kigi_home()))
.collect();
Ok(SandboxProfile {
name: "strict".to_string(),
read_only: system_read,
read_write: essential_writable_paths(workspace),
read_write: essential_writable_paths_strict(workspace),
deny: vec![],
default_read: false,
restrict_network: true,
@@ -544,6 +575,82 @@ mod tests {
}
}
#[test]
#[cfg(all(feature = "enforce", unix))]
fn strict_read_write_is_workspace_home_state_and_temp() {
let workspace = std::env::temp_dir();
let profile = ProfileName::Strict
.resolve_profile(&workspace, &SandboxConfig::default())
.expect("strict resolves");
let home = kigi_home();
assert_eq!(profile.read_write[0], workspace);
for dir in ["sessions", "worktrees", "memory", "logs", "debug"] {
assert!(
profile.read_write.contains(&home.join(dir)),
"{dir} must stay writable: {:?}",
profile.read_write
);
}
for denied in [
home.clone(),
home.join("hooks"),
home.join("vendor"),
home.join("bin"),
] {
assert!(
!profile.read_write.contains(&denied),
"strict must not grant {}: {:?}",
denied.display(),
profile.read_write
);
}
assert!(
profile.read_only.contains(&home),
"config/auth/hooks are read after enforcement: {:?}",
profile.read_only
);
}
#[test]
#[cfg(all(feature = "enforce", unix))]
fn read_write_grant_follows_real_dirs_and_creates_missing_ones() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
std::fs::create_dir_all(home.join("sessions")).unwrap();
assert_eq!(
ProfileName::read_write_grant_path(&home.join("sessions"), &home),
Some(home.join("sessions"))
);
let missing = home.join("fresh");
assert_eq!(
ProfileName::read_write_grant_path(&missing, &home),
Some(missing.clone())
);
assert!(missing.is_dir());
}
#[test]
#[cfg(all(feature = "enforce", unix))]
fn read_write_grant_refuses_a_home_symlink_pointing_elsewhere() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let outside = tmp.path().join("outside");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::os::unix::fs::symlink(&outside, home.join("sessions")).unwrap();
assert_eq!(
ProfileName::read_write_grant_path(&home.join("sessions"), &home),
None
);
// A symlinked workspace outside the home is the user's own choice.
let ws_link = tmp.path().join("ws");
std::os::unix::fs::symlink(&outside, &ws_link).unwrap();
assert_eq!(
ProfileName::read_write_grant_path(&ws_link, &home),
Some(ws_link.clone())
);
}
#[test]
#[cfg(all(feature = "enforce", unix))]
fn base_profile_capability_set_builds() {
+9 -10
View File
@@ -1164,6 +1164,7 @@ mod platform {
#[cfg(not(target_os = "macos"))]
mod platform {
use super::ImageData;
#[cfg(target_os = "linux")]
use std::process::{Command, Stdio};
/// No subprocess-free pasteboard probe exists off-macOS.
@@ -1926,19 +1927,18 @@ mod platform {
// Public API
pub fn get_text() -> anyhow::Result<Option<String>> {
let mut arboard_error = None;
match arboard_get_text() {
let arboard_error = match arboard_get_text() {
Ok(Some(text)) => return Ok(Some(text)),
// Wayland-only: arboard's empty answer is not authoritative,
// fall through to wl-paste (see `wayland_tool_selected`).
#[cfg(target_os = "linux")]
Ok(None) if wayland_tool_selected(linux_tool_spec()) => {}
Ok(None) if wayland_tool_selected(linux_tool_spec()) => None,
Ok(None) => return Ok(None),
Err(e) => {
tracing::debug!("arboard get_text failed: {e}");
arboard_error = Some(e);
Some(e)
}
}
};
#[cfg(target_os = "linux")]
if let Some(spec) = linux_tool_spec() {
let bytes = run_capture_out_checked(spec.read_text, CLI_READ_WAIT)?;
@@ -2039,19 +2039,18 @@ mod platform {
}
pub fn get_image() -> anyhow::Result<Option<ImageData>> {
let mut arboard_error = None;
match arboard_get_image() {
let arboard_error = match arboard_get_image() {
Ok(Some(image)) => return Ok(Some(image)),
// Wayland-only: arboard's empty answer is not authoritative,
// fall through to wl-paste (see `wayland_tool_selected`).
#[cfg(target_os = "linux")]
Ok(None) if wayland_tool_selected(linux_tool_spec()) => {}
Ok(None) if wayland_tool_selected(linux_tool_spec()) => None,
Ok(None) => return Ok(None),
Err(e) => {
tracing::debug!("arboard get_image failed: {e}");
arboard_error = Some(e);
Some(e)
}
}
};
#[cfg(target_os = "linux")]
if let Some(spec) = linux_tool_spec()
&& let Some(argv) = spec.read_png
@@ -26,7 +26,7 @@ use std::io::{self, Write};
use std::path::Path;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
/// Creates or opens a file with secure permissions (owner read/write only).
///
@@ -58,17 +58,20 @@ pub fn write_secure_file(path: &Path, contents: &[u8]) -> io::Result<()> {
file.write_all(contents)?;
file.flush()?;
// On Windows, we need to set permissions after file creation
#[cfg(windows)]
{
set_windows_secure_permissions(path)?;
}
// Re-assert owner-only bits: `OpenOptions::mode` only applies on create,
// so an existing world-readable file would otherwise keep open perms.
ensure_owner_only_permissions(path)?;
Ok(())
}
/// Opens a file for writing with secure permissions set during creation (Unix)
/// or prepares it for permission setting after creation (Windows).
///
/// Callers that write secret material should also call
/// [`ensure_owner_only_permissions`] after the write (or use
/// [`write_secure_file`]), because `mode(0o600)` only applies when the file
/// is newly created — not when truncating an existing path.
pub fn open_secure_file(path: &Path) -> io::Result<File> {
let mut options = OpenOptions::new();
options.truncate(true).write(true).create(true);
@@ -82,6 +85,44 @@ pub fn open_secure_file(path: &Path) -> io::Result<File> {
options.open(path)
}
/// Ensure `path` is owner-read/write only (Unix `0o600` / Windows user ACL).
///
/// Best-effort on missing files (`NotFound` is ignored). Other errors
/// propagate so callers can fail closed when tightening a secret store.
///
/// Use on **load** of credential files so a hand-copied or restored
/// world-readable `auth.json` is tightened before the process continues.
pub fn ensure_owner_only_permissions(path: &Path) -> io::Result<()> {
match ensure_owner_only_permissions_inner(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
fn ensure_owner_only_permissions_inner(path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
let metadata = std::fs::metadata(path)?;
let mode = metadata.permissions().mode();
if mode & 0o777 != 0o600 {
let mut perms = metadata.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(path, perms)?;
}
Ok(())
}
#[cfg(windows)]
{
set_windows_secure_permissions(path)
}
#[cfg(not(any(unix, windows)))]
{
let _ = path;
Ok(())
}
}
/// Sets Windows-specific secure permissions on a file.
///
/// This function modifies the file's ACL to:
@@ -219,8 +260,6 @@ mod tests {
#[cfg(unix)]
#[test]
fn test_unix_permissions() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test_perms.txt");
@@ -231,4 +270,43 @@ mod tests {
// Check that only owner has read/write (0o600), ignoring file type bits
assert_eq!(mode & 0o777, 0o600);
}
#[cfg(unix)]
#[test]
fn ensure_owner_only_tightens_world_readable_file() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("loose.txt");
fs::write(&file_path, b"secret").unwrap();
let mut loose = fs::metadata(&file_path).unwrap().permissions();
loose.set_mode(0o644);
fs::set_permissions(&file_path, loose).unwrap();
assert_eq!(
fs::metadata(&file_path).unwrap().permissions().mode() & 0o777,
0o644
);
ensure_owner_only_permissions(&file_path).unwrap();
assert_eq!(
fs::metadata(&file_path).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[cfg(unix)]
#[test]
fn write_secure_file_tightens_existing_world_readable_file() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("existing.txt");
fs::write(&file_path, b"old").unwrap();
let mut loose = fs::metadata(&file_path).unwrap().permissions();
loose.set_mode(0o666);
fs::set_permissions(&file_path, loose).unwrap();
write_secure_file(&file_path, b"new secret").unwrap();
assert_eq!(
fs::metadata(&file_path).unwrap().permissions().mode() & 0o777,
0o600
);
assert_eq!(fs::read_to_string(&file_path).unwrap(), "new secret");
}
}
+1
View File
@@ -150,6 +150,7 @@ fs2 = { workspace = true }
zstd = { workspace = true }
tar = { workspace = true }
url = { workspace = true }
kigi-secrets = { path = "../kigi-secrets" }
walkdir = { workspace = true }
tdigests = "1.0"
semver = { workspace = true }
+2 -2
View File
@@ -2101,7 +2101,7 @@ kigi --sandbox strict
| `off` (default) | Unrestricted | Unrestricted | Unrestricted | No sandbox |
| `workspace` | Everywhere | CWD + `/tmp` + `~/.kigi/` | Allowed | Normal development |
| `read-only` | Everywhere | `~/.kigi/` only | Blocked | Exploration, code review |
| `strict` | CWD + system paths | CWD + `/tmp` + `~/.kigi/` | Blocked | Untrusted code |
| `strict` | CWD + system paths | CWD + `/tmp` + `~/.kigi/` state dirs | Blocked | Untrusted code |
Sensitive paths (`~/.ssh/`, `~/.aws/`, `~/.gnupg/`, `~/.kigi/auth/`) are always
write-protected regardless of profile.
@@ -2160,7 +2160,7 @@ model cannot convince the agent to relax restrictions at runtime.
### Event Logging
Sandbox events (profile applied, violations) are logged to `~/.kigi/sandbox-events.jsonl`
Sandbox events (profile applied, violations) are logged to `~/.kigi/sessions/sandbox-events.jsonl`
for telemetry and debugging.
---
+65 -2
View File
@@ -2171,6 +2171,19 @@ impl Config {
)
.with_segment_detail(self.resolve_compaction_detail())
}
/// Resolve summarizer `tool_choice`: env `KIGI_COMPACTION_TOOL_CHOICE` >
/// config > remote settings > default (`auto`).
pub(crate) fn resolve_compaction_tool_choice(
&self,
) -> crate::util::config::CompactionToolChoice {
crate::util::config::resolve_compaction_tool_choice_from(
env_string(crate::util::config::ENV_COMPACTION_TOOL_CHOICE).as_deref(),
self.features.compaction_tool_choice.as_deref(),
self.remote_settings
.as_ref()
.and_then(|r| r.compaction_tool_choice.as_deref()),
)
}
/// Resolve verbatim-input flag: env `KIGI_COMPACTION_VERBATIM_INPUT` > config > remote settings > default `true`.
pub(crate) fn resolve_compaction_verbatim_input(&self) -> bool {
BoolFlag::env("KIGI_COMPACTION_VERBATIM_INPUT")
@@ -2588,6 +2601,7 @@ pub(crate) fn resolve_model_list(
}
resolved = prefetched;
}
let mut explicit_api_backend_keys = std::collections::HashSet::new();
for (key, model_override) in &cfg.config_models {
let had_base = resolved.contains_key(key);
let base = resolved.shift_remove(key);
@@ -2604,6 +2618,9 @@ pub(crate) fn resolve_model_list(
);
}
}
if model_override.api_backend.is_some() {
explicit_api_backend_keys.insert(key.as_str());
}
let entry = model_override.apply(key, base, &cfg.endpoints);
tracing::debug!(
model_key = % key, base_url = % entry.info.base_url, has_api_key = entry
@@ -2625,7 +2642,7 @@ pub(crate) fn resolve_model_list(
)
})
.collect();
for entry in resolved.values_mut() {
for (key, entry) in resolved.iter_mut() {
if let Some((donor_cw, donor_backend)) = donors.get(&entry.info.model) {
if entry.info.context_window.get() == default_cw {
tracing::debug!(
@@ -2635,7 +2652,9 @@ pub(crate) fn resolve_model_list(
);
entry.info.context_window = *donor_cw;
}
if entry.info.api_backend == ApiBackend::default()
// An explicit `[model.X] api_backend` wins even when it is the default.
if !explicit_api_backend_keys.contains(key.as_str())
&& entry.info.api_backend == ApiBackend::default()
&& *donor_backend != ApiBackend::default()
{
entry.info.api_backend.clone_from(donor_backend);
@@ -3696,6 +3715,9 @@ pub struct Features {
/// Feed the summarizer the verbatim conversation instead of the lossy rewrite; `None` = defer to env/remote settings/default (true).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compaction_verbatim_input: Option<bool>,
/// Summarizer `tool_choice` (`auto` | `none`); `None` = defer to env/remote settings/default (`auto`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compaction_tool_choice: Option<String>,
/// Snapshot a completed subagent's isolated worktree into a durable git ref
/// and delete its directory (resume rehydrates from the ref). This is the
/// per-deployment rollout lever (set in managed_config.toml `[features]`).
@@ -9051,6 +9073,47 @@ default = "kigi-4.5"
sibling 'kigi-build' (same model slug), not stay at {default_cw}"
);
}
/// An explicit `[model.X] api_backend` survives a same-slug sibling even when it is the default.
#[test]
fn slug_propagation_keeps_an_explicit_default_api_backend() {
// Only kigi-messages has a non-default context window: the slug's single donor.
let default_cw = DEFAULT_CONTEXT_WINDOW;
let raw: toml::Value = toml::from_str(&format!(
r#"
[model.kigi-messages]
model = "kigi-4.5"
context_window = 500000
base_url = "https://test.example.com/v1"
api_backend = "messages"
[model.kigi-chat]
model = "kigi-4.5"
context_window = {default_cw}
base_url = "https://test.example.com/v1"
api_backend = "chat_completions"
[model.kigi-plain]
model = "kigi-4.5"
context_window = {default_cw}
base_url = "https://test.example.com/v1"
"#
))
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
let resolved = resolve_model_list(&cfg, None, &Default::default());
let backend = |key: &str| resolved.get(key).expect(key).info.api_backend.clone();
assert_eq!(
backend("kigi-chat"),
ApiBackend::ChatCompletions,
"explicit default backend must not be overwritten by the sibling"
);
assert_eq!(
backend("kigi-plain"),
ApiBackend::Messages,
"unset backend still inherits"
);
assert_eq!(backend("kigi-messages"), ApiBackend::Messages);
}
/// Slug propagation should carry over api_backend but NOT agent_type.
#[test]
fn slug_propagation_inherits_api_backend_but_not_agent_type() {
@@ -12,8 +12,7 @@ use std::sync::Arc;
use serde::Serialize;
/// Where non-subscription users (no OAuth session) submit feedback instead.
pub const FEEDBACK_ISSUES_URL: &str =
"https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/issues";
pub const FEEDBACK_ISSUES_URL: &str = "https://github.com/ZacharyZhang-NY/Kigi-CLI/issues";
/// HTTP error from the feedback endpoint with a preserved status code, so
/// callers can distinguish auth failures (401) without string matching.
@@ -210,9 +210,9 @@ pub(crate) async fn apply(
auto_compact_threshold_percent: new_threshold,
responds_to: tx,
});
let updated_model = rx
let (updated_model, context_window) = rx
.await
.map_err(|_| acp::Error::internal_error().data("failed to set session model"))?;
.map_err(|_| acp::Error::internal_error().data("failed to set session model"))??;
if let Some(handle) = agent.sessions.borrow_mut().get_mut(&session_id) {
handle.model_id = model_id.clone();
handle.reasoning_effort = applied_effort;
@@ -224,6 +224,7 @@ pub(crate) async fn apply(
&session_id,
model_id.0.as_ref(),
applied_effort.map(|eff| eff.to_string()),
context_window.get(),
);
if agent.cfg.borrow().mode != config::AgentMode::Leader {
agent.models_manager.set_current_model_id(model_id);
@@ -232,9 +233,12 @@ pub(crate) async fn apply(
.set_current_reasoning_effort(applied_effort);
}
Ok(acp::SetSessionModelResponse::new().meta(
serde_json::json!({ "model" : updated_model, })
.as_object()
.cloned(),
serde_json::json!({
"model": updated_model,
"totalContextTokens": context_window.get(),
})
.as_object()
.cloned(),
))
}
/// Broadcast a `ModelChanged` to every client subscribed to this session so
@@ -245,12 +249,14 @@ fn broadcast_model_changed(
session_id: &acp::SessionId,
model_id: &str,
reasoning_effort: Option<String>,
context_window: u64,
) {
let notification = crate::extensions::notification::SessionNotification {
session_id: session_id.clone(),
update: crate::extensions::notification::SessionUpdate::ModelChanged {
model_id: model_id.to_owned(),
reasoning_effort,
context_window: Some(context_window),
},
meta: None,
};
+54 -41
View File
@@ -1938,7 +1938,7 @@ fn resolve_prefetch_env(kimi_code_config: Option<KimiCodeConfig>) -> Option<Pref
/// credentials from disk.
pub fn start_early_prefetch_with_auth(auth: Option<KimiAuth>) -> Option<EarlyPrefetchHandle> {
let env = resolve_prefetch_env_with_auth(auth)?;
Some(spawn_prefetch_thread(env))
spawn_prefetch_thread(env)
}
/// Start the model-catalog prefetch on a background thread.
@@ -1949,51 +1949,64 @@ pub fn start_early_prefetch(
kimi_code_config: Option<KimiCodeConfig>,
) -> Option<EarlyPrefetchHandle> {
let env = resolve_prefetch_env(kimi_code_config)?;
Some(spawn_prefetch_thread(env))
spawn_prefetch_thread(env)
}
fn spawn_prefetch_thread(env: PrefetchEnv) -> EarlyPrefetchHandle {
std::thread::spawn(move || {
let mut timer = crate::instrumentation_timer!("startup.early_prefetch");
let proxy_endpoint = env.endpoints.proxy_url();
timer.with_field("endpoint", proxy_endpoint.as_str());
// Resolve each stored subscription-OAuth session's bearer (refreshed
// on expiry) so those platforms are part of the STARTUP fetch plan —
// otherwise a claude-pro-max-only user boots onto the bundled Kimi
// table until some later async refresh happens to run, and the cache
// origin (which encodes enabled platforms) never matches the
// async-path's claude-inclusive origin.
let oauth_tokens = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map(|rt| {
rt.block_on(crate::agent::models_fetch::resolve_generic_oauth_tokens(
&crate::auth::oauth_registry::pool_home(),
))
})
.unwrap_or_default();
let models = prefetch_models_blocking(
&env.endpoints,
env.auth.as_ref(),
&oauth_tokens,
env.model_fetch_auth,
&env.platform_keys,
);
if (env.endpoints.deployment_key.is_some() || crate::managed_config::has_active_team_auth())
&& crate::config::is_managed_config_stale_for(
&crate::managed_config::current_serving_identity(),
)
&& crate::managed_config::is_fetch_enabled()
&& let Ok(rt) = tokio::runtime::Builder::new_current_thread()
/// `None` when the OS refuses a thread: the prefetch is an optimization and
/// runs before the runtime builds, so a spawn panic here would preempt the
/// runtime's own out-of-threads diagnostic.
fn spawn_prefetch_thread(env: PrefetchEnv) -> Option<EarlyPrefetchHandle> {
let spawned = std::thread::Builder::new()
.name("early-prefetch".into())
.spawn(move || {
let mut timer = crate::instrumentation_timer!("startup.early_prefetch");
let proxy_endpoint = env.endpoints.proxy_url();
timer.with_field("endpoint", proxy_endpoint.as_str());
// Resolve each stored subscription-OAuth session's bearer (refreshed
// on expiry) so those platforms are part of the STARTUP fetch plan —
// otherwise a claude-pro-max-only user boots onto the bundled Kimi
// table until some later async refresh happens to run, and the cache
// origin (which encodes enabled platforms) never matches the
// async-path's claude-inclusive origin.
let oauth_tokens = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
crate::managed_config::clear_orphan();
let _ = rt.block_on(crate::managed_config::sync());
}
.map(|rt| {
rt.block_on(crate::agent::models_fetch::resolve_generic_oauth_tokens(
&crate::auth::oauth_registry::pool_home(),
))
})
.unwrap_or_default();
let models = prefetch_models_blocking(
&env.endpoints,
env.auth.as_ref(),
&oauth_tokens,
env.model_fetch_auth,
&env.platform_keys,
);
if (env.endpoints.deployment_key.is_some()
|| crate::managed_config::has_active_team_auth())
&& crate::config::is_managed_config_stale_for(
&crate::managed_config::current_serving_identity(),
)
&& crate::managed_config::is_fetch_enabled()
&& let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
crate::managed_config::clear_orphan();
let _ = rt.block_on(crate::managed_config::sync());
}
EarlyPrefetchResult { models }
})
EarlyPrefetchResult { models }
});
match spawned {
Ok(handle) => Some(handle),
Err(e) => {
tracing::error!(error = %e, "early prefetch thread spawn failed; skipping prefetch");
None
}
}
}
/// Map a model id (catalog key or routing slug) to its catalog key.
@@ -1725,8 +1725,8 @@ impl acp::Agent for MvpAgent {
return Ok(acp::PromptResponse::new(acp::StopReason::EndTurn));
}
}
let intake_lock = self.prompt_intake_lock(&arguments.session_id);
let intake_guard = intake_lock.lock().await;
let dispatch_lock = self.dispatch_lock(&arguments.session_id);
let dispatch_guard = dispatch_lock.lock().await;
let meta_prompt_mode = arguments
.meta
.as_ref()
@@ -1830,7 +1830,7 @@ impl acp::Agent for MvpAgent {
acp::Error::internal_error()
.data(format!("failed to dispatch prompt to session: {e}"))
})?;
drop(intake_guard);
drop(dispatch_guard);
self.push_roster_activity_delta(
&arguments.session_id,
crate::agent::roster::RosterActivity::Working,
@@ -2025,6 +2025,11 @@ impl acp::Agent for MvpAgent {
.and_then(|m| m.get("rewindIfPristine"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Ordered behind any in-flight prompt intake: a cancel that
// overtakes the prompt it targets lands on an idle actor, and the
// prompt then starts a turn nothing will ever cancel.
let dispatch_lock = self.dispatch_lock(&args.session_id);
let _dispatch_guard = dispatch_lock.lock().await;
let _ = handle
.cmd_tx
.send(SessionCommand::Cancel {
@@ -978,7 +978,10 @@ impl MvpAgent {
sessions: RefCell::new(HashMap::new()),
activity,
loading_sessions: RefCell::new(HashMap::new()),
prompt_intake_locks: RefCell::new(HashMap::new()),
dispatch_locks: RefCell::new(HashMap::new()),
subagent_limits:
kigi_tools::implementations::kigi::task::admission::SubagentLimits::from_env(),
subagent_spawn_permits: RefCell::new(HashMap::new()),
session_threads: RefCell::new(HashMap::new()),
resident_roster_titles: RefCell::new(HashMap::new()),
initialize_request: OnceLock::new(),
@@ -1362,7 +1365,8 @@ impl MvpAgent {
/// `FlushAndAck` on the persistence actor — a true sync barrier that only
/// resolves after all queued writes (chat messages, updates) hit disk.
///
/// Returns `Ok(())` on success, `Err(reason)` on timeout or channel failure.
/// Returns `Ok(())` once the writes are on disk, `Err(reason)` on a failed
/// flush, timeout or channel failure.
pub(crate) async fn flush_session(
&self,
session_id: &acp::SessionId,
@@ -1381,7 +1385,11 @@ impl MvpAgent {
return Err("send failed");
}
match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await {
Ok(Ok(_)) => Ok(()),
Ok(Ok(Ok(()))) => Ok(()),
Ok(Ok(Err(error))) => {
tracing::error!(session_id = %session_id.0, %error, "persistence flush failed");
Err("persistence flush failed")
}
Ok(Err(_)) => Err("channel closed"),
Err(_) => Err("timeout"),
}
@@ -2183,6 +2191,7 @@ impl MvpAgent {
.cfg
.borrow()
.resolve_compaction_verbatim_input();
let compaction_tool_choice = self.cfg.borrow().resolve_compaction_tool_choice();
let two_pass_enabled = self.cfg.borrow().is_two_pass_compaction_enabled();
let auto_update = self.cfg.borrow().cli.auto_update;
let client_type = *self.client_type.borrow();
@@ -2568,6 +2577,7 @@ impl MvpAgent {
system_prompt_label,
compaction_mode,
compaction_verbatim_input,
compaction_tool_choice,
two_pass_enabled,
buffering_settings,
origin_client.clone(),
@@ -462,21 +462,30 @@ pub struct MvpAgent {
loading_sessions: RefCell<
HashMap<acp::SessionId, tokio::sync::watch::Receiver<bool>>,
>,
/// Per-session prompt-intake serialization lock. LEADER-SAFE(per-session):
/// keyed by SessionId, mirrors `sessions` lifecycle.
///
/// Each incoming `session/prompt` RPC is dispatched as its own task by the
/// ACP message loop, and [`Self::prompt`] runs an async preamble (prompt-mode
/// query, trace context, model lookup) BEFORE it enqueues
/// `SessionCommand::Prompt` onto the actor's FIFO mailbox. Without
/// serialization those preambles interleave across tasks, so the mailbox —
/// and therefore the authoritative prompt queue — receives prompts out of
/// submission order. `prompt()` holds this lock across the preamble and
/// releases it immediately after the enqueue (the turn itself runs unlocked),
/// which makes intake order match arrival order.
prompt_intake_locks: RefCell<
/// Per-session lock ordering dispatch onto the actor's mailbox:
/// [`Self::prompt`] holds it across its intake preamble (prompt-mode
/// query, trace context, model lookup) and releases it right after the
/// enqueue, so prompts land in submission order; `cancel` holds it
/// around its `Cancel` send, so a cancel cannot overtake the prompt it
/// targets and wedge the turn slot (see
/// `cancel_never_overtakes_in_flight_prompt_intake`). Cancels wait out
/// preambles held ahead of them — keep preambles lean.
/// LEADER-SAFE(per-session): mirrors `sessions` lifecycle.
dispatch_locks: RefCell<
HashMap<acp::SessionId, std::rc::Rc<tokio::sync::Mutex<()>>>,
>,
/// Session-scoped subagent spawn limits, read once from env at
/// construction (composition root).
subagent_limits: kigi_tools::implementations::kigi::task::admission::SubagentLimits,
/// Per-parent-session spawn semaphores sized to `subagent_limits`; a
/// wide fan-out queues on the semaphore instead of exhausting file
/// descriptors. Keyed by session id string (the coordinator's
/// `parent_session_id`). Entries deliberately live for the whole
/// process: a removed-then-resumed session keeps its id, so sharing one
/// semaphore across generations keeps the limit correct while old
/// children still run. Growth is bounded by distinct sessions per
/// process and each entry is tiny. LEADER-SAFE(per-session).
subagent_spawn_permits: RefCell<HashMap<String, std::sync::Arc<tokio::sync::Semaphore>>>,
/// LEADER-SAFE(per-session): keyed by SessionId. Mirrors `sessions` lifecycle.
session_threads: RefCell<HashMap<acp::SessionId, SessionThread>>,
/// Title per resident session id, refreshed each `build_roster`. Lets the
@@ -717,10 +726,18 @@ pub struct MvpAgent {
/// sampling client is discarded, but the TLS root certificates it loads
/// are cached at the process level by `rustls-native-certs`.
pub fn warm_async_http_client() {
std::thread::spawn(|| {
let _timer = crate::instrumentation_timer!("startup.async_http_warmup");
let _ = crate::http::shared_client();
});
// Recoverable spawn: the warmup is an optimization that runs before the
// runtime builds — an out-of-threads host must reach the runtime's own
// diagnostic, not panic here.
let spawned = std::thread::Builder::new()
.name("http-warmup".into())
.spawn(|| {
let _timer = crate::instrumentation_timer!("startup.async_http_warmup");
let _ = crate::http::shared_client();
});
if let Err(e) = spawned {
tracing::warn!(error = %e, "async HTTP warmup thread spawn failed; first request warms instead");
}
}
pub(crate) fn resolve_required_agent_type(
model_agent_type: Option<&str>,
@@ -38,19 +38,16 @@ impl MvpAgent {
/// drops the `session_live_state` entry so that map stays bounded.
pub(crate) fn remove_session(&self, id: &acp::SessionId) {
self.sessions.borrow_mut().remove(id);
self.prompt_intake_locks.borrow_mut().remove(id);
self.dispatch_locks.borrow_mut().remove(id);
self.session_threads.borrow_mut().remove(id);
self.session_index_claims.borrow_mut().remove(id);
self.require_gateway_sessions.borrow_mut().remove(id);
self.session_live_state.borrow_mut().remove(id);
}
/// Get-or-create the per-session prompt-intake lock (see
/// [`Self::prompt_intake_locks`]).
pub(super) fn prompt_intake_lock(
&self,
id: &acp::SessionId,
) -> std::rc::Rc<tokio::sync::Mutex<()>> {
self.prompt_intake_locks
/// Get-or-create the per-session dispatch lock (see
/// [`Self::dispatch_locks`]). Cheap clone of the shared `Rc`.
pub(super) fn dispatch_lock(&self, id: &acp::SessionId) -> std::rc::Rc<tokio::sync::Mutex<()>> {
self.dispatch_locks
.borrow_mut()
.entry(id.clone())
.or_default()
@@ -272,6 +272,19 @@ impl MvpAgent {
self.try_build_subagent_spawn_context(parent_session_id)
.expect("parent session must exist when spawning subagents")
}
/// Get-or-create the parent session's spawn semaphore (see
/// [`Self::subagent_spawn_permits`]). Cheap clone of the shared `Arc`.
fn subagent_spawn_permit(&self, parent_session_id: &str) -> std::sync::Arc<tokio::sync::Semaphore> {
self.subagent_spawn_permits
.borrow_mut()
.entry(parent_session_id.to_string())
.or_insert_with(|| {
std::sync::Arc::new(tokio::sync::Semaphore::new(
self.subagent_limits.effective_max_concurrent(),
))
})
.clone()
}
/// Fallible variant of [`Self::build_subagent_spawn_context`]: returns
/// `None` when the parent `SessionHandle` is absent (evicted / torn down)
/// instead of panicking, so read-only paths that can race a teardown can
@@ -387,6 +400,8 @@ impl MvpAgent {
};
Some(crate::agent::subagent::SubagentSpawnContext {
lsp: parent_lsp,
spawn_limits: self.subagent_limits,
spawn_permits: self.subagent_spawn_permit(parent_session_id),
gateway: self.gateway.clone(),
client_hooks: Default::default(),
sampling_config: self.sampling_config.borrow().clone(),
@@ -2152,6 +2152,59 @@ fn cancel_does_not_forward_to_bridge_in_local_mode() {
);
});
}
/// Regression (post-cancel slot hang): a cancel racing the prompt it targets
/// must land on the actor mailbox AFTER that prompt, or it hits an idle
/// actor as a no-op and the prompt then starts a turn nothing cancels.
/// The dispatch lock (`dispatch_locks`) provides that ordering.
#[test]
fn cancel_never_overtakes_in_flight_prompt_intake() {
use crate::session::SessionCommand;
use acp::Agent as _;
run_local_for_bridge_test(|| async {
let agent = build_minimal_agent_for_tests();
let sid = acp::SessionId::new("sess-cancel-intake-race");
let (handle, _tx, mut cmd_rx) = make_live_session_handle(&sid, None);
agent.sessions.borrow_mut().insert(sid.clone(), handle);
let order: std::rc::Rc<std::cell::RefCell<Vec<&'static str>>> =
std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let (intake_parked_tx, intake_parked_rx) = tokio::sync::oneshot::channel::<()>();
let driver_order = order.clone();
tokio::task::spawn_local(async move {
let mut intake_parked_tx = Some(intake_parked_tx);
while let Some(cmd) = cmd_rx.recv().await {
match cmd {
SessionCommand::GetCurrentPromptMode { .. } => {
if let Some(tx) = intake_parked_tx.take() {
let _ = tx.send(());
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
SessionCommand::Prompt { .. } => driver_order.borrow_mut().push("prompt"),
SessionCommand::Cancel { .. } => driver_order.borrow_mut().push("cancel"),
_ => {}
}
}
});
let prompt_fut = agent.prompt(acp::PromptRequest::new(
sid.clone(),
vec![acp::ContentBlock::from("hi")],
));
let cancel_fut = async {
intake_parked_rx
.await
.expect("prompt intake reaches the fake actor");
let _ = agent
.cancel(acp::CancelNotification::new(sid.clone()))
.await;
};
let _ = futures::join!(prompt_fut, cancel_fut);
assert_eq!(
order.borrow().as_slice(),
["prompt", "cancel"],
"cancel must land on the actor mailbox after the prompt it targets"
);
});
}
use crate::session::SessionCommand as TestSessionCommand;
/// Build a session handle wired to a *live* command channel. Returns the
/// handle (move into `sessions`) plus a probe `cmd_tx`/`cmd_rx` so a test
@@ -27,7 +27,7 @@ async fn subagent_spawn_context_inherits_parent_permission_handle() {
kigi_workspace::permission::spawn_permission_manager(
sid.clone(),
gateway,
cwd,
cwd.clone(),
kigi_workspace::permission::types::ClientType::Generic,
Some(PermissionConfig::new(vec![PermissionRule {
action: RuleAction::Deny,
@@ -64,6 +64,8 @@ async fn subagent_spawn_context_inherits_parent_permission_handle() {
Some("child-session".to_owned()),
Some("general-purpose".to_owned()),
Some("permission inheritance regression".to_owned()),
cwd.clone(),
None,
)
.await;
assert!(
@@ -19,6 +19,99 @@ use kigi_tools::implementations::kigi::task::types::*;
use kigi_workspace::file_system::AsyncFileSystem;
use kigi_hunk_tracker::HunkTrackerHandle;
use super::*;
/// Bounds each parent-side await in the child completion path so a starved parent `cmd_rx` cannot park a completed child forever.
pub(super) const PARENT_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// Bounds reads of the child's own actors, which freeze with a tool that blocks the child thread; teardown must still reach `Shutdown`.
pub(super) const CHILD_ACTOR_ACK_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(10);
/// One bounded child-actor query, degrading to `fallback` when the actor never answers.
pub(super) async fn child_actor_query<T>(
what: &'static str,
query: impl std::future::Future<Output = T>,
fallback: T,
) -> T {
match tokio::time::timeout(CHILD_ACTOR_ACK_TIMEOUT, query).await {
Ok(value) => value,
Err(_) => {
tracing::warn!(query = what, "child actor did not answer in time; using fallback");
fallback
}
}
}
/// Bounds the post-Shutdown worktree snapshot (a `git add` behind a stuck clean filter must not park completion).
const WORKTREE_SNAPSHOT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
/// Snapshot the child worktree to `ref_name`, persist the ref, then remove the worktree; returns `(snapshot_ref, removed)`.
async fn dispose_subagent_worktree(
worktree: &Path,
source_repo: &Path,
ref_name: &str,
meta_dir: &Path,
final_status: &str,
subagent_id: &str,
) -> (Option<String>, bool) {
let snapshot = tokio::time::timeout(
WORKTREE_SNAPSHOT_TIMEOUT,
crate::session::worktree::snapshot_subagent_worktree(worktree, source_repo, ref_name),
)
.await;
let snapshot_ref = match snapshot {
Ok(Ok(snapshot_ref)) => snapshot_ref,
Ok(Err(e)) => {
tracing::warn!(
subagent_id = % subagent_id, worktree_path = % worktree.display(), error = % e,
"Failed to snapshot subagent worktree; preserving for review"
);
return (None, false);
}
Err(_) => {
tracing::warn!(
subagent_id = % subagent_id, worktree_path = % worktree.display(),
"worktree snapshot did not finish in time; preserving worktree for resume"
);
return (None, false);
}
};
if !update_subagent_meta_snapshot_ref(meta_dir, &snapshot_ref, final_status) {
tracing::warn!(
subagent_id = % subagent_id, worktree_path = % worktree.display(),
"snapshot_ref not persisted; preserving worktree for resume"
);
return (None, false);
}
// Removal is awaited in full: a detached deletion could race a resume that rehydrates into this path.
match crate::session::worktree::remove_subagent_worktree(worktree).await {
Ok(()) => {
tracing::info!(
subagent_id = % subagent_id, worktree_path = % worktree.display(),
"snapshotted and removed subagent worktree"
);
(Some(snapshot_ref), true)
}
Err(e) => {
tracing::warn!(
subagent_id = % subagent_id, worktree_path = % worktree.display(), error = % e,
"snapshotted subagent worktree but removal failed; ref persisted for resume"
);
(Some(snapshot_ref), false)
}
}
}
/// Await a parent ack for at most `PARENT_ACK_TIMEOUT`; `false` when it never answers or drops the sender.
pub(super) async fn parent_ack(what: &'static str, ack: oneshot::Receiver<()>) -> bool {
match tokio::time::timeout(PARENT_ACK_TIMEOUT, ack).await {
Ok(Ok(())) => true,
Ok(Err(_)) => false,
Err(_) => {
tracing::warn!(ack = what, "parent did not ack in time; proceeding to teardown");
false
}
}
}
pub(super) fn task_model_override_error(
requested: Option<&str>,
provenance: ModelOverrideProvenance,
@@ -81,6 +174,64 @@ pub(crate) async fn handle_subagent_request(
}
_ => {}
}
// Session-scoped concurrency admission: hold one permit for the whole
// child run so a wide fan-out queues (default) or fails (configured)
// instead of exhausting file descriptors. Queued spawns are not yet
// visible in the coordinator; they admit in FIFO order.
use kigi_tools::implementations::kigi::task::admission::LimitBehavior;
let _spawn_permit = match ctx.spawn_limits.behavior {
LimitBehavior::Fail => match ctx.spawn_permits.clone().try_acquire_owned() {
Ok(permit) => permit,
Err(_) => {
let limit = ctx.spawn_limits.effective_max_concurrent();
kigi_log::unified_log::warn(
"subagent.spawn.rejected_at_concurrent_limit",
Some(&ctx.parent_session_id),
Some(serde_json::json!({ "limit": limit })),
);
let msg = format!(
"Session subagent limit reached ({limit} running); \
retry after one finishes, or raise KIGI_MAX_CONCURRENT_SUBAGENTS"
);
send_pre_spawn_failure(request, &msg, coordinator, &ctx, gateway);
return;
}
},
LimitBehavior::Queue => {
if ctx.spawn_permits.available_permits() == 0 {
kigi_log::unified_log::warn(
"subagent.spawn.queued_at_concurrent_limit",
Some(&ctx.parent_session_id),
Some(serde_json::json!({
"limit": ctx.spawn_limits.effective_max_concurrent(),
"subagent_id": request.id,
})),
);
}
ctx.spawn_permits
.clone()
.acquire_owned()
.await
.expect("subagent spawn semaphore is never closed")
}
};
// The parent can end while a spawn is queued on the semaphore (a queued
// request is not yet in the coordinator, so the teardown cancel sweep
// cannot see it). A closed parent command channel is the teardown
// signal. The channel closes when the actor finishes shutting down, so a
// permit granted during that brief drain can still slip through — the
// same in-flight window an ordinary spawn racing teardown already has;
// closing it fully would require queueing inside the coordinator.
if ctx.parent_cmd_tx.as_ref().is_some_and(|tx| tx.is_closed()) {
send_pre_spawn_failure(
request,
"Parent session ended while the spawn was queued at the subagent limit.",
coordinator,
&ctx,
gateway,
);
return;
}
let run_in_background = request.run_in_background
|| definition.background.unwrap_or(false);
let cancel_token = CancellationToken::new();
@@ -837,13 +988,22 @@ pub(crate) async fn handle_subagent_request(
}
}
}
let agent_mcp_servers: Vec<_> = if is_plugin_agent {
if !definition.mcp_servers.is_empty() {
tracing::warn!(
agent = % definition.name, plugin = ? definition.plugin_name,
"ignoring mcpServers on plugin agent (not supported for security)"
);
}
let agent_mcp_servers: Vec<_> = if definition.mcp_servers.is_empty() {
vec![]
} else if is_plugin_agent {
tracing::warn!(
agent = % definition.name, plugin = ? definition.plugin_name,
"ignoring mcpServers on plugin agent (not supported for security)"
);
vec![]
} else if !crate::agent::folder_trust::agent_inline_hooks_allowed(definition.scope, || {
crate::agent::folder_trust::project_scope_allowed(&ctx.parent_cwd)
}) {
// An untrusted repo's inline stdio MCP command would run like an inline hook.
tracing::warn!(
agent = % definition.name,
"ignoring mcpServers on untrusted project agent (folder not trusted; re-run with --trust)"
);
vec![]
} else {
definition
@@ -1006,6 +1166,7 @@ pub(crate) async fn handle_subagent_request(
kigi_agent::DEFAULT_SYSTEM_PROMPT_LABEL.to_string(),
kigi_chat_state::CompactionMode::Summary,
ctx.resolve_compaction_verbatim_input(),
ctx.resolve_compaction_tool_choice(),
false,
None,
None,
@@ -1274,7 +1435,12 @@ pub(crate) async fn handle_subagent_request(
let duration_ms = start.elapsed().as_millis() as u64;
let mut result = match wait_outcome {
SubagentWaitOutcome::Cancelled => {
let (tool_calls, turns) = signals_snapshot_counts(&child_handle).await;
let (tool_calls, turns) = child_actor_query(
"signals_snapshot_counts",
signals_snapshot_counts(&child_handle),
(0, 0),
)
.await;
SubagentResult {
success: false,
cancelled: true,
@@ -1301,14 +1467,28 @@ pub(crate) async fn handle_subagent_request(
},
),
) => (snapshot.current.tool_call_count, snapshot.current.turn_count),
_ => signals_snapshot_counts(&child_handle).await,
_ => {
child_actor_query(
"signals_snapshot_counts",
signals_snapshot_counts(&child_handle),
(0, 0),
)
.await
}
};
let final_text = child_handle
.chat_state_handle
.get_last_assistant_text()
.await
.unwrap_or_default();
let result_tokens = child_handle.chat_state_handle.get_total_tokens().await;
let final_text = child_actor_query(
"get_last_assistant_text",
child_handle.chat_state_handle.get_last_assistant_text(),
None,
)
.await
.unwrap_or_default();
let result_tokens = child_actor_query(
"get_total_tokens",
child_handle.chat_state_handle.get_total_tokens(),
0,
)
.await;
match *turn_result {
Ok(
Ok(
@@ -1468,14 +1648,21 @@ pub(crate) async fn handle_subagent_request(
let final_status = result.status().to_string();
let snapshot_dispose_enabled = ctx.resolve_subagent_worktree_snapshot_enabled();
let telemetry_tokens = if result.tool_calls > 0 || result.success {
child_handle.chat_state_handle.get_total_tokens().await
child_actor_query(
"get_total_tokens",
child_handle.chat_state_handle.get_total_tokens(),
0,
)
.await
} else {
0
};
let (subagent_usage_by_model, subagent_usage_incomplete) = match child_handle
.chat_state_handle
.try_get_session_usage()
.await
let (subagent_usage_by_model, subagent_usage_incomplete) = match child_actor_query(
"try_get_session_usage",
child_handle.chat_state_handle.try_get_session_usage(),
Err(()),
)
.await
{
Ok(u) => (Some(u.by_model.into_iter().collect::<Vec<_>>()), u.incomplete),
Err(()) => (None, true),
@@ -1494,7 +1681,7 @@ pub(crate) async fn handle_subagent_request(
respond_to,
})
{
Ok(()) => ack.await.is_ok(),
Ok(()) => parent_ack("RecordSubagentUsage", ack).await,
Err(_) => false,
}
} else {
@@ -1511,53 +1698,71 @@ pub(crate) async fn handle_subagent_request(
.parent_prompt_id
.clone()
.or_else(|| coordinator.borrow().parent_prompt_id_for(&request.id));
if let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref() {
// A slow parent may apply both the fold and this mark; both are idempotent.
let marked_by_parent = if let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref() {
let (respond_to, ack) = tokio::sync::oneshot::channel();
if cmd_tx
cmd_tx
.send(crate::session::commands::SessionCommand::MarkSubagentUsageNotApplied {
parent_prompt_id: sticky_prompt,
parent_prompt_id: sticky_prompt.clone(),
respond_to,
})
.is_ok()
{
let _ = ack.await;
}
} else if let Some(ref pid) = sticky_prompt {
&& parent_ack("MarkSubagentUsageNotApplied", ack).await
} else {
false
};
if !marked_by_parent && let Some(ref pid) = sticky_prompt {
coordinator.borrow_mut().mark_subagent_usage_not_applied(pid);
}
}
match (&ctx.parent_terminal_backend, &ctx.parent_notification_handle) {
(Some(parent_tb), Some(parent_notif_handle)) => {
if !request.surface_completion {
let reparented_task_ids: Vec<String> = parent_tb
.list_tasks()
.await
.into_iter()
.filter(|t| {
!t.completed
&& t.owner_session_id.as_deref()
== Some(&*child_session_id.0)
})
.map(|t| t.task_id)
.collect();
if !reparented_task_ids.is_empty()
&& let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref()
{
let _ = cmd_tx
.send(SessionCommand::RecordGoalTurnTaskIds {
task_ids: reparented_task_ids,
});
match tokio::time::timeout(PARENT_ACK_TIMEOUT, parent_tb.list_tasks()).await {
Ok(tasks) => {
let reparented_task_ids: Vec<String> = tasks
.into_iter()
.filter(|t| {
!t.completed
&& t.owner_session_id.as_deref()
== Some(&*child_session_id.0)
})
.map(|t| t.task_id)
.collect();
if !reparented_task_ids.is_empty()
&& let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref()
{
let _ = cmd_tx.send(SessionCommand::RecordGoalTurnTaskIds {
task_ids: reparented_task_ids,
});
}
}
Err(_) => tracing::warn!(
child_session_id = % child_session_id.0,
parent_session_id = % ctx.parent_session_id,
"list_tasks not answered in time; skipping goal-turn task-id recording"
),
}
}
let parent_backend_weak = std::sync::Arc::downgrade(parent_tb);
parent_tb
.reparent_notifications(
if tokio::time::timeout(
PARENT_ACK_TIMEOUT,
parent_tb.reparent_notifications(
&child_session_id.0,
&ctx.parent_session_id,
parent_notif_handle.clone(),
parent_backend_weak,
)
.await;
),
)
.await
.is_err()
{
tracing::warn!(
child_session_id = % child_session_id.0,
parent_session_id = % ctx.parent_session_id,
"reparent_notifications not acked in time; proceeding to child shutdown"
);
}
}
(Some(_), None) | (None, Some(_)) => {
tracing::warn!(
@@ -1578,55 +1783,17 @@ pub(crate) async fn handle_subagent_request(
if snapshot_dispose_enabled {
let ref_name = format!("refs/kigi/subagents/{}", request.id);
let source_repo = resolve_subagent_source_repo(&ctx);
match crate::session::worktree::snapshot_subagent_worktree(
wt_path,
&source_repo,
&ref_name,
)
.await
{
Ok(snapshot_ref) => {
let persisted = update_subagent_meta_snapshot_ref(
&subagent_meta_dir,
&snapshot_ref,
&final_status,
);
if persisted {
disposed_snapshot_ref = Some(snapshot_ref);
match crate::session::worktree::remove_subagent_worktree(wt_path)
.await
{
Ok(()) => {
worktree_removed = true;
tracing::info!(
subagent_id = % request.id, worktree_path = % wt_path
.display(), "snapshotted and removed subagent worktree"
);
}
Err(e) => {
tracing::warn!(
subagent_id = % request.id, worktree_path = % wt_path
.display(), error = % e,
"snapshotted subagent worktree but removal failed; ref persisted for resume"
)
}
}
} else {
tracing::warn!(
subagent_id = % request.id, worktree_path = % wt_path
.display(),
"snapshot_ref not persisted; preserving worktree for resume"
);
}
}
Err(e) => {
tracing::warn!(
subagent_id = % request.id, worktree_path = % wt_path.display(),
error = % e,
"Failed to snapshot subagent worktree; preserving for review"
);
}
}
let (snapshot_ref, removed) = dispose_subagent_worktree(
wt_path,
&source_repo,
&ref_name,
&subagent_meta_dir,
&final_status,
&request.id,
)
.await;
disposed_snapshot_ref = snapshot_ref;
worktree_removed = removed;
} else {
tracing::info!(
subagent_id = % request.id, worktree_path = % wt_path.display(),
@@ -133,6 +133,13 @@ impl AutoCompactThresholdTiers {
pub(crate) struct SubagentSpawnContext {
/// Parent's LSP runtime — inherited via ToolContext, same as fs/terminal.
pub lsp: Option<std::sync::Arc<dyn kigi_tools::implementations::lsp::LspBackend>>,
/// Session-scoped spawn limits (env-resolved once at the composition root).
pub spawn_limits: kigi_tools::implementations::kigi::task::admission::SubagentLimits,
/// Parent session's spawn semaphore, sized to
/// `spawn_limits.effective_max_concurrent()`. A permit is held for the
/// whole child run, so a wide fan-out queues (or fails, per behavior)
/// instead of exhausting file descriptors.
pub spawn_permits: std::sync::Arc<tokio::sync::Semaphore>,
#[expect(
dead_code,
reason = "unused in production; remove expect when wired or delete the item"
@@ -397,6 +404,21 @@ impl SubagentSpawnContext {
.resolve()
.value
}
/// Subagent summarizer `tool_choice`, mirroring
/// `Config::resolve_compaction_tool_choice` (env > config > remote
/// settings > default `auto`).
pub fn resolve_compaction_tool_choice(&self) -> crate::util::config::CompactionToolChoice {
crate::util::config::resolve_compaction_tool_choice_from(
crate::agent::config::env_string(crate::util::config::ENV_COMPACTION_TOOL_CHOICE)
.as_deref(),
self.agent_config
.as_ref()
.and_then(|c| c.features.compaction_tool_choice.as_deref()),
self.remote_settings
.as_ref()
.and_then(|r| r.compaction_tool_choice.as_deref()),
)
}
/// Whether a completed subagent's worktree is snapshotted into a durable ref
/// and its directory deleted. Resolution mirrors the other subagent gates
/// (env > config > remote settings > default). Default `false` so it ships dark;
@@ -3298,3 +3298,27 @@ fn spawn_test_parent_chat_state(model_slug: &str) -> kigi_chat_state::ChatStateH
)
}
mod rest;
/// A blocked child actor must not park teardown: the read degrades to the fallback in bounded time.
#[tokio::test(start_paused = true)]
async fn child_actor_query_is_bounded_when_the_actor_never_answers() {
use super::handle_request::{CHILD_ACTOR_ACK_TIMEOUT, child_actor_query};
let tokens = tokio::time::timeout(
20 * CHILD_ACTOR_ACK_TIMEOUT,
child_actor_query("test_query", std::future::pending::<u64>(), 7),
)
.await
.expect("child-actor queries must complete in bounded time");
assert_eq!(tokens, 7, "a starved query must degrade to the fallback");
}
/// A parent that never services `cmd_rx` must not park a completed child.
#[tokio::test(start_paused = true)]
async fn parent_ack_is_bounded_when_the_parent_never_answers() {
use super::handle_request::{PARENT_ACK_TIMEOUT, parent_ack};
let (_never_answered, ack) = tokio::sync::oneshot::channel::<()>();
let acked = tokio::time::timeout(20 * PARENT_ACK_TIMEOUT, parent_ack("test_ack", ack))
.await
.expect("parent acks must complete in bounded time");
assert!(!acked, "an unanswered ack must report a miss");
}
+154 -12
View File
@@ -224,6 +224,18 @@ impl AuthFileLock {
pub fn read_auth_json(auth_file: &Path) -> std::io::Result<AuthStore> {
let mut file = File::open(auth_file)?;
// Tighten world-readable copies (hand-restored, umask edge cases, etc.)
// right after open, so even an unreadable/invalid file gets fixed.
// Best-effort: a chmod failure must not block login/read paths.
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(auth_file) {
tracing::warn!(
path = %auth_file.display(),
error = %e,
"auth: failed to enforce owner-only permissions on auth.json"
);
}
let mut contents = String::new();
file.read_to_string(&mut contents)?;
@@ -289,6 +301,14 @@ pub(crate) fn backup_corrupt_auth_file(path: &Path) -> Option<PathBuf> {
match std::fs::rename(path, &backup) {
Ok(()) => {
// Corrupt backups still hold token material — keep them owner-only.
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(&backup) {
tracing::warn!(
path = %backup.display(),
error = %e,
"auth: failed to tighten permissions on corrupt-auth backup"
);
}
tracing::warn!(
original = %path.display(),
backup = %backup.display(),
@@ -421,18 +441,59 @@ fn write_store_to(path: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
.into_inner()
.map_err(|e| e.into_error())?
.sync_all()?;
#[cfg(windows)]
{
crate::util::secure_file::set_windows_secure_permissions(path)?;
// `open_secure_file` mode bits apply only on create; tighten existing
// paths. Best-effort after durable content: a chmod-only failure must not
// look like a failed write — the in-place fallback restores the prior
// snapshot on any `write_store_to` Err, which would discard freshly
// written tokens. The load path re-tightens on the next read.
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(path) {
tracing::warn!(
error = %e,
path = %path.display(),
"auth: failed to ensure owner-only permissions after write"
);
}
Ok(())
}
/// Atomic write: tmp + Windows-safe replace (see `util::fs::replace_file`).
fn write_auth_json_atomic(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
let tmp = auth_file.with_extension(format!("json.{}.tmp", std::process::id()));
write_store_to(&tmp, auth_store)?;
crate::util::fs::replace_file(&tmp, auth_file)
write_auth_json_atomic_with(auth_file, auth_store, write_store_to)
}
/// Inner of [`write_auth_json_atomic`] with the tmp-file writer injectable so
/// the torn-tmp-write path is unit-testable without real I/O faults.
fn write_auth_json_atomic_with(
auth_file: &Path,
auth_store: &AuthStore,
write: fn(&Path, &AuthStore) -> std::io::Result<()>,
) -> std::io::Result<()> {
// Unique per write (pid + monotonic seq): two concurrent in-process
// writers (e.g. background mint + proactive refresher) must not share
// one tmp path, or each can tear the other's half-written bytes.
static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let tmp = auth_file.with_extension(format!(
"json.{}.{}.tmp",
std::process::id(),
TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
if let Err(e) = write(&tmp, auth_store) {
// Reclaim the partial tmp; the unique name otherwise accumulates
// one orphan per failed write. `replace_file` cleans its own leg.
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
crate::util::fs::replace_file(&tmp, auth_file)?;
// Re-assert on the final path (covers rename edge cases / FS quirks).
// Best-effort: the replace already published the new tokens.
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(auth_file) {
tracing::warn!(
error = %e,
path = %auth_file.display(),
"auth: failed to ensure owner-only permissions after replace"
);
}
Ok(())
}
/// Non-atomic fallback: truncate and rewrite `auth.json` in place.
@@ -486,10 +547,7 @@ fn restore_prior_bytes(auth_file: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut file = open_secure_file(auth_file)?;
file.write_all(bytes)?;
file.sync_all()?;
#[cfg(windows)]
{
crate::util::secure_file::set_windows_secure_permissions(auth_file)?;
}
crate::util::secure_file::ensure_owner_only_permissions(auth_file)?;
Ok(())
}
@@ -804,7 +862,87 @@ mod write_fallback_tests {
);
}
/// Rollback after a failed write must keep the file owner-only (0o600).
/// An in-place rewrite over a pre-existing world-readable auth.json must
/// tighten it to 0o600 — `open_secure_file` mode bits apply only on
/// create, so truncating an existing loose file keeps its old mode. The
/// in-place path is the one that reuses the inode (the atomic path
/// replaces it with a fresh 0o600 temp file).
#[cfg(unix)]
#[test]
fn in_place_write_tightens_preexisting_world_readable_auth_json() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
std::fs::write(&path, b"{}").unwrap();
let mut loose = std::fs::metadata(&path).unwrap().permissions();
loose.set_mode(0o644);
std::fs::set_permissions(&path, loose).unwrap();
write_auth_json_in_place(&path, &sample_store()).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(
mode & 0o777,
0o600,
"in-place rewrite must tighten preexisting open perms"
);
}
/// Simulates a torn tmp write: partial bytes land at the target path,
/// then the write fails — the temp file must not be left behind.
fn fake_partial_write_then_fail(path: &Path, _: &AuthStore) -> std::io::Result<()> {
std::fs::write(path, b"{ torn")?;
Err(std::io::Error::from(std::io::ErrorKind::StorageFull))
}
/// A tmp write that fails partway must reclaim the temp file; the unique
/// tmp name otherwise accumulates one orphan per failed write.
#[test]
fn atomic_write_reclaims_tmp_when_store_write_fails() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
let err = write_auth_json_atomic_with(&path, &sample_store(), fake_partial_write_then_fail)
.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::StorageFull);
let orphans: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
.collect();
assert!(
orphans.is_empty(),
"failed tmp write must not leave temp files behind: {orphans:?}"
);
}
/// The replace step failing (target path occupied by a directory) must
/// also leave no temp file — `replace_file` reclaims its tmp on failure.
#[test]
fn atomic_write_reclaims_tmp_when_replace_fails() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
std::fs::create_dir(&path).unwrap();
assert!(
write_auth_json_atomic(&path, &sample_store()).is_err(),
"replacing a directory must fail"
);
let orphans: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
.collect();
assert!(
orphans.is_empty(),
"failed replace must not leave temp files behind: {orphans:?}"
);
}
/// Rollback after a failed write must leave the file owner-only (0o600)
/// even when the file had loose permissions going in — the restore path
/// truncates the existing inode, which keeps its old mode unless
/// explicitly tightened.
#[cfg(unix)]
#[test]
fn in_place_restore_is_owner_only() {
@@ -812,9 +950,13 @@ mod write_fallback_tests {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("auth.json");
write_auth_json_in_place(&path, &sample_store()).unwrap();
let mut loose = std::fs::metadata(&path).unwrap().permissions();
loose.set_mode(0o644);
std::fs::set_permissions(&path, loose).unwrap();
let _ = write_auth_json_in_place_with(&path, &sample_store(), fake_truncate_then_fail);
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "restored file must stay 0o600");
assert_eq!(mode & 0o777, 0o600, "restored file must be 0o600");
}
}
+18 -7
View File
@@ -1034,16 +1034,27 @@ pub fn apply_sandbox(
.and_then(|p| dunce::canonicalize(p).ok())
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| std::path::PathBuf::from("."));
if kigi_sandbox::restricts_home_writes(&sandbox_profile, &workspace) {
// The profile leaves ~/.kigi read-only outside its state dirs: mint
// what first use would otherwise write after enforcement.
if let Err(e) = crate::auth::device::device_id() {
tracing::warn!(error = %e, "sandbox: device id not created before enforcement");
}
kigi_tools::implementations::kigi::grep::ripgrep::rg_path();
crate::builtin::extract_bundled_files(&crate::util::kigi_home::kigi_home());
}
// The devbox `/data` write-deny is a bwrap mount; Landlock would grant the workspace under `/data`.
#[cfg(target_os = "linux")]
let requires_read_deny = kigi_sandbox::requires_read_deny(&sandbox_profile, &workspace);
let bwrap_required = kigi_sandbox::requires_read_deny(&sandbox_profile, &workspace)
|| kigi_sandbox::requires_data_write_deny(&sandbox_profile, &workspace);
#[cfg(target_os = "linux")]
{
let refuse_unprotected = |detail: &str| {
eprintln!(
"error: this sandbox could not enforce its read-deny set on Linux \
(bubblewrap missing/unusable, or a deny glob exceeded its expansion \
limit — see any message above). Install bubblewrap with \
`apt install -y bubblewrap` if needed. Refusing to start with denied \
"error: this sandbox could not enforce its read-deny set or the devbox \
`/data` write-deny on Linux (bubblewrap missing/unusable, or a deny glob \
exceeded its expansion limit — see any message above). Install bubblewrap \
with `apt install -y bubblewrap` if needed. Refusing to start with denied \
paths unprotected.{detail}"
);
};
@@ -1051,7 +1062,7 @@ pub fn apply_sandbox(
Some(mut cmd) => {
use std::os::unix::process::CommandExt;
let err = cmd.exec();
if requires_read_deny {
if bwrap_required {
refuse_unprotected(&format!(" (bwrap exec failed: {err})"));
std::process::exit(1);
}
@@ -1061,7 +1072,7 @@ pub fn apply_sandbox(
Install bubblewrap: apt install -y bubblewrap"
);
}
None if requires_read_deny && !kigi_sandbox::is_inside_bwrap() => {
None if bwrap_required && !kigi_sandbox::is_inside_bwrap() => {
refuse_unprotected("");
std::process::exit(1);
}
@@ -37,9 +37,9 @@ async fn handle_compact(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
respond_to: tx,
});
}
// The session's own ACP error goes through unchanged: a Debug wrapper would escape its detail.
rx.await
.map_err(|_| acp::Error::internal_error().data("session failed to respond"))?
.map_err(|e| acp::Error::internal_error().data(format!("Internal error: {:?}", e)))?;
.map_err(|_| acp::Error::internal_error().data("session failed to respond"))??;
to_raw_response(&CompactConversationResponse {})
}
@@ -691,6 +691,11 @@ pub enum SessionUpdate {
/// does not support reasoning effort or no effort override was applied.
#[serde(default, skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
/// The session's effective context window after the switch, so a
/// follower repaints its header without waiting for the next turn.
/// `None` from an agent that predates the field.
#[serde(default, skip_serializing_if = "Option::is_none")]
context_window: Option<u64>,
},
/// Streaming chunk of a tool call's arguments.
///
@@ -1970,15 +1975,18 @@ mod tests {
let with_effort = SessionUpdate::ModelChanged {
model_id: "kigi-4".into(),
reasoning_effort: Some("high".into()),
context_window: Some(262_144),
};
let json = serde_json::to_value(&with_effort).unwrap();
assert_eq!(json["sessionUpdate"], "model_changed");
assert_eq!(json["model_id"], "kigi-4");
assert_eq!(json["reasoning_effort"], "high");
assert_eq!(json["context_window"], 262_144);
let without_effort = SessionUpdate::ModelChanged {
model_id: "kigi-3".into(),
reasoning_effort: None,
context_window: None,
};
let json = serde_json::to_value(&without_effort).unwrap();
assert_eq!(json["sessionUpdate"], "model_changed");
@@ -1988,6 +1996,7 @@ mod tests {
"reasoning_effort: None must be skipped on the wire so old pagers \
and third-party ACP clients see a smaller, no-extra-keys payload"
);
assert!(json.get("context_window").is_none());
}
/// `ModelChanged` round-trips through JSON: a follower client deserializes
@@ -2000,6 +2009,7 @@ mod tests {
let original = SessionUpdate::ModelChanged {
model_id: "kigi-4".into(),
reasoning_effort: Some("medium".into()),
context_window: Some(1_000_000),
};
let json_str = serde_json::to_string(&original).unwrap();
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
@@ -2021,6 +2031,7 @@ mod tests {
update: SessionUpdate::ModelChanged {
model_id: "kigi-4".into(),
reasoning_effort: None,
context_window: None,
},
meta: None,
};
+13 -4
View File
@@ -52,10 +52,16 @@ pub fn default_lock_path_in(root: &Path) -> PathBuf {
root.join("leader.lock")
}
/// Runtime files live under `~/.kigi/run`: the one home subtree a strict
/// sandbox leaves writable for them.
pub(super) fn runtime_root() -> PathBuf {
kigi_home().join("run")
}
/// Effective leader lock path: the [`LEADER_SOCKET_ENV`] override's sibling
/// `.lock` when set, else the default under kigi home.
/// `.lock` when set, else the default under `~/.kigi/run`.
pub fn default_lock_path() -> PathBuf {
resolve_lock_path(leader_socket_override(), &kigi_home())
resolve_lock_path(leader_socket_override(), &runtime_root())
}
pub fn default_socket_path_in(root: &Path) -> PathBuf {
@@ -63,9 +69,9 @@ pub fn default_socket_path_in(root: &Path) -> PathBuf {
}
/// Effective leader socket path: the [`LEADER_SOCKET_ENV`] override when set,
/// else the default under kigi home.
/// else the default under `~/.kigi/run`.
pub fn default_socket_path() -> PathBuf {
resolve_socket_path(leader_socket_override(), &kigi_home())
resolve_socket_path(leader_socket_override(), &runtime_root())
}
/// The instance suffix encoded in a lock/socket file-name pair
@@ -145,6 +151,9 @@ impl LeaderLock {
}
fn open_lock_file(&self) -> Result<File, LockError> {
if let Some(parent) = self.lock_path.parent() {
std::fs::create_dir_all(parent)?;
}
Ok(OpenOptions::new()
.read(true)
.write(true)
+68 -5
View File
@@ -23,7 +23,7 @@
//! │ │ - Tracks session ownership for routing ││
//! │ └────────────────────────┬────────────────────────────────┘│
//! └───────────────────────────┼──────────────────────────────────┘
//! │ IPC (Unix socket at ~/.kigi/leader.sock)
//! │ IPC (Unix socket at ~/.kigi/run/leader.sock)
//! ┌───────────────────┼───────────────────┐
//! ▼ ▼ ▼
//! ┌───────────────┐ ┌───────────────┐ ┌───────────────┐
@@ -481,7 +481,10 @@ async fn discover_leaders_in(root: &Path) -> Vec<LeaderDescriptor> {
entries
}
pub async fn discover_leaders() -> Vec<LeaderDescriptor> {
discover_leaders_in(&crate::util::kigi_home::kigi_home()).await
let mut entries = discover_leaders_in(&lock::runtime_root()).await;
// Pre-0.1.15 leaders and socket overrides keep their files at the home root.
entries.extend(discover_leaders_in(&crate::util::kigi_home::kigi_home()).await);
entries
}
/// (pid, leader_binary_version) of socket-verified (Reachable) leaders; a
/// stale-lock-only descriptor is skipped (its `pid_from_lock` may be recycled).
@@ -610,13 +613,28 @@ fn resolve_matching_descriptors(
fn resolve_target_from_descriptors(
target: LeaderTarget,
leaders: Vec<LeaderDescriptor>,
current_default_socket: &Path,
) -> Result<LeaderTargetSelection, LeaderTargetError> {
match target {
LeaderTarget::Default => {
let matching: Vec<_> = leaders
let defaults: Vec<_> = leaders
.into_iter()
.filter(|descriptor| descriptor.is_default)
.collect();
// An upgrade can leave a pre-0.1.15 default at the home root; the
// current default socket wins while it exists.
let current: Vec<_> = defaults
.iter()
.filter(|descriptor| {
descriptor.socket_path.as_deref() == Some(current_default_socket)
})
.cloned()
.collect();
let matching = if current.is_empty() {
defaults
} else {
current
};
resolve_matching_descriptors("default", matching)
}
LeaderTarget::Socket(socket_path) => {
@@ -685,7 +703,7 @@ pub async fn resolve_leader_target(
target: LeaderTarget,
) -> Result<LeaderTargetSelection, LeaderTargetError> {
let leaders = discover_leaders().await;
resolve_target_from_descriptors(target, leaders)
resolve_target_from_descriptors(target, leaders, &lock::default_socket_path())
}
#[derive(Debug, thiserror::Error)]
pub enum ConnectionError {
@@ -1315,7 +1333,12 @@ fn spawn_leader_subprocess() -> Result<u32, ConnectionError> {
}
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null());
let log_path = crate::util::kigi_home::kigi_home().join("leader.log");
let log_path = crate::util::kigi_home::kigi_home()
.join("logs")
.join("leader.log");
if let Some(parent) = log_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::File::create(&log_path) {
Ok(log_file) => {
info!("Leader stderr → log file");
@@ -1444,6 +1467,46 @@ mod tests {
vec![(222, "0.2.52".to_string())]
);
}
fn reachable_default(socket: &str) -> LeaderDescriptor {
let socket_path = PathBuf::from(socket);
LeaderDescriptor {
pid_from_lock: Some(1),
lock_path: Some(socket_path.with_extension("lock")),
socket_path: Some(socket_path.clone()),
socket_suffix: String::new(),
classification: LeaderDiscoveryState::Reachable,
is_default: true,
live_info: Some(LiveLeaderInfo {
pid: 1,
socket_path,
lock_path: PathBuf::new(),
socket_suffix: String::new(),
leader_protocol_version: 1,
leader_binary_version: "0.1.15".to_string(),
}),
target_error: None,
}
}
#[test]
fn default_target_prefers_the_current_socket_over_a_legacy_default() {
let legacy = reachable_default("/h/.kigi/leader.sock");
let current = reachable_default("/h/.kigi/run/leader.sock");
let current_socket = PathBuf::from("/h/.kigi/run/leader.sock");
let picked = resolve_target_from_descriptors(
LeaderTarget::Default,
vec![current.clone(), legacy.clone()],
&current_socket,
)
.expect("current default resolves");
assert_eq!(picked.descriptor.socket_path, current.socket_path);
let fallback = resolve_target_from_descriptors(
LeaderTarget::Default,
vec![legacy.clone()],
&current_socket,
)
.expect("legacy default still resolves alone");
assert_eq!(fallback.descriptor.socket_path, legacy.socket_path);
}
#[test]
fn leader_is_older_than_directional() {
assert!(leader_is_older_than("0.1.0", "0.2.0"));
@@ -1146,6 +1146,9 @@ pub async fn run_leader_server(
control_state: LeaderServerControlState,
) -> Result<(), ServerError> {
let _ = std::fs::remove_file(&socket_path);
if let Some(parent) = socket_path.parent() {
std::fs::create_dir_all(parent)?;
}
let shutdown_reason_rx = shutdown_tx.subscribe();
let listener = LeaderListener::bind(&socket_path)?;
info!("Leader server listening");
@@ -171,6 +171,70 @@ pub fn error_detail_from_data(data: &serde_json::Value) -> Option<String> {
.map(str::to_owned)
}
/// The human-readable text of an ACP error: its `data` detail when present (Display would JSON-escape it), else the message.
pub fn acp_error_message(err: &acp::Error) -> String {
err.data
.as_ref()
.and_then(error_detail_from_data)
.unwrap_or_else(|| err.message.clone())
}
/// First line of a compaction error without internal `compact failed:` prefixes, capped for a banner; diagnostic lines (request URLs) are dropped.
pub fn user_facing_compact_error(raw: &str) -> String {
const INTERNAL_PREFIX: &str = "compact failed:";
const MAX_CHARS: usize = 200;
// Redact before line selection: a line break inside a URL must not hide its `@`.
let raw = redact_urls_in_text(raw);
let mut rest = raw.trim();
while rest
.get(..INTERNAL_PREFIX.len())
.is_some_and(|head| head.eq_ignore_ascii_case(INTERNAL_PREFIX))
{
rest = rest[INTERNAL_PREFIX.len()..].trim_start();
}
rest.lines()
.next()
.unwrap_or_default()
.trim()
.chars()
.take(MAX_CHARS)
.collect()
}
/// Every canonical `scheme://` URL in `text` loses userinfo, sensitive query values and fragment; one that fails to parse is not echoed.
fn redact_urls_in_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(sep) = rest.find("://") {
// The delimiter before the scheme may be multi-byte (`“`, `:`): step past its UTF-8 length.
let scheme_start = rest[..sep]
.char_indices()
.rev()
.find(|(_, c)| !(c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')))
.map_or(0, |(at, c)| at + c.len_utf8());
out.push_str(&rest[..scheme_start]);
// The authority may hold whitespace (a configured password); the token ends at whitespace after it.
let authority = &rest[sep + 3..];
let authority_end = authority.find(['/', '?', '#']).unwrap_or(authority.len());
let token_end = authority[authority_end..]
.find(char::is_whitespace)
.map_or(rest.len(), |at| sep + 3 + authority_end + at);
let token = &rest[scheme_start..token_end];
let url_text = token.trim_end_matches([')', ']', '}', ',', '.', ';', '\'', '"', '>']);
match url::Url::parse(url_text) {
Ok(mut url) => {
kigi_secrets::redact_url(&mut url);
out.push_str(url.as_str());
}
Err(_) => out.push_str("<unparseable url>"),
}
out.push_str(&token[url_text.len()..]);
rest = &rest[token_end..];
}
out.push_str(rest);
out
}
pub fn http_status_from_error(err: &acp::Error) -> Option<u16> {
err.data
.as_ref()?
@@ -254,6 +318,77 @@ pub fn prompt_complete_fields(
#[cfg(test)]
mod tests {
#[test]
fn compact_error_text_keeps_the_first_decoded_line_only() {
let err = super::acp::Error::internal_error().data(
"compact failed: API error (status 500): overloaded\n\nRequest URL: http://u:TEST_PASSWORD@127.0.0.1/v1",
);
let detail = super::user_facing_compact_error(&super::acp_error_message(&err));
assert_eq!(detail, "API error (status 500): overloaded");
assert!(!detail.contains("TEST_PASSWORD"));
assert_eq!(
super::user_facing_compact_error(&"x".repeat(500))
.chars()
.count(),
200
);
assert_eq!(
super::acp_error_message(&super::acp::Error::internal_error()),
"Internal error"
);
}
/// A sampler 401 names the endpoint on the retained line; its userinfo must not survive.
#[test]
fn compact_error_text_redacts_url_credentials_on_the_first_line() {
for password in [
"TEST_PASSWORD",
"TEST PASSWORD",
"TEST%20PASSWORD",
"TEST_PASSWORD\n",
"TEST_PASSWORD\r\n",
] {
let raw = format!(
"compact failed: Unauthorized (401) from http://u:{password}@127.0.0.1:33263/401/v1/chat/completions: bad auth"
);
assert_eq!(
super::user_facing_compact_error(&raw),
"Unauthorized (401) from http://127.0.0.1:33263/401/v1/chat/completions: bad auth",
"{password:?}"
);
}
assert_eq!(
super::redact_urls_in_text("see https://a:b@h.example/x and http://h2/"),
"see https://h.example/x and http://h2/"
);
}
/// A credential carried as a query value survives no failure shape: 401 message or transport error.
#[test]
fn compact_error_text_redacts_query_credentials_in_every_failure_shape() {
let unauthorized = "compact failed: Unauthorized (401) from http://127.0.0.1:1/?access_token=TEST_QUERY_TOKEN&route=/v1/chat/completions: bad auth";
let transport = "compact failed: request error: error sending request for url (http://127.0.0.1:1/?access_token=TEST_QUERY_TOKEN&route=/500/v1/chat/completions)";
for raw in [unauthorized, transport] {
let detail = super::user_facing_compact_error(raw);
assert!(!detail.contains("TEST_QUERY_TOKEN"), "{detail}");
assert!(detail.contains("route="), "{detail}");
}
assert!(super::user_facing_compact_error("see http://:://").contains("<unparseable url>"));
}
/// A multi-byte delimiter right before the URL must not break the scan.
#[test]
fn compact_error_text_handles_unicode_before_a_url() {
for raw in [
"compact failed: Service unavailable. See “https://u:TEST_PASSWORD@status.example/”.",
"compact failed: 请参阅:https://u:TEST_PASSWORD@status.example/",
] {
let detail = super::user_facing_compact_error(raw);
assert!(!detail.contains("TEST_PASSWORD"), "{detail}");
assert!(detail.contains("https://status.example/"), "{detail}");
}
}
use super::*;
use reqwest::StatusCode;
@@ -1518,6 +1518,9 @@ mod load_user_prompts_tests;
#[path = "acp_session_tests/memory_config_tests.rs"]
mod memory_config_tests;
#[cfg(test)]
#[path = "acp_session_tests/model_switch_window_tests.rs"]
mod model_switch_window_tests;
#[cfg(test)]
#[path = "acp_session_tests/parallel_dispatch_tests.rs"]
mod parallel_dispatch_tests;
#[cfg(test)]
@@ -10,7 +10,7 @@ impl SessionActor {
apply_prompt_override: bool,
skip_prompt_rewrite: bool,
auto_compact_threshold_percent: u8,
) -> Result<acp::ModelId, acp::Error> {
) -> Result<(acp::ModelId, std::num::NonZeroU64), acp::Error> {
let model_id = acp::ModelId::new(sampling_config.model.clone());
// H4: record the picker's catalog KEY as this SESSION's own selection.
// `sampling_config.model` is the ambiguous bare slug; the key is what
@@ -120,7 +120,7 @@ impl SessionActor {
agent_name: Some(agent_name),
reasoning_effort: Some(sampling_config.reasoning_effort),
});
Ok(model_id)
Ok((model_id, new_context_window))
}
/// Handle [`SessionCommand::RebuildAgentForDefinition`].
///
@@ -548,17 +548,10 @@ impl SessionActor {
if report.changed() && !dry_run {
// Flush barrier: success must mean the rewrite is on disk.
let (flush_tx, flush_rx) = tokio::sync::oneshot::channel();
if self
.notifications
.persistence_tx
.send(PersistenceMsg::FlushAndAck {
respond_to: flush_tx,
})
.is_err()
|| flush_rx.await.is_err()
{
anyhow::bail!("history repaired in memory but the persistence flush failed");
if let Err(error) = self.persistence_barrier().await {
anyhow::bail!(
"history repaired in memory but the persistence flush failed: {error}"
);
}
tracing::warn!(
session_id = %self.session_info.id.0,
@@ -112,6 +112,7 @@ pub(crate) async fn spawn_session_actor(
system_prompt_label: String,
compaction_mode: kigi_chat_state::CompactionMode,
compaction_verbatim_input: bool,
compaction_tool_choice: crate::util::config::CompactionToolChoice,
two_pass_enabled: bool,
buffering_settings: Option<BufferingSettings>,
origin_client: Option<crate::http::OriginClientInfo>,
@@ -1032,6 +1033,7 @@ pub(crate) async fn spawn_session_actor(
previous_model: std::cell::Cell::new(None),
compaction_mode,
verbatim_input: compaction_verbatim_input,
tool_choice: compaction_tool_choice,
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -1535,6 +1537,7 @@ pub(crate) async fn spawn_session_on_thread(
system_prompt_label: String,
compaction_mode: kigi_chat_state::CompactionMode,
compaction_verbatim_input: bool,
compaction_tool_choice: crate::util::config::CompactionToolChoice,
two_pass_enabled: bool,
buffering_settings: Option<BufferingSettings>,
origin_client: Option<crate::http::OriginClientInfo>,
@@ -1636,10 +1639,23 @@ pub(crate) async fn spawn_session_on_thread(
};
(initial_last_compaction, initial_prompt_texts)
};
let rt = tokio::runtime::Builder::new_current_thread()
// Runtime construction acquires fds (epoll/kqueue, waker) and
// fails with EMFILE/EAGAIN under resource pressure — report
// through the init channel instead of panicking the thread.
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("session runtime");
{
Ok(rt) => rt,
Err(e) => {
tracing::error!(
error = %e,
"failed to build session runtime (resource exhaustion?)"
);
let _ = init_tx.send(Err(kigi_agent::AgentBuildError::RuntimeBuild(e)));
return;
}
};
let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move {
let _trace_span = parent_traceparent.as_ref().map(|tp| {
@@ -1679,6 +1695,7 @@ pub(crate) async fn spawn_session_on_thread(
system_prompt_label,
compaction_mode,
compaction_verbatim_input,
compaction_tool_choice,
two_pass_enabled,
buffering_settings,
origin_client,
@@ -1759,8 +1776,19 @@ pub(crate) async fn spawn_session_on_thread(
}));
let _ = session_done_rx.await;
});
})
.expect("spawn session thread");
});
let join_handle = match join_handle {
Ok(handle) => handle,
Err(e) => {
tracing::error!(
error = %e,
"failed to spawn session thread (thread/PID limit or memory pressure?)"
);
return Err(
acp::Error::internal_error().data(format!("failed to spawn session thread: {e}"))
);
}
};
let init = init_rx
.await
.map_err(|_| {
@@ -957,6 +957,8 @@ impl SessionActor {
Some(self.session_info.id.0.to_string()),
None,
None,
self.tool_context.cwd.clone(),
self.display_cwd.get().map(std::path::PathBuf::from),
)
.await
};
@@ -681,22 +681,14 @@ impl SessionActor {
.await
.is_some()
{
let (flush_tx, flush_rx) = oneshot::channel();
if self
.notifications
.persistence_tx
.send(PersistenceMsg::FlushAndAck {
respond_to: flush_tx,
})
.is_ok()
&& flush_rx.await.is_ok()
{
let _ = ack.send(());
} else {
tracing::error!(
session_id = % self.session_info.id.0, prompt_id = %
prompt_id, "persist_ack flush barrier failed"
);
match self.persistence_barrier().await {
Ok(()) => {
let _ = ack.send(());
}
Err(error) => tracing::error!(
session_id = % self.session_info.id.0, prompt_id = % prompt_id,
%error, "persist_ack flush barrier failed"
),
}
} else {
tracing::error!(
@@ -333,15 +333,24 @@ impl SessionActor {
if let Err(e) = crate::session::replay_events::flush_replay_actor(&self.event_tx).await {
tracing::warn!(?e, "flush_replay_actor failed");
}
if let Err(error) = self.persistence_barrier().await {
tracing::error!(%error, "persistence flush failed");
}
}
/// The `FlushAndAck` durability barrier: `Ok` only once every prior
/// persistence write is on stable media.
pub(super) async fn persistence_barrier(&self) -> std::io::Result<()> {
let (tx, rx) = tokio::sync::oneshot::channel();
if self
.notifications
.persistence_tx
.send(PersistenceMsg::FlushAndAck { respond_to: tx })
.is_ok()
.is_err()
{
let _ = rx.await;
return Err(std::io::Error::other("persistence actor gone"));
}
rx.await
.unwrap_or_else(|_| Err(std::io::Error::other("persistence actor gone")))
}
/// Extracts the update type name and relevant parameters for logging
fn extract_update_info(
@@ -157,6 +157,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -434,7 +435,7 @@ async fn first_turn_memory_injection_persists_to_chat_history() {
respond_to: flush_tx,
})
.unwrap();
flush_rx.await.unwrap();
flush_rx.await.unwrap().unwrap();
let loaded = storage
.load_session_without_updates(&session_info)
.await
@@ -611,6 +612,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -755,7 +757,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
respond_to: flush_tx,
})
.unwrap();
flush_rx.await.unwrap();
flush_rx.await.unwrap().unwrap();
let storage = crate::session::storage::JsonlStorageAdapter::with_explicit_session_dir(
session_dir.path().to_path_buf(),
);
@@ -871,6 +873,7 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() {
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -1307,7 +1310,7 @@ async fn actor_with_persistence_drain() -> std::sync::Arc<SessionActor> {
tokio::task::spawn_local(async move {
while let Some(msg) = persistence_rx.recv().await {
if let PersistenceMsg::FlushAndAck { respond_to } = msg {
let _ = respond_to.send(());
let _ = respond_to.send(Ok(()));
}
}
});
@@ -1865,6 +1868,7 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -176,6 +176,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -112,6 +112,7 @@ async fn create_test_actor(
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -539,6 +540,7 @@ async fn create_test_actor_with_memory(
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -1298,6 +1300,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -163,6 +163,7 @@ async fn create_test_actor_with_memory(
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -0,0 +1,24 @@
//! `handle_set_session_model` reports the session's effective context
//! window so clients repaint their header without waiting for a turn.
use super::support::*;
#[tokio::test(flavor = "current_thread")]
async fn set_session_model_returns_the_effective_context_window() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (actor, _rx) = build_actor().await;
let cfg = kigi_sampler::SamplerConfig {
model: "narrow".to_string(),
context_window: 128_000,
..Default::default()
};
let (model_id, window) = actor
.handle_set_session_model(cfg, None, false, false, true, 85)
.await
.expect("model switch");
assert_eq!(model_id.0.as_ref(), "narrow");
assert_eq!(window.get(), 128_000);
})
.await;
}
@@ -82,6 +82,8 @@ async fn set_auto_mode_path_wires_live_side_query_via_session_actor() {
None,
None,
None,
session.tool_context.cwd.clone(),
None,
)
.await;
// cargo is heuristic-allow when sampling fails; must not be Prompt-only
@@ -99,6 +101,8 @@ async fn set_auto_mode_path_wires_live_side_query_via_session_actor() {
None,
None,
None,
session.tool_context.cwd.clone(),
None,
)
.await;
assert!(
@@ -116,6 +116,7 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -226,6 +226,7 @@ pub(crate) async fn create_test_actor_ex(
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -173,7 +173,9 @@ pub enum SessionCommand {
/// `compaction.threshold_percent` (which is `Cell<u8>` so it can
/// update without `&mut self`).
auto_compact_threshold_percent: u8,
responds_to: oneshot::Sender<Result<acp::ModelId, acp::Error>>,
/// Ok: the applied model id and the session's effective context
/// window, which clients repaint from without waiting for a turn.
responds_to: oneshot::Sender<Result<(acp::ModelId, std::num::NonZeroU64), acp::Error>>,
},
/// Zero-turn harness rebuild: build a brand-new `Agent` from the
/// session's `AgentRebuildSpec` and the new `AgentDefinition`,
@@ -332,10 +334,10 @@ pub enum SessionCommand {
CopyFile {
respond_to: oneshot::Sender<anyhow::Result<crate::session::persistence::SessionStateCopy>>,
},
/// Flush the replay buffer and persistence, then signal completion.
/// Used during reconnect to ensure all buffered content is persisted before replay.
/// Flush the replay buffer and persistence, then report the durability
/// barrier result. Used during reconnect so replay follows persisted content.
FlushComplete {
respond_to: oneshot::Sender<()>,
respond_to: oneshot::Sender<std::io::Result<()>>,
},
/// Update MCP servers for an existing session (used during reconnect or
/// mid-session via the `kigi/session/update_mcp_servers` extension method).
@@ -212,6 +212,7 @@ impl SessionActor {
&sampling_config,
self.inference_idle_timeout,
wall_clock_budget_secs,
self.compaction.tool_choice,
)
.await
{
@@ -688,6 +689,19 @@ impl SessionActor {
.await;
}
}
/// Retry guidance headline, then the user-facing detail on its own line.
fn failure_with_retry_guidance(err: &acp::Error) -> String {
const RETRY_GUIDANCE: &str =
"it'll retry on the next turn, or start a new session using /new.";
let detail = crate::sampling::error::user_facing_compact_error(
&crate::sampling::error::acp_error_message(err),
);
if detail.is_empty() {
RETRY_GUIDANCE.to_string()
} else {
format!("{RETRY_GUIDANCE}\n{detail}")
}
}
/// Map a deterministic failure's error text to a fixed, content-free
/// [`SuppressReason`] (drives telemetry + sticky-vs-per-turn scope).
fn classify_suppress_reason(error_msg: &str) -> SuppressReason {
@@ -983,6 +997,7 @@ impl SessionActor {
sampling_config.clone(),
self.inference_idle_timeout,
wall_clock_budget_secs,
self.compaction.tool_choice,
);
let observer =
crate::session::helpers::full_replace_compaction::ShellFullReplaceObserver::new(
@@ -1943,7 +1958,7 @@ impl SessionActor {
== SUPPRESS_NONE
{
self.send_xai_notification(XaiSessionUpdate::AutoCompactFailed {
error: String::new(),
error: Self::failure_with_retry_guidance(&e),
})
.await;
}
@@ -2198,6 +2213,7 @@ mod inline_auto_compact_flow_tests {
previous_model: std::cell::Cell::new(None),
compaction_mode: kigi_chat_state::CompactionMode::Transcript,
verbatim_input: true,
tool_choice: Default::default(),
prefire: crate::session::compaction_config::PrefireState::default(),
prefix_released: std::sync::atomic::AtomicBool::new(false),
},
@@ -3283,3 +3299,28 @@ mod inline_auto_compact_flow_tests {
.await;
}
}
#[cfg(test)]
mod failure_message_tests {
use super::SessionActor;
use agent_client_protocol as acp;
#[test]
fn transient_failure_message_carries_guidance_and_the_decoded_detail() {
let err = acp::Error::internal_error().data(
"Compact failed: compact failed: HTTP 500 from upstream\n\nRequest URL: http://u:TEST_PASSWORD@host/v1",
);
let text = SessionActor::failure_with_retry_guidance(&err);
let (headline, detail) = text.split_once('\n').expect("guidance + detail");
assert_eq!(
headline,
"it'll retry on the next turn, or start a new session using /new."
);
assert_eq!(detail, "HTTP 500 from upstream");
assert!(!text.contains("TEST_PASSWORD"));
assert_eq!(
SessionActor::failure_with_retry_guidance(&acp::Error::internal_error().data(" ")),
"it'll retry on the next turn, or start a new session using /new."
);
}
}
@@ -146,6 +146,9 @@ pub struct CompactionConfig {
pub compaction_mode: kigi_chat_state::CompactionMode,
/// When `true`, feed the summarizer the verbatim conversation instead of the lossy rewrite (the retry loop may still fall back).
pub verbatim_input: bool,
/// `tool_choice` for summarizer requests with tools attached (`auto` |
/// `none`); some backends reject `"none"`.
pub tool_choice: crate::util::config::CompactionToolChoice,
/// Prefire two-pass state (background NOTE₁ cache + in-flight guard).
/// `Default` (empty cache, not in-flight).
pub prefire: PrefireState,
@@ -519,6 +519,9 @@ pub(crate) async fn capture_plan_changes(
}
}
/// Pin the `a/` `b/` header prefixes [`extract_changed_files`] parses.
const DIFF_PREFIX_ARGS: [&str; 2] = ["--src-prefix=a/", "--dst-prefix=b/"];
/// Run `git diff <baseline>` and return the FULL (untruncated) stdout;
/// the caller extracts the changed-file list before truncating.
async fn run_git_diff_against_baseline(
@@ -526,7 +529,7 @@ async fn run_git_diff_against_baseline(
workspace_root: &Path,
) -> Result<String, ChangesCaptureError> {
let mut cmd = git_command(workspace_root);
cmd.arg("diff").arg(baseline);
cmd.arg("diff").args(DIFF_PREFIX_ARGS).arg(baseline);
let output = match tokio::time::timeout(DIFF_COMMAND_TIMEOUT, cmd.output()).await {
Ok(Ok(output)) => output,
Ok(Err(err)) => return Err(ChangesCaptureError::DiffCommandFailed(err.to_string())),
@@ -579,10 +582,14 @@ async fn lazy_git_baseline_diff(
// support SHA-256 repos.
let mut cmd = git_command(workspace_root);
if git_has_parent(workspace_root, &oldest).await {
cmd.arg("diff").arg(format!("{oldest}^..{head}"));
cmd.arg("diff")
.args(DIFF_PREFIX_ARGS)
.arg(format!("{oldest}^..{head}"));
} else {
let empty_tree = derive_empty_tree_sha(workspace_root).await;
cmd.arg("diff").arg(format!("{empty_tree}..{head}"));
cmd.arg("diff")
.args(DIFF_PREFIX_ARGS)
.arg(format!("{empty_tree}..{head}"));
}
let output = match tokio::time::timeout(DIFF_COMMAND_TIMEOUT, cmd.output()).await {
@@ -2192,4 +2199,38 @@ mod tests {
"patch must carry the untracked-files note",
);
}
#[tokio::test]
async fn changed_files_ignore_the_host_diff_prefix_config() {
// Both keys rewrite the `diff --git a/<old> b/<new>` header the parser keys on.
for key in ["diff.mnemonicprefix", "diff.noprefix"] {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
git(tmp.path(), &["config", key, "true"]);
tokio::fs::write(tmp.path().join("tracked.txt"), b"seed\n")
.await
.unwrap();
let goal_created_at: i64 = 1_700_000_100;
git_at(tmp.path(), &["add", "."], goal_created_at + 100);
git_at(
tmp.path(),
&["commit", "-q", "-m", "post-goal"],
goal_created_at + 100,
);
let lazy = capture_changes_diff(None, tmp.path(), goal_created_at)
.await
.expect("lazy baseline must succeed");
assert_eq!(lazy.changed_files, ["tracked.txt"], "{key}: lazy baseline");
tokio::fs::write(tmp.path().join("tracked.txt"), b"changed\n")
.await
.unwrap();
let explicit = capture_changes_diff(Some("HEAD"), tmp.path(), goal_created_at)
.await
.expect("layer-1 git diff must succeed");
assert_eq!(
explicit.changed_files,
["tracked.txt"],
"{key}: explicit baseline"
);
}
}
}
@@ -70,6 +70,8 @@ pub(crate) struct ShellCompactionSampler {
/// Wall-clock budget (secs) forwarded to `generate_session_compact` as the
/// reasoning-runaway backstop; `0` disables it.
wall_clock_budget_secs: u64,
/// Summarizer `tool_choice` forwarded to `generate_session_compact`.
tool_choice: crate::util::config::CompactionToolChoice,
/// Full output of the most recent successful sample (for L5 telemetry).
last_success: Mutex<Option<CompactOutput>>,
}
@@ -86,6 +88,7 @@ impl ShellCompactionSampler {
sampling_config: SamplingConfig,
idle_timeout: Duration,
wall_clock_budget_secs: u64,
tool_choice: crate::util::config::CompactionToolChoice,
) -> Self {
Self {
use_short_prompt,
@@ -97,6 +100,7 @@ impl ShellCompactionSampler {
sampling_config,
idle_timeout,
wall_clock_budget_secs,
tool_choice,
last_success: Mutex::new(None),
}
}
@@ -135,6 +139,7 @@ impl CompactionSampler for ShellCompactionSampler {
&self.sampling_config,
self.idle_timeout,
self.wall_clock_budget_secs,
self.tool_choice,
)
.await
{
@@ -339,9 +339,10 @@ where
/// prefix and force a full prefill on the summarizer call — attaching them
/// keeps the request prefix byte-identical to the turn requests so the
/// engine reuses the session's KV cache (the whole point of the verbatim
/// input path). Tool *use* is forbidden via `tool_choice: none` where the
/// backend can express it (ChatCompletions, Responses); the Messages wire
/// enum has no `none`, so that path relies on the prompt instruction alone.
/// input path). Tool *use* is forbidden by the prompt instruction;
/// `tool_choice` says what goes on the wire next to the attached tools
/// (ChatCompletions, Responses — some servers reject `"none"`, hence the
/// `auto` default). The Messages wire enum has no `none` either way.
///
/// Errors carry a [`CompactFailure`] classification so the caller can
/// short-circuit retries on deterministic failures (4xx schema violations,
@@ -356,8 +357,17 @@ pub(crate) async fn generate_session_compact(
sampling_config: &SamplingConfig,
idle_timeout: std::time::Duration,
wall_clock_budget_secs: u64,
tool_choice: crate::util::config::CompactionToolChoice,
) -> Result<CompactOutput, CompactFailure> {
let num_messages = chat_history.len();
let wire_tool_choice = match tool_choice {
crate::util::config::CompactionToolChoice::Auto => ToolChoice::auto(),
crate::util::config::CompactionToolChoice::None => ToolChoice::none(),
};
let conversation_tool_choice = match tool_choice {
crate::util::config::CompactionToolChoice::Auto => ConversationToolChoice::Auto,
crate::util::config::CompactionToolChoice::None => ConversationToolChoice::None,
};
let output = match sampling_config.api_backend {
ApiBackend::ChatCompletions => {
let chat_messages: Vec<ChatRequestMessage> =
@@ -373,7 +383,7 @@ pub(crate) async fn generate_session_compact(
.map(|t| ToolDefinition::function(t.name, t.description, t.parameters))
.collect(),
)
.with_tool_choice(ToolChoice::none());
.with_tool_choice(wire_tool_choice);
}
let sid = session_id.to_string();
message.x_kigi_conv_id = Some(sid.clone());
@@ -466,7 +476,10 @@ pub(crate) async fn generate_session_compact(
ApiBackend::Responses => {
let request = ConversationRequest {
items: chat_history,
tool_choice: (!tools.is_empty()).then_some(ConversationToolChoice::None),
// Hosted tools count: a hosted-only request with the choice
// omitted would leave the hosted tool callable mid-compact.
tool_choice: (!tools.is_empty() || !hosted_tools.is_empty())
.then_some(conversation_tool_choice),
tools,
hosted_tools,
model: Some(sampling_config.model.to_owned()),
@@ -1573,6 +1586,7 @@ mod reasoning_compaction_regression_tests {
&config,
std::time::Duration::from_secs(30),
0,
crate::util::config::CompactionToolChoice::Auto,
)
.await
.unwrap_or_else(|_| panic!("compaction must succeed"));
@@ -1661,6 +1675,7 @@ mod reasoning_compaction_regression_tests {
&config,
std::time::Duration::from_secs(30),
0,
crate::util::config::CompactionToolChoice::Auto,
)
.await;
let output = result
@@ -1669,11 +1684,11 @@ mod reasoning_compaction_regression_tests {
let _ = shutdown_tx.send(());
}
/// The compaction request must carry the turn loop's tool definitions
/// (prompt-prefix/KV-cache alignment) with `tool_choice: "none"`, and
/// must omit both keys when no tools are passed (Chat Completions rejects a bare
/// `tool_choice`).
/// (prompt-prefix/KV-cache alignment) with the configured `tool_choice`
/// (`auto` default; `none` when configured), and must omit both keys when
/// no tools are passed (Chat Completions rejects a bare `tool_choice`).
#[tokio::test]
async fn chat_completions_compaction_attaches_tools_with_tool_choice_none() {
async fn chat_completions_compaction_attaches_tools_with_configured_tool_choice() {
use std::sync::{Arc, Mutex};
let captured: Arc<Mutex<Vec<serde_json::Value>>> = Arc::new(Mutex::new(Vec::new()));
let cap = captured.clone();
@@ -1726,12 +1741,13 @@ mod reasoning_compaction_regression_tests {
&config,
std::time::Duration::from_secs(30),
0,
crate::util::config::CompactionToolChoice::Auto,
)
.await
.unwrap_or_else(|_| panic!("compaction with tools must succeed"));
let client = Client::new(config.clone()).unwrap();
generate_session_compact(
chat_history,
chat_history.clone(),
vec![],
vec![],
client,
@@ -1739,16 +1755,36 @@ mod reasoning_compaction_regression_tests {
&config,
std::time::Duration::from_secs(30),
0,
crate::util::config::CompactionToolChoice::Auto,
)
.await
.unwrap_or_else(|_| panic!("compaction without tools must succeed"));
let tools = vec![ToolSpec {
name: "read_file".to_string(),
description: Some("Reads a file".to_string()),
parameters: json!({ "type" : "object", "properties" : {} }),
}];
let client = Client::new(config.clone()).unwrap();
generate_session_compact(
chat_history,
tools,
vec![],
client,
acp::SessionId::new("test-session"),
&config,
std::time::Duration::from_secs(30),
0,
crate::util::config::CompactionToolChoice::None,
)
.await
.unwrap_or_else(|_| panic!("compaction with tool_choice none must succeed"));
let bodies = captured.lock().unwrap();
assert_eq!(bodies.len(), 2, "mock must have served both requests");
assert_eq!(bodies.len(), 3, "mock must have served all three requests");
let with_tools = &bodies[0];
assert_eq!(
with_tools["tool_choice"],
json!("none"),
"tool use must be disabled at decode time"
json!("auto"),
"default tool_choice is auto — some backends reject none; the prompt forbids tool use"
);
let sent_tools = with_tools["tools"]
.as_array()
@@ -1764,6 +1800,12 @@ mod reasoning_compaction_regression_tests {
without_tools.get("tool_choice").is_none(),
"tool_choice without tools is rejected by OpenAI-compat backends"
);
let none_configured = &bodies[2];
assert_eq!(
none_configured["tool_choice"],
json!("none"),
"configured none must reach the wire"
);
let _ = shutdown_tx.send(());
}
#[tokio::test]
@@ -1802,6 +1844,7 @@ mod reasoning_compaction_regression_tests {
&config,
std::time::Duration::from_millis(150),
0,
crate::util::config::CompactionToolChoice::Auto,
)
.await;
match result {
@@ -1869,6 +1912,7 @@ mod reasoning_compaction_regression_tests {
&config,
std::time::Duration::from_millis(150),
0,
crate::util::config::CompactionToolChoice::Auto,
)
.await;
match result {
@@ -1940,6 +1984,7 @@ mod reasoning_compaction_regression_tests {
&config,
std::time::Duration::from_millis(150),
0,
crate::util::config::CompactionToolChoice::Auto,
)
.await;
match result {
@@ -2008,6 +2053,7 @@ mod reasoning_compaction_regression_tests {
&config,
std::time::Duration::from_millis(150),
0,
crate::util::config::CompactionToolChoice::Auto,
)
.await;
match result {
@@ -467,7 +467,24 @@ fn compute_normalized_blocking(
});
}
};
if buf.len() >= original_bytes {
// A resize that falls under the vision floors cannot be sent; keep the original with the fallback note.
if new_w < MIN_VISION_SIDE_PX
|| new_h < MIN_VISION_SIDE_PX
|| u64::from(new_w) * u64::from(new_h) < MIN_VISION_TOTAL_PX
{
tracing::warn!(
index,
width = new_w,
height = new_h,
"image resize fell under the minimum dimensions; keeping original attachment"
);
return Ok(NormalizedEntry::ReEncodingOversized {
bytes: Bytes::from(raw_bytes),
mime: Cow::Borrowed(orig_mime),
});
}
// An image over the dimension caps is resized even when that saves no bytes.
if !exceeded_dimensions && buf.len() >= original_bytes {
return Ok(NormalizedEntry::Unchanged {
bytes: Bytes::from(raw_bytes),
mime: Cow::Borrowed(orig_mime),
@@ -551,6 +568,50 @@ mod tests {
image::ImageFormat::Png
);
}
/// A flat image at the best PNG compression is tinier than any re-encode the fast encoder produces.
fn make_best_compressed_png_content(width: u32, height: u32) -> ImageContent {
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::{ImageBuffer, ImageEncoder, Rgba};
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_pixel(width, height, Rgba([128, 64, 32, 255]));
let mut buf = Vec::new();
PngEncoder::new_with_quality(&mut buf, CompressionType::Best, FilterType::Adaptive)
.write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgba8)
.unwrap();
ImageContent::new(
base64::engine::general_purpose::STANDARD.encode(&buf),
"image/png",
)
}
/// Over the dimension caps, a re-encode that saves no bytes is still a resize.
#[tokio::test]
async fn over_dimension_image_is_resized_even_when_re_encode_saves_no_bytes() {
let img = make_best_compressed_png_content(3000, 2000);
let cache = fresh_cache();
match normalize_one_in(img, 1, false, &cache).await {
Outcome::Compressed { info, .. } => {
assert!(
info.compressed_bytes >= info.original_bytes,
"test premise: the re-encode must not shrink ({} vs {})",
info.compressed_bytes,
info.original_bytes
);
assert!(info.compressed_width <= MAX_ENCODE_SIDE_PX);
assert!(info.compressed_height <= MAX_ENCODE_SIDE_PX);
}
other => panic!("expected the oversized image to be downscaled, got {other:?}"),
}
}
/// A 3000x8 strip resizes to 2000x5, under the 8 px floor: it is kept with the fallback note.
#[tokio::test]
async fn resize_under_the_minimum_side_keeps_the_original() {
let img = make_best_compressed_png_content(3000, 8);
let cache = fresh_cache();
match normalize_one_in(img, 1, false, &cache).await {
Outcome::ReEncodingOversized(content) => assert_eq!(content.mime_type, "image/png"),
other => panic!("expected the strip to be kept with a fallback note, got {other:?}"),
}
}
fn make_gif_content(width: u32, height: u32) -> ImageContent {
use image::{ImageBuffer, Rgba};
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
@@ -359,11 +359,13 @@ pub enum PersistenceMsg {
/// stays sequential with other summary.json mutations.
GeneratedTitle(String),
Flush,
/// Flush all pending writes, then signal the caller once the flush is complete.
/// Unlike `Flush` (fire-and-forget), this is a **sync barrier**: the caller's
/// oneshot only resolves after `flush_pending()` finishes writing to disk.
/// Flush all pending writes AND fsync the session files, then signal the
/// caller. Unlike `Flush` (fire-and-forget, page cache only), this is a
/// **durability barrier**: `Ok` means every prior write is on stable
/// media; `Err` reports a storage write that failed since the last
/// barrier or a failed sync.
FlushAndAck {
respond_to: tokio::sync::oneshot::Sender<()>,
respond_to: tokio::sync::oneshot::Sender<io::Result<()>>,
},
/// Flush all pending writes, then copy the current session directory contents and return
/// the in-memory snapshot to the caller (who can tar.gz + upload to GCS, etc.).
@@ -1392,6 +1394,9 @@ struct SessionPersistence {
storage: Arc<dyn StorageAdapter>,
/// Pending ACP notification for merging consecutive text chunks
pending_notification: Option<acp::SessionNotification>,
/// First storage failure since the last barrier: `FlushAndAck` must not
/// report `Ok` for state that never reached the file.
pending_write_error: Option<io::Error>,
rx: mpsc::UnboundedReceiver<PersistenceMsg>,
/// Session title generation lifecycle.
summary: crate::session::summary::SummaryGenerator,
@@ -1491,29 +1496,49 @@ impl SessionPersistence {
}
}
async fn write_update(&mut self, update: &SessionUpdate) {
if let Err(e) = self.storage.append_update(&self.info, update).await {
tracing::warn!(?e, "failed to write update");
fn note_storage_failure(&mut self, error: &io::Error) {
if self.pending_write_error.is_none() {
self.pending_write_error = Some(io::Error::new(error.kind(), error.to_string()));
}
}
/// Flush any pending merged ACP notification to disk.
async fn flush_pending(&mut self) {
// Write any pending merged ACP notification
if let Some(notification) = self.pending_notification.take() {
self.write_update(&SessionUpdate::Acp(Box::new(notification)))
.await;
/// Append an update; a failure is logged and latched for the next barrier.
async fn write_update(&mut self, update: &SessionUpdate) -> io::Result<()> {
let result = self.storage.append_update(&self.info, update).await;
if let Err(error) = &result {
tracing::warn!(%error, "failed to write update");
self.note_storage_failure(error);
}
result
}
/// Write the pending merged ACP notification.
async fn flush_pending(&mut self) -> io::Result<()> {
match self.pending_notification.take() {
Some(notification) => {
self.write_update(&SessionUpdate::Acp(Box::new(notification)))
.await
}
None => Ok(()),
}
}
/// Flush pending writes and sync all session files to disk.
/// Called before CopyFile to ensure all data is persisted.
async fn flush_and_sync(&mut self) {
self.flush_pending().await;
// Sync all session files to disk to ensure they're actually written
if let Err(e) = self.storage.sync_session_files(&self.info).await {
tracing::warn!(?e, "Failed to sync session files to disk");
/// Fsync every session file to stable media.
async fn sync_files(&self) -> io::Result<()> {
let synced = self.storage.sync_session_files(&self.info).await;
if let Err(error) = &synced {
tracing::warn!(%error, "failed to sync session files to disk");
}
synced
}
/// The `FlushAndAck` barrier. First error wins: a write that failed since
/// the last barrier outranks a failed sync.
async fn flush_and_sync(&mut self) -> io::Result<()> {
let flushed = self.flush_pending().await;
let prior_write = self.pending_write_error.take().map_or(Ok(()), Err);
let synced = self.sync_files().await;
flushed.and(prior_write).and(synced)
}
async fn run(mut self) {
@@ -1530,34 +1555,38 @@ impl SessionPersistence {
}
match msg {
PersistenceMsg::Flush => {
self.flush_pending().await;
// Logged and latched by write_update; the next barrier reports it.
let _ = self.flush_pending().await;
}
PersistenceMsg::FlushAndAck { respond_to } => {
self.flush_pending().await;
let _ = respond_to.send(());
let result = self.flush_and_sync().await;
let _ = respond_to.send(result);
}
PersistenceMsg::Update(update) => {
match update {
SessionUpdate::Acp(notification) => {
// ACP notifications use merging to coalesce consecutive text chunks
if let Some(to_write) = self.maybe_merge_notification(&notification) {
self.write_update(&SessionUpdate::Acp(Box::new(to_write)))
// Logged and latched by write_update.
let _ = self
.write_update(&SessionUpdate::Acp(Box::new(to_write)))
.await;
}
}
SessionUpdate::Xai(_) => {
// xAI notifications are written directly without merging
self.write_update(&update).await;
// Written directly without merging; logged and latched by write_update.
let _ = self.write_update(&update).await;
}
}
}
PersistenceMsg::Chat(chat_msg) => {
if let Err(e) = self
if let Err(error) = self
.storage
.append_chat_message(&self.info, &chat_msg)
.await
{
tracing::warn!(?e, "failed to write chat message");
tracing::warn!(%error, "failed to write chat message");
self.note_storage_failure(&error);
}
}
PersistenceMsg::ReplaceChatHistory(messages) => {
@@ -1571,6 +1600,7 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, "failed to replace chat history");
self.note_storage_failure(&e);
}
}
PersistenceMsg::CurrentModel {
@@ -1589,21 +1619,25 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, "failed to update current model");
self.note_storage_failure(&e);
}
}
PersistenceMsg::PlanState(state) => {
if let Err(e) = self.storage.write_plan_state(&self.info, &state).await {
tracing::warn!(?e, "failed to write plan state");
self.note_storage_failure(&e);
}
}
PersistenceMsg::PlanModeState(state) => {
if let Err(e) = self.storage.write_plan_mode_state(&self.info, &state).await {
tracing::warn!(?e, "failed to write plan mode state");
self.note_storage_failure(&e);
}
}
PersistenceMsg::GoalModeState(state) => {
if let Err(e) = self.storage.write_goal_mode_state(&self.info, &state).await {
tracing::warn!(?e, "failed to write goal mode state");
self.note_storage_failure(&e);
}
}
PersistenceMsg::GraphModeState(state) => {
@@ -1613,6 +1647,7 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, "failed to write graph mode state");
self.note_storage_failure(&e);
}
}
PersistenceMsg::ContentChunk(content_chunks) => {
@@ -1686,12 +1721,14 @@ impl SessionPersistence {
}
Err(e) => {
tracing::warn!(?e, "failed to persist generated session title");
self.note_storage_failure(&e);
}
}
}
PersistenceMsg::RewindPoint(point) => {
if let Err(e) = self.storage.append_rewind_point(&self.info, &point).await {
tracing::warn!(?e, "failed to write rewind point");
if let Err(error) = self.storage.append_rewind_point(&self.info, &point).await {
tracing::warn!(%error, "failed to write rewind point");
self.note_storage_failure(&error);
}
}
PersistenceMsg::TruncateRewindPoints { from_index } => {
@@ -1701,6 +1738,7 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, from_index, "failed to truncate rewind points");
self.note_storage_failure(&e);
}
}
PersistenceMsg::MergeRewindPointsFrom { target_index } => {
@@ -1710,6 +1748,7 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, target_index, "failed to merge rewind points");
self.note_storage_failure(&e);
}
}
PersistenceMsg::CollectionId(collection_id) => {
@@ -1719,6 +1758,7 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, "failed to write collection id");
self.note_storage_failure(&e);
}
}
PersistenceMsg::NextTraceTurn {
@@ -1731,11 +1771,13 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, "failed to write next trace turn");
self.note_storage_failure(&e);
}
}
PersistenceMsg::Signals(signals) => {
if let Err(e) = self.storage.write_signals(&self.info, &signals).await {
tracing::warn!(?e, "failed to write session signals");
self.note_storage_failure(&e);
}
}
PersistenceMsg::AnnouncementState(state) => {
@@ -1745,16 +1787,19 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, "failed to write announcement state");
self.note_storage_failure(&e);
}
}
PersistenceMsg::Feedback(entry) => {
if let Err(e) = self.storage.append_feedback(&self.info, &entry).await {
tracing::warn!(?e, "failed to write feedback entry");
self.note_storage_failure(&e);
}
}
PersistenceMsg::Btw(entry) => {
if let Err(e) = self.storage.append_btw(&self.info, &entry).await {
tracing::warn!(?e, "failed to write btw entry");
self.note_storage_failure(&e);
}
}
PersistenceMsg::GitHead { commit, branch } => {
@@ -1764,6 +1809,7 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, "failed to persist git HEAD");
self.note_storage_failure(&e);
}
}
PersistenceMsg::CompactionCheckpoint(checkpoint) => {
@@ -1773,6 +1819,7 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, "failed to write compaction checkpoint file");
self.note_storage_failure(&e);
}
}
PersistenceMsg::CompactionRequest(request) => {
@@ -1782,11 +1829,13 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, "failed to write compaction request artifact");
self.note_storage_failure(&e);
}
}
PersistenceMsg::RecapRequest(request) => {
if let Err(e) = self.storage.write_recap_request(&self.info, &request).await {
tracing::warn!(?e, "failed to write recap request artifact");
self.note_storage_failure(&e);
}
}
PersistenceMsg::CompactionSegment(segment) => {
@@ -1796,20 +1845,25 @@ impl SessionPersistence {
.await
{
tracing::warn!(?e, "failed to write compaction segment");
self.note_storage_failure(&e);
}
}
PersistenceMsg::CopyFile { one_shot } => {
// Flush pending writes and sync all session files to disk before copying.
self.flush_and_sync().await;
let result = self.copy_session_dir_to_memory().await;
// Not the durability barrier: the write-failure latch stays for FlushAndAck.
let flushed = self.flush_pending().await;
let result = match flushed.and(self.sync_files().await) {
Ok(()) => self.copy_session_dir_to_memory().await,
Err(error) => {
Err(anyhow::Error::new(error).context("session files are not on disk"))
}
};
let _ = one_shot.send(result);
}
}
}
// Drain the merge buffer on channel close.
self.flush_pending().await;
// Drain the merge buffer on channel close; a failure is logged by write_update.
let _ = self.flush_pending().await;
}
async fn copy_session_dir_to_memory(&self) -> anyhow::Result<SessionStateCopy> {
@@ -2002,6 +2056,7 @@ pub(crate) async fn new(
info: info_clone,
storage: storage.clone(),
pending_notification: None,
pending_write_error: None,
rx,
summary: crate::session::summary::SummaryGenerator::new(
crate::session::summary::SummaryConfig {
@@ -2070,6 +2125,7 @@ pub async fn new_with_explicit_dir(
info: info_clone,
storage: storage.clone(),
pending_notification: None,
pending_write_error: None,
rx,
summary: crate::session::summary::SummaryGenerator::new(
crate::session::summary::SummaryConfig {
@@ -2181,6 +2237,7 @@ pub(crate) async fn load_light(
info: loaded_info,
storage: storage.clone(),
pending_notification: None,
pending_write_error: None,
rx,
summary: summary_gen,
registry_title_sync,
@@ -3625,3 +3682,120 @@ mod repo_wide_resolution_tests {
);
}
}
#[cfg(test)]
mod barrier_tests {
use super::*;
fn sampling_client() -> crate::sampling::Client {
crate::sampling::Client::new(kigi_sampler::SamplerConfig {
api_key: Some("test-key".to_string()),
base_url: "http://localhost".to_string(),
model: "test".to_string(),
max_completion_tokens: None,
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
auth_scheme: Default::default(),
anthropic_oauth: false,
github_copilot: false,
openai_codex: false,
extra_headers: Default::default(),
context_window: 100_000,
force_http1: false,
max_retries: None,
stream_tool_calls: false,
idle_timeout_secs: None,
reasoning_effort: None,
origin_client: None,
attribution_callback: None,
bearer_resolver: None,
supports_backend_search: false,
compactions_remaining: None,
compaction_at_tokens: None,
doom_loop_recovery: None,
header_injector: None,
})
.expect("sampling client should build")
}
async fn barrier(handle: &PersistenceHandle) -> io::Result<()> {
let (tx, rx) = tokio::sync::oneshot::channel();
handle
.tx
.send(PersistenceMsg::FlushAndAck { respond_to: tx })
.unwrap();
rx.await.unwrap()
}
async fn spawn(dir: &std::path::Path) -> (Info, PersistenceHandle) {
let info = Info {
id: acp::SessionId::new("barrier"),
cwd: dir.to_string_lossy().into_owned(),
};
let handle = new_with_explicit_dir(
&info,
dir.to_path_buf(),
acp::ModelId::new("test-model"),
sampling_client(),
crate::test_support::TEST_MODEL.to_owned(),
)
.await
.unwrap();
(info, handle)
}
/// A chat append that never reached the file fails the next barrier once;
/// the barrier after that is clean again.
#[tokio::test]
async fn flush_and_ack_reports_a_lost_chat_append_once() {
let dir = tempfile::tempdir().unwrap();
let (info, handle) = spawn(dir.path()).await;
barrier(&handle).await.unwrap();
// A directory where the chat log should be makes the append fail.
let chat_file = JsonlStorageAdapter::with_explicit_session_dir(dir.path().to_path_buf())
.chat_file(&info);
let _ = std::fs::remove_file(&chat_file);
std::fs::create_dir(&chat_file).unwrap();
handle
.tx
.send(PersistenceMsg::Chat(ConversationItem::user("lost")))
.unwrap();
// CopyFile is an ordering fence that keeps the write-failure latch.
let (tx, rx) = tokio::sync::oneshot::channel();
handle
.tx
.send(PersistenceMsg::CopyFile { one_shot: tx })
.unwrap();
let _ = rx.await.unwrap();
// With the path restored only the lost append can fail the barrier.
std::fs::remove_dir(&chat_file).unwrap();
let error = barrier(&handle).await.unwrap_err();
assert_ne!(error.kind(), io::ErrorKind::NotFound, "got {error}");
barrier(&handle).await.unwrap();
}
/// A history rewrite that never landed fails the next barrier.
#[tokio::test]
async fn flush_and_ack_reports_a_failed_history_replacement() {
let dir = tempfile::tempdir().unwrap();
let (info, handle) = spawn(dir.path()).await;
let chat_file = JsonlStorageAdapter::with_explicit_session_dir(dir.path().to_path_buf())
.chat_file(&info);
// The rewrite stages a sibling temp file; a directory there fails it.
std::fs::create_dir(chat_file.with_extension("jsonl.tmp")).unwrap();
handle
.tx
.send(PersistenceMsg::ReplaceChatHistory(vec![
ConversationItem::user("rewritten"),
]))
.unwrap();
let error = barrier(&handle).await.unwrap_err();
assert_ne!(error.kind(), io::ErrorKind::NotFound, "got {error}");
barrier(&handle).await.unwrap();
}
}
@@ -86,7 +86,7 @@ impl JsonlStorageAdapter {
fn updates_file(&self, info: &Info) -> PathBuf {
self.session_dir(info).join("updates.jsonl")
}
fn chat_file(&self, info: &Info) -> PathBuf {
pub(crate) fn chat_file(&self, info: &Info) -> PathBuf {
self.session_dir(info).join("chat_history.jsonl")
}
fn summary_file(&self, info: &Info) -> PathBuf {
@@ -700,7 +700,7 @@ impl JsonlStorageAdapter {
options: super::CopySessionOptions,
) -> io::Result<super::CopySessionResult> {
let target_dir = self.session_dir(target_info);
std::fs::create_dir_all(&target_dir)?;
super::create_dir_all_durable(&target_dir)?;
let source_summary = self.read_summary_sync(source_info)?;
let chat_format_version = source_summary.chat_format_version;
let mut chat_to_copy: Vec<ConversationItem> =
@@ -920,7 +920,7 @@ async fn next_compaction_segment_index(compaction_dir: &std::path::Path) -> u64
impl StorageAdapter for JsonlStorageAdapter {
async fn init_session(&self, info: &Info, model_id: acp::ModelId) -> io::Result<Summary> {
let dir = self.session_dir(info);
std::fs::create_dir_all(&dir)?;
super::create_dir_all_durable(&dir)?;
let summary_path = self.summary_file(info);
if Path::new(&summary_path).exists() {
tracing::info!("Loading existing session from JSONL");
@@ -1283,29 +1283,10 @@ impl StorageAdapter for JsonlStorageAdapter {
.await
}
async fn sync_session_files(&self, info: &Info) -> io::Result<()> {
let info_clone = info.clone();
let adapter_clone = self.clone();
tokio::task::spawn_blocking(move || -> io::Result<()> {
use std::fs::OpenOptions;
let adapter = adapter_clone;
let files_to_sync = [
adapter.updates_file(&info_clone),
adapter.chat_file(&info_clone),
adapter.summary_file(&info_clone),
adapter.plan_file(&info_clone),
adapter.rewind_points_file(&info_clone),
];
for file_path in &files_to_sync {
if file_path.exists()
&& let Ok(file) = OpenOptions::new().write(true).open(file_path)
{
let _ = file.sync_all();
}
}
Ok(())
})
.await
.map_err(io::Error::other)?
let dir = self.session_dir(info);
tokio::task::spawn_blocking(move || super::sync_tree_durable(&dir))
.await
.map_err(io::Error::other)?
}
async fn replace_chat_history(
&self,
@@ -2794,3 +2794,24 @@ async fn graph_mode_state_round_trips_and_tombstones() {
let after = adapter.load_session_without_updates(&info).await.unwrap();
assert!(after.graph_mode_state.is_none(), "cleared graph must not resurrect");
}
/// The barrier covers nested state files, and an unsyncable one fails it.
#[cfg(unix)]
#[tokio::test]
async fn sync_session_files_reports_a_nested_file_that_cannot_be_synced() {
use std::os::unix::fs::PermissionsExt;
// Root opens anything; the permission probe is meaningless there.
if unsafe { libc::geteuid() } == 0 {
return;
}
let dir = TempDir::new().unwrap();
let adapter = JsonlStorageAdapter::with_explicit_session_dir(dir.path().to_path_buf());
let info = create_test_info();
adapter.init_session(&info, default_model_id()).await.unwrap();
let nested = adapter.goal_mode_state_file(&info);
std::fs::create_dir_all(nested.parent().unwrap()).unwrap();
std::fs::write(&nested, b"{}").unwrap();
std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o000)).unwrap();
let error = adapter.sync_session_files(&info).await.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "got {error}");
}
@@ -1,5 +1,5 @@
use async_trait::async_trait;
use std::io::{self, BufRead, BufReader, Seek, SeekFrom};
use std::io::{self, BufRead, BufReader, Seek, SeekFrom, Write};
use std::path::Path;
use crate::extensions::notification::SessionNotification;
@@ -15,6 +15,137 @@ use agent_client_protocol as acp;
use kigi_sampling_types::ReasoningEffort;
use kigi_workspace::session::file_state::RewindPoint;
/// Fsync `file` to stable media; macOS `fsync` may stop at the drive cache.
#[cfg(target_os = "macos")]
pub(crate) fn sync_file_durable(file: &std::fs::File) -> io::Result<()> {
use std::os::fd::AsRawFd;
file.sync_all()?;
fullfsync_raw(file.as_raw_fd())
}
#[cfg(target_os = "macos")]
fn fullfsync_raw(fd: std::os::fd::RawFd) -> io::Result<()> {
// SAFETY: fcntl on a live descriptor with no pointer arguments.
if unsafe { libc::fcntl(fd, libc::F_FULLFSYNC) } == -1 {
return Err(io::Error::last_os_error());
}
Ok(())
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn sync_file_durable(file: &std::fs::File) -> io::Result<()> {
file.sync_all()
}
/// Fsync `dir` so entries just created or renamed into it survive power loss.
#[cfg(target_os = "macos")]
pub(crate) fn sync_dir_durable(dir: &Path) -> io::Result<()> {
use std::os::fd::AsRawFd;
let file = std::fs::File::open(dir)?;
file.sync_all()?;
fullfsync_raw(file.as_raw_fd())
}
#[cfg(all(unix, not(target_os = "macos")))]
pub(crate) fn sync_dir_durable(dir: &Path) -> io::Result<()> {
std::fs::File::open(dir)?.sync_all()
}
/// Windows has no directory-handle fsync; NTFS journals directory metadata.
#[cfg(windows)]
pub(crate) fn sync_dir_durable(_dir: &Path) -> io::Result<()> {
Ok(())
}
/// Write `bytes` to `tmp`, fsync them, rename over `dest`, fsync the parent:
/// a crash never leaves `dest` torn, and the rename never publishes
/// unsynced bytes over a synced file.
pub(crate) fn write_bytes_atomic(tmp: &Path, dest: &Path, bytes: &[u8]) -> io::Result<()> {
let written = std::fs::File::create(tmp).and_then(|mut file| {
file.write_all(bytes)?;
sync_file_durable(&file)
});
if let Err(error) = written {
let _ = std::fs::remove_file(tmp);
return Err(error);
}
crate::util::fs::replace_file(tmp, dest)?;
sync_parent_dir_durable(dest)
}
fn sync_parent_dir_durable(path: &Path) -> io::Result<()> {
let parent = path
.parent()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no parent"))?;
sync_dir_durable(parent)
}
fn open_for_sync(path: &Path) -> io::Result<std::fs::File> {
let mut options = std::fs::OpenOptions::new();
options.read(true);
// FlushFileBuffers needs a writable handle; fsync does not.
options.write(cfg!(windows));
options.open(path)
}
/// Fsync every file and directory under `dir`, `dir` included, so all
/// session state reaches stable media. A path that vanished is not an error.
pub(crate) fn sync_tree_durable(dir: &Path) -> io::Result<()> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error),
};
for entry in entries {
let entry = entry?;
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
Err(error) => return Err(error),
};
if file_type.is_dir() {
sync_tree_durable(&entry.path())?;
} else if file_type.is_file() {
match open_for_sync(&entry.path()) {
Ok(file) => sync_file_durable(&file)?,
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
}
match sync_dir_durable(dir) {
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
result => result,
}
}
/// `create_dir_all`, then fsync every directory that gained an entry so the
/// chain itself survives power loss. A directory that exists but is still
/// empty re-syncs a few ancestors: an earlier create may have died before
/// its sync.
pub(crate) fn create_dir_all_durable(dir: &Path) -> io::Result<()> {
let mut gaining_an_entry = Vec::new();
let mut cursor = dir;
while !cursor.exists() {
let Some(parent) = cursor.parent() else { break };
gaining_an_entry.push(parent.to_path_buf());
cursor = parent;
}
std::fs::create_dir_all(dir)?;
if gaining_an_entry.is_empty() && std::fs::read_dir(dir)?.next().is_none() {
gaining_an_entry.extend(dir.ancestors().skip(1).take(4).map(Path::to_path_buf));
}
for parent in gaining_an_entry.iter().filter(|parent| !is_fs_root(parent)) {
sync_dir_durable(parent)?;
}
Ok(())
}
fn is_fs_root(path: &Path) -> bool {
path.parent()
.is_none_or(|parent| parent.as_os_str().is_empty())
}
pub mod jsonl;
pub mod search;
pub mod search_fts;
@@ -228,12 +228,13 @@ fn read_summary(path: &Path) -> io::Result<Summary> {
fn write_summary_atomic(summary_path: &Path, summary: &Summary) -> io::Result<()> {
let bytes = serde_json::to_vec_pretty(summary)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let tmp = summary_path.with_extension("json.tmp");
std::fs::write(&tmp, &bytes)?;
// Windows-safe replace: this is the write that persists a session's
// CURRENT MODEL — a bare rename made a switched model silently revert
// on resume whenever AV/indexer held summary.json open on Windows.
crate::util::fs::replace_file(&tmp, summary_path)
// Durable and Windows-safe: this write persists a session's CURRENT MODEL,
// and other writers (`/rename`) race the persistence barrier on this path.
super::write_bytes_atomic(
&summary_path.with_extension("json.tmp"),
summary_path,
&bytes,
)
}
#[cfg(test)]
@@ -8,13 +8,66 @@ use tokio::time;
use crate::terminal::runner::{
AsyncTerminalRunner, TerminalError, TerminalRunRequest, TerminalRunResult,
};
use kigi_tty_utils::KILL_REAP_TIMEOUT;
pub struct LocalTerminalRunner;
async fn read_stream(mut stream: impl AsyncReadExt + Unpin) -> Vec<u8> {
let mut buffer = Vec::new();
let _ = stream.read_to_end(&mut buffer).await;
buffer
/// A pipe reader whose buffer is shared with the spawner, so bytes read
/// before an abort are not lost with the task.
struct PipeReader {
task: tokio::task::JoinHandle<()>,
buffer: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
}
impl PipeReader {
fn spawn(mut stream: impl AsyncReadExt + Unpin + Send + 'static) -> Self {
let buffer = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let task_buffer = std::sync::Arc::clone(&buffer);
let task = tokio::spawn(async move {
let mut chunk = [0u8; 8192];
loop {
match stream.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(n) => Self::lock_buffer(&task_buffer).extend_from_slice(&chunk[..n]),
}
}
});
Self { task, buffer }
}
/// Never panics: a poisoned lock just means a holder panicked mid-append —
/// the buffer is plain bytes and stays consistent, so recover it.
fn lock_buffer(buffer: &std::sync::Mutex<Vec<u8>>) -> std::sync::MutexGuard<'_, Vec<u8>> {
buffer
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn take_buffer(self) -> Vec<u8> {
std::mem::take(&mut Self::lock_buffer(&self.buffer))
}
/// Wait for EOF (pipe closed by all writers) at most [`KILL_REAP_TIMEOUT`],
/// then return everything read so far.
///
/// Callers only join after the child was reaped or killed, so EOF is
/// normally immediate; the bound is a backstop for writers that outlive
/// the command and keep the pipe open — a background descendant
/// (`printf hi; sleep 30 &`) or a process wedged in uninterruptible
/// kernel I/O (D-state), which not even SIGKILL moves.
async fn join_bounded(mut self) -> Vec<u8> {
if time::timeout(KILL_REAP_TIMEOUT, &mut self.task)
.await
.is_err()
{
self.task.abort();
// Await the cancellation: abort() is asynchronous, and a reader
// past its await point could otherwise append between the take
// below and its own completion, losing those bytes.
let _ = (&mut self.task).await;
}
self.take_buffer()
}
}
/// Truncate buffer to keep only the last `limit` bytes (drops oldest bytes).
@@ -66,12 +119,35 @@ impl AsyncTerminalRunner for LocalTerminalRunner {
// Detach from the controlling terminal so child processes
// (e.g. GPG pinentry) cannot open /dev/tty and corrupt the TUI.
// (This also makes the child its own session/group leader, which the
// ProcessGroup attach below relies on.)
kigi_tools::util::detach_command(&mut cmd);
// process-group-killed on the request timeout; an unreapable (D-state)
// child is abandoned to the runtime's orphan reaper
let mut child = cmd
.spawn()
.map_err(|e| TerminalError::Other(format!("Failed to start shell: {e}")))?;
// Own the whole tree, not just the direct shell: on timeout the group
// is killed so grandchildren can't keep running (and can't hold the
// output pipes open past the kill). Group teardown is an enhancement
// over the direct-kill floor, so both enrollment failures degrade
// softly instead of dropping an already-running shell.
let process_group = match kigi_tty_utils::ProcessGroup::new() {
Ok(mut group) => {
if let Err(e) = group.attach(&child) {
// e.g. the child already exited.
tracing::debug!("ProcessGroup::attach failed: {e}");
}
Some(group)
}
Err(e) => {
tracing::warn!("Failed to create process group: {e}");
None
}
};
let stdout = child
.stdout
.take()
@@ -81,28 +157,46 @@ impl AsyncTerminalRunner for LocalTerminalRunner {
.take()
.ok_or_else(|| TerminalError::Other("Failed to capture stderr".into()))?;
let stdout_task = tokio::spawn(read_stream(stdout));
let stderr_task = tokio::spawn(read_stream(stderr));
let stdout_reader = PipeReader::spawn(stdout);
let stderr_reader = PipeReader::spawn(stderr);
let mut timed_out = false;
let wait_result = time::timeout(request.timeout, child.wait()).await;
let exit_status = match wait_result {
let exit_code = match wait_result {
Ok(status_res) => status_res
.map_err(|e| TerminalError::Other(format!("Failed to wait for process: {e}")))?,
.map_err(|e| TerminalError::Other(format!("Failed to wait for process: {e}")))?
.code(),
Err(_) => {
timed_out = true;
// Kill the direct child AND the whole group (grandchildren),
// then return the synthetic timeout result without waiting
// for the corpse: `timed_out: true` already tells the caller
// everything, and a D-state child would never become reapable
// anyway. Both kills are unconditional — `kill()` on a group
// whose attach failed is a silent no-op, so the direct
// `start_kill` is the guaranteed floor (same as
// `util/subprocess.rs`). The kills close the pipes, so the
// bounded joins below return immediately in the normal case;
// the abandoned child goes to tokio's orphan reaper.
if let Err(e) = child.start_kill() {
tracing::warn!("Failed to kill timed-out process: {e}");
}
child.wait().await.map_err(|e| {
TerminalError::Other(format!("Failed to wait for killed process: {e}"))
})?
if let Some(group) = &process_group
&& let Err(e) = group.kill()
{
tracing::warn!("Failed to kill timed-out process group: {e}");
}
None
}
};
let stdout_result = stdout_task.await.unwrap_or_else(|_| Vec::new());
let stderr_result = stderr_task.await.unwrap_or_else(|_| Vec::new());
// Join concurrently (worst case one reap bound, not one per pipe).
// Bounded on the natural-exit arm too: a background descendant that
// outlives the shell (`printf hi; sleep 30 &`) holds the pipes open
// past the child's exit, and an unbounded join stalls on it.
let (stdout_result, stderr_result) =
tokio::join!(stdout_reader.join_bounded(), stderr_reader.join_bounded());
let mut combined = stdout_result;
combined.extend(stderr_result);
@@ -112,7 +206,7 @@ impl AsyncTerminalRunner for LocalTerminalRunner {
Ok(TerminalRunResult {
combined_output,
exit_code: exit_status.code(),
exit_code,
truncated,
signal: None,
timed_out,
@@ -181,4 +275,56 @@ mod tests {
assert_eq!(result.combined_output.trim(), "hello");
assert_eq!(result.exit_code, Some(0));
}
/// Timing out a command whose grandchild inherited the output pipes must
/// (a) return promptly — before the group kill, the joins waited for the
/// grandchild's EOF (the full sleep here; forever for a writer wedged in
/// uninterruptible kernel I/O, the production incident this guards
/// against) — and (b) actually kill the grandchild via the group kill,
/// not merely stop waiting for it.
#[tokio::test]
#[cfg(unix)]
async fn test_timeout_kills_grandchildren_and_returns_promptly() {
let mut request = make_request("sleep 5 & echo bgpid=$!; sleep 5");
request.timeout = std::time::Duration::from_millis(300);
let started = std::time::Instant::now();
let result = LocalTerminalRunner.run(request).await.unwrap();
assert!(result.timed_out, "run should report the timeout");
// Prompt return: request timeout (0.3s) + pipe EOF from the group
// kill (normally instant; KILL_REAP_TIMEOUT-bounded worst case) —
// anything near the 5s sleeps means we waited for the grandchild.
assert!(
started.elapsed() < std::time::Duration::from_secs(4),
"timeout path must not wait for the killed tree (took {:?})",
started.elapsed()
);
// Outcome: the background grandchild must actually be dead, proving
// the group kill reached it. Poll briefly — the SIGKILL is delivered
// synchronously but init may reap the orphan a beat later.
let bg_pid = result
.combined_output
.lines()
.find_map(|l| l.trim().strip_prefix("bgpid="))
.and_then(|p| p.trim().parse::<u32>().ok())
.expect("background pid should have been echoed before the kill");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
let alive = std::process::Command::new("kill")
.args(["-0", &bg_pid.to_string()])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !alive {
break;
}
assert!(
std::time::Instant::now() < deadline,
"background grandchild {bg_pid} survived the group kill"
);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
}
@@ -912,7 +912,10 @@ fn spawn_with_argv(
if kigi_sandbox::should_restrict_child_network() {
// SAFETY: single prctl syscall (async-signal-safe).
unsafe {
cmd.pre_exec(|| kigi_sandbox::child_net::install_child_network_filter());
let filter = kigi_sandbox::child_net::prebuilt_child_network_filter();
cmd.pre_exec(move || {
kigi_sandbox::child_net::install_child_network_filter(filter)
});
}
}
});
@@ -33,6 +33,11 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap<String, bool>) -> SubagentSpawnCon
let (tx, _rx) = mpsc::unbounded_channel();
SubagentSpawnContext {
lsp: None,
spawn_limits: kigi_tools::implementations::kigi::task::admission::SubagentLimits::default(),
spawn_permits: std::sync::Arc::new(tokio::sync::Semaphore::new(
kigi_tools::implementations::kigi::task::admission::SubagentLimits::default()
.effective_max_concurrent(),
)),
parent_max_turns: None,
gateway: test_gateway(),
client_hooks: Default::default(),
@@ -1,6 +1,42 @@
/// Default auto-compact threshold (% of context window) when no source sets it.
pub const DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT: u8 = 85;
/// `tool_choice` sent on compaction summarizer requests when tools are
/// attached (prefix-cache alignment keeps the tool definitions in the
/// request). Default `Auto` because some OpenAI-compatible servers reject
/// `tool_choice: "none"`; the summarization prompt itself forbids tool use.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CompactionToolChoice {
#[default]
Auto,
None,
}
impl std::str::FromStr for CompactionToolChoice {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"auto" => Ok(Self::Auto),
"none" => Ok(Self::None),
_ => Err(()),
}
}
}
pub(crate) const ENV_COMPACTION_TOOL_CHOICE: &str = "KIGI_COMPACTION_TOOL_CHOICE";
pub(crate) fn resolve_compaction_tool_choice_from(
env: Option<&str>,
config: Option<&str>,
remote: Option<&str>,
) -> CompactionToolChoice {
env.and_then(|s| s.parse().ok())
.or_else(|| config.and_then(|s| s.parse().ok()))
.or_else(|| remote.and_then(|s| s.parse().ok()))
.unwrap_or_default()
}
/// Env-var override for `auto_compact_threshold_percent`. Parsed as `u8`;
/// out-of-range or unparseable values are ignored.
pub(crate) const ENV_AUTO_COMPACT_THRESHOLD_PERCENT: &str = "KIGI_AUTO_COMPACT_THRESHOLD_PERCENT";
@@ -127,6 +163,37 @@ pub fn resolve_compaction_wall_clock_budget_secs(gb_global: Option<u64>) -> u64
resolved
}
#[cfg(test)]
mod compaction_tool_choice_tests {
use super::{CompactionToolChoice, resolve_compaction_tool_choice_from as resolve};
#[test]
fn precedence_env_config_remote_default() {
assert_eq!(resolve(None, None, None), CompactionToolChoice::Auto);
assert_eq!(
resolve(None, None, Some("none")),
CompactionToolChoice::None
);
assert_eq!(
resolve(None, Some("auto"), Some("none")),
CompactionToolChoice::Auto
);
assert_eq!(
resolve(Some("none"), Some("auto"), Some("auto")),
CompactionToolChoice::None
);
// Unparseable tiers fall through instead of masking lower tiers.
assert_eq!(
resolve(Some("bogus"), None, Some("none")),
CompactionToolChoice::None
);
assert_eq!(
resolve(Some("NONE "), None, None),
CompactionToolChoice::None
);
}
}
#[cfg(test)]
mod compaction_wall_clock_budget_tests {
use super::resolve_compaction_wall_clock_budget_secs as resolve;
@@ -161,6 +161,9 @@ async fn request(handle: &PermissionHandle, access: AccessKind, id: &str) -> Dec
session_id: None,
subagent_type: None,
subagent_description: None,
// The accesses here carry no file path; the process cwd is a real cwd.
tool_cwd: AbsPathBuf::new(std::env::current_dir().unwrap()).unwrap(),
display_cwd: None,
};
let PermissionHandle::Actor { cmd_tx, .. } = handle else {
panic!("expected actor handle");
Loaded 100 of 216 files, more files were not shown because too many files have changed in this diff. Show more