fix(connections): isolate warm mint agent specs per process - #8325
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Moves process-private specs to a process-owned scope with fail-closed, PID-reuse-proof lifecycle — root-cause fix, proportionate machinery, reversible, boot-path and injection fences preserved. [DESIGN-REVIEWED] 9cf5b62 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All checks complete. Producing the review. First-Principles-Verdict: CONCERNS Cause-level fix for warm modes, but the cold mint engine still publishes process-only specs into the same global roster this PR calls pollution. What this change shipsIntent: stop internal warm-mint modes appearing in the user's agent roster and outliving their process — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 9cf5b62 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsNo findings block the merge. FINDING — src/kiro_crew/connections/warm.py:1516 — When [OPUS-REVIEWED] 9cf5b62 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/connections/warm.py:1516 -- discarded False positive or not applicable? A repository writer can comment: |
698abe7 to
f4bf213
Compare
Correct, and the divergence is total rather than intermittent. What made this worth fixing rather than noting is that it inverts a safety property instead of merely losing a signal.
Pinned red-first; both tests fail on the prior head and pass on this one:
Neither test stubs Verified on |
Adopted exactly as written. "Deterministically" is the right word, and the platform split is worth stating precisely: The severity comes from which direction the misclassification falls. Pinned red-first; both tests fail on the prior head and pass on this one:
Verified on |
f4bf213 to
2fb074a
Compare
Correct, and
Placement is deliberate. Of the three Pinned by two tests, deliberately paired:
One pre-existing test needed its premise stated rather than weakened. Verified on |
2fb074a to
5564b3a
Compare
Accepted, and the deferral turned out to buy nothing worth keeping. The rule's exception list covers Hoisting is also safe rather than merely tolerable, which is the part I checked rather than assumed. One test moved with the change, and it landed on the exact hazard the rule names third. I took this on despite it being advisory and non-blocking because the PR is not green yet, so it costs no extra review round — and because the alternative was arguing for an exception the rule does not grant. Verified on |
5564b3a to
388b882
Compare
The mechanism is real and the reading matches the code: an unprovable identity keeps the directory, by the fail-closed rule this PR deliberately adopts (unreadable or ambiguous ownership must never delete). The consequence is bounded clutter under run/connections-warm/generations, not a credential or correctness risk. The prescribed fix (the confirmed-kill abandon path removes a never-bound provisional directory directly, as the runtime-is-None branch already does) is sound and small, but this head is otherwise green with zero blocking findings across every lane, so per the repository babysit convention the head is frozen and the fix lands in the next Connections slice (warm-rearm), which edits the same file. |
ade09dd to
5b0124d
Compare
5b0124d to
50c47c5
Compare
50c47c5 to
9cf5b62
Compare
Accepted, and the escalation from the earlier advisory framing is warranted: the loose directory does not merely weaken a guarantee, it holds the agent SPECS a warm runtime mounts — writable specs in a world-accessible directory are an injection point into the agent, which is the exact isolation this PR exists to provide.
This supersedes my earlier same-mechanism advisory record (span b23252488e85, "accepted as follow-up"): the re-roll's blocking severity is the correct reading, so the fix landed now rather than in the follow-up slice. The follow-up still carries the Opus abandon-path advisory (span 0e5c8da823a6). Verified on |
buluoray
left a comment
There was a problem hiding this comment.
Reviewed against source at 9cf5b62, with the guards mutation-tested and all 209 deletions individually accounted for. 0 blocking findings.
The sharing was real. Pre-fix (git show f59b865bbd:…/warm.py), both warm modes wrote specs to _agent.kiro_agents_dir_path() — the user's global ~/.kiro/agents, shared by every process and scanned by agent discovery. It polluted the user's agent roster and left global files outliving the process that owned their OAuth sessions.
The isolation key is the right one. Each ACP process gets run/connections-warm/generations/generation-<uuid4hex>/ as its cwd, with specs in that dir's project-local .kiro/agents, which kiro-cli resolves ahead of the global scope for both the initial --agent spawn and later session/set_mode. Deliberately not a PID, so OS PID reuse cannot collide dir names; PID reuse is instead handled in the ownership marker via platform_compat.process_start_time identity plus the pid_liveness tri-state, so a reused PID reads as dead rather than live.
Credentials and platform, the two things I most wanted to check: the generation dir holds agent specs only (model / allowedTools / mcpServers), not tokens — the OAuth cache is kiro-cli's separate store. It is owner-only via fail-loud platform_compat.restrict_dir_to_owner re-tightening all four levels after the best-effort make_owner_only_dir, with BaseException cleanup, so an ACL-write failure raises rather than leaving world-readable and injectable specs. Every process/lock/identity/ACL call routes through platform_compat; the diff adds no bare os.chmod, fcntl, os.kill or resource use.
Cleanup is three-layered: removal once the runtime's death is proven, a graceful shutdown_warm_mint hook wired into both start_dashboard and start_api_server, and a post-bind boot scavenge that deletes only generations whose gateway and runtime identities are both provably dead. The scavenge is scheduled strictly after _start_site with the connections.warm import deferred into the worker thread, so the no-new-work-on-boot-path rule holds.
Mutation results:
| Mutation | Result |
|---|---|
_recorded_runtime_is_dead: pid<=0 → return True |
RED |
| Scavenge drops the gateway-liveness veto | RED |
_warm_generation_agents_dir → global kiro_agents_dir_path() |
3 RED (private-scope, discovery-exclusion, both-resolve) |
Delete all restrict_dir_to_owner calls |
RED (DID NOT RAISE) |
Deletion accounting — every one is a move or a strengthening, none drops a guard. The global _write_warm_mint_specs / _unowned_plan_specs / _remove_warm_mint_specs become the same functions taking an explicit agents_dir, fed the private scope with ownership/sentinel gating preserved. The retire-time global spec sweeps in _abandon_spawn_locked, _kill_generation and shutdown become per-generation _release_runtime_generation, which is strictly stronger: it proves runtime death before removing, where the old path swept globally and unconditionally. The "never strand a still-running parked process" rule is preserved and tightened. No changed-signature symbol has a stale caller.
Non-blocking:
- The residual leak the blocking bots found and you accepted as a follow-up is real, and I concur it is non-blocking: on a spawn failure where the runtime dies before
_bind_warm_generation(warm.py:921, theif pid <= 0: return Falsebranch) the marker keepsruntime_pid: 0, so_recorded_runtime_is_deadreturns "unproved" and_process_identity_live(0, "")returnsNoneat every future boot — the dir persists indefinitely. It is error-path-only (per spawn failure, not per operation), the artifacts are small owner-only dirs, and there is no credential or correctness exposure. It is the one thing keeping this from pristine. - Cold mint still writes
kirocrew-mint-<alias>-<pid>-<hex>.jsoninto the global agents dir (mint.py:392) with no roster filter — the same pollution class, pre-existing, correctly out of scope here.
Not verified: no test spawns two real OS processes racing on the generations root — all 26 tests run in one pytest process and simulate the second via marker files with synthetic PIDs and start-times. That is the practical way to test it and the marker-identity logic is exercised directly and proven load-bearing by mutation, but the genuine concurrent-process case rests on platform_compat's own tests. Linux only; Windows/macOS ACL and start-time behavior untested here.
The warm pool premints approval URLs ahead of need and deliberately skips any provider that already holds a grant. Nothing ever told it when a grant went away, so a provider stayed skipped after its credential was revoked, destroyed or expired -- and the next Authorize paid a full cold mint, measured at ~23s against a warm sub-second. (G2 pod bug-bash finding 3.) remove_provider_entry is the only production caller of revoke_local_grant, so it is the single place every local invalidation passes through. It now asks the pool to re-arm that provider, scheduled and never awaited: an activation spawns a helper process and negotiates OAuth, which must neither hold the config lock the transaction just released nor delay the user's answer. Eligibility is not re-derived. The ordinary tri-state candidate scan stays the authority, so a disabled entry, a provider with no usable auth configuration, a grant still present because the revoke was refused, and a grant cache that could not be read at all are skipped here exactly as arming already skips them -- a re-arm that would itself fail cold is noise. A provider already minting or waiting keeps its URL rather than having a redeemable one displaced, and at most one re-arm per provider is in flight, so repeated invalidations stack nothing. Also folds in the advisory dispositioned as follow-up on #8325: a spawn that never produced a process left its provisional generation directory behind for good, because the abandon path and startup scavenging both read a pid-0 marker as unproved and unproved must keep. A runtime that never reached a PID has no child that could still be reading that tree, which is the evidence the no-runtime branch already removes on, so that tree is released. A marker naming a PID still needs the identity proof: a spawn that failed after its child started may leave it running.
The warm pool premints approval URLs ahead of need and deliberately skips any provider that already holds a grant. Nothing ever told it when a grant went away, so a provider stayed skipped after its credential was revoked, destroyed or expired -- and the next Authorize paid a full cold mint, measured at ~23s against a warm sub-second. (G2 pod bug-bash finding 3.) remove_provider_entry is the only production caller of revoke_local_grant, so it is the single place every local invalidation passes through. It now asks the pool to re-arm that provider, scheduled and never awaited: an activation spawns a helper process and negotiates OAuth, which must neither hold the config lock the transaction just released nor delay the user's answer. Eligibility is not re-derived. The ordinary tri-state candidate scan stays the authority, so a disabled entry, a provider with no usable auth configuration, a grant still present because the revoke was refused, and a grant cache that could not be read at all are skipped here exactly as arming already skips them -- a re-arm that would itself fail cold is noise. A provider already minting or waiting keeps its URL rather than having a redeemable one displaced, and at most one re-arm per provider is in flight, so repeated invalidations stack nothing. Also folds in the advisory dispositioned as follow-up on #8325: a spawn that never produced a process left its provisional generation directory behind for good, because the abandon path and startup scavenging both read a pid-0 marker as unproved and unproved must keep. A runtime that never reached a PID has no child that could still be reading that tree, which is the evidence the no-runtime branch already removes on, so that tree is released. A marker naming a PID still needs the identity proof: a spawn that failed after its child started may leave it running.
The warm pool premints approval URLs ahead of need and deliberately skips any provider that already holds a grant. Nothing ever told it when a grant went away, so a provider stayed skipped after its credential was revoked, destroyed or expired -- and the next Authorize paid a full cold mint, measured at ~23s against a warm sub-second. (G2 pod bug-bash finding 3.) remove_provider_entry is the only production caller of revoke_local_grant, so it is the single place every local invalidation passes through. It now asks the pool to re-arm that provider, scheduled and never awaited: an activation spawns a helper process and negotiates OAuth, which must neither hold the config lock the transaction just released nor delay the user's answer. Eligibility is not re-derived. The ordinary tri-state candidate scan stays the authority, so a disabled entry, a provider with no usable auth configuration, a grant still present because the revoke was refused, and a grant cache that could not be read at all are skipped here exactly as arming already skips them -- a re-arm that would itself fail cold is noise. A provider already minting or waiting keeps its URL rather than having a redeemable one displaced, and at most one re-arm per provider is in flight, so repeated invalidations stack nothing. Also folds in the advisory dispositioned as follow-up on #8325: a spawn that never produced a process left its provisional generation directory behind for good, because the abandon path and startup scavenging both read a pid-0 marker as unproved and unproved must keep. A runtime that never reached a PID has no child that could still be reading that tree, which is the evidence the no-runtime branch already removes on, so that tree is released. A marker naming a PID still needs the identity proof: a spawn that failed after its child started may leave it running.
Problem / Motivation
Connections warm mint modes were written into the user's global agent directory. That exposed internal process-only modes in the user agent roster and left global artifacts behind when a helper or gateway crashed.
Why it matters
Internal warm modes are runtime implementation details, not user-selectable agents. Publishing them globally pollutes discovery and allows stale files to outlive the process that owns their OAuth sessions, making the visible roster and the actual runtime lifecycle disagree.
What changed (motivation → approach → change)
The warm helper now gives every ACP process a random, owner-only generation root under the protected Connections run tree and writes both warm modes to that root's project-local
.kiro/agentsscope. The generation root is also the runtime working directory, so the initial--agentspawn and later session mode selection resolve the same private specs without publishing either mode globally.Each generation carries an ownership marker with gateway/runtime PIDs and process-start identities. Confirmed process teardown releases only that generation; parked, failed-to-kill, and otherwise ambiguous generations are retained. Startup schedules a tracked off-loop scavenger without delaying listener binding, and removes a private generation only when both recorded identities are provably dead. Graceful cleanup retires live and parked generations in both full-dashboard and headless API gateways.
Startup also removes sentinel-owned legacy global warm specs. Unsentinelled legacy files remain untouched and are documented for manual inspection because their names alone cannot prove ownership. Internal generation scopes remain excluded from discovery and agent pickers.
Tests
mainin the same synthetic HOME/temp environment, so they are not PR-11 regressions.Manual verification
N/A — this is backend/runtime lifecycle behavior with no visual surface. The automated tests exercise filesystem placement, process ownership, cleanup, startup scheduling, and both gateway registrations directly.
Related Issues
no linked issue: this is PR-11 in the pre-G2 Connections hardening sequence.
Pattern harvest
Rule candidate: review-prompt
Pattern: Process-only agent specs must use a process-owned project scope, never a globally discovered user-agent scope.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)