feat(acp): probe backend install state and gate selection by policy - #7166
Conversation
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
UX Review (Fable 5) — ✅ PASSUX-level review of All evidence gathered. The product does have an existing gateway-restart control ( UX-Verdict: PASS Every probe verdict renders as a plain, actionable sentence in the row it gates, fail-open states stay muted, and the screenshots confirm all four states read cleanly. Suggestions
[UX-REVIEWED] 68af3f7 |
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound layering and honest failure semantics, but the shipped docs contradict themselves about when a fleet policy binds — half still describe a reverted runtime call site. Watch
[DESIGN-REVIEWED] 68af3f7 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of Analysis complete. The change is two declared, independently-justified additions (a machine-install probe and an First-Principles-Verdict: CONCERNS Both features earn their place, but What this change shipsIntent: tell an operator why a backend option is dead (install state) and let a fleet bound which harness is selectable. ADDITION (two, both declared in the title).
WatchGovernance scope binds only at gateway start; runtime Subtractions
[FIRST-PRINCIPLES-REVIEWED] 68af3f7 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
03566fa to
793f6da
Compare
793f6da to
2ceb857
Compare
|
|
|
|
|
Two independent facts decided whether an ACP harness could be selected, and only one of them existed.
2ceb857 to
68af3f7
Compare
|
…end architecture Upstream shipped a parallel backend architecture while this branch was built (kirodotdev#7166 install-state probing + policy-gated selection, kirodotdev#7301 Claude Code selectable, plus the subagent_manager/ and session_* decompositions). This merge adopts it as the foundation: acp_backends.py is the backend-id vocabulary leaf; Claude Code is serviceable (our wave-1 refusal posture dropped as factually superseded); the install probe and governance selectability layers gate the bundled rows. Our registry generalizes their closed set on top: operator descriptors, GenericAdapter serving, HarnessBinding per-chat/spawn/cron selection, and fail-closed capability gates all preserved, with the subagent/session harness logic re-homed into upstream's decomposed components and AgentBackendTab superseded by HarnessPanel carrying its install-state and policy display.
Problem / Motivation
Developer → Agent Backend cannot tell a user why a backend is unavailable, and its
one signal is the wrong kind of fact.
AgentBackendTabderives every option'sdisabledstate fromGET /api/config/schemaenumValues, which resolves fromacp_backends.selectable_backend_values()— abuild/edition fact. Nothing checks whether the harness's components are installed
on this machine, so:
claude-agent-acpadapter seesa dead option and no reason. The resolvers that could answer exist
(
_resolve_claude_acp_bin,_resolve_claude_code_executable) but are called onlyat spawn time and never surfaced.
has a
SCOPE_CATALOGrow (capabilities.publish,yolo_duration, …); the agentbackend has none, so "only run the harness we qualified" is unexpressible
(tracked as Add an agent_backend governance scope to bound which ACP harness a fleet may select #6622, which the selectable-backend registry from feat(config): one registry for selectable ACP backends instead of three literals #6593 left open).
Why it matters
The first is a dead end a user cannot diagnose: the panel says nothing, and the two
components have different remedies, so a bare "unavailable" sends someone after the
half they already have. The second means an enterprise that qualified one harness has
no way to stop a user selecting another.
What changed (motivation → approach → change)
Two independent facts decide whether a harness can be selected, and this PR adds the
two that were missing — deliberately at different layers.
1. Machine probe → whether the option can be clicked.
New
agent_sdk/backend_install.pyanswers per backend through the same resolvers the spawnuses, never a reimplementation — a probe that agreed with a hand-rolled PATH search
but disagreed with the spawn would tell an operator they are ready and then fail the
session. Three states, and the third is load-bearing:
unknownmeans the check itselfraised, and is never collapsed to
missing, because that would send someone to installwhat they may already have. Claude needs both components and names the absent one,
so the half-install case gets an actionable line plus the adapter's npm command (read
from
CLAUDE_ACP_NPM_PKG, not restated). KAS shares kiro's answer structurally: it isnot an independent harness —
kas_transport.build_kas_argvreturns[kiro_bin, "acp", "--agent-engine", "v3", …]— so there is no separate resolver todisagree with. Served owner-only at
GET /api/acp-backends, offloaded viaasyncio.to_threadbecause the resolvers shell out, with a short TTL cache because thedashboard polls.
AgentBackendTabdisables on!selectable || installed === 'missing' || restart_requiredand fails open everywhere else:
unknown, the query in flight, a 403 (non-owner) ora 404 (older gateway) all leave the option exactly as it was, since the PATCH allowlist
is the real gate and an optimistic disable costs a user a control they were entitled to.
2.
agent_backendgovernance scope → whether the deployment may select it.Semantics is the decision #6622 was blocked on: additive over a floor.
{"agent_backend": {"mode": "allow", "allow": ["claude"]}}means also allow claude.GOVERNANCE_FLOOR_BACKEND(kiro) is never submitted to the scope, so no rule canremove it — the exclusive reading can empty the set, and an install with no startable
harness cannot be repaired from the dashboard, since the trust-root policy is the one
file the dashboard may not write. Members are policy ids (
kiro/kas/claude)because the kiro backend is the empty string internally, which no identifier matcher
can carry.
Enforced by recomputing the registry, not by a check anywhere downstream —
narrow_selectable_backends()frombootstrap_contextat gateway start, via a newapply_selectable_denials()that assignsbaseline - denied. That position is forcedby three harness-parity invariants, and the obvious alternatives each break one:
resolve_selected_backend(the one gate)KiroCrewConfig.load(); resolving a ceiling loads config and re-enters that loadcreate_provider_factoryNarrowing at boot satisfies all three: the context is installed (no re-entrant load),
the registry stays the single source, and no call site changes. The existing single gate
resolve_selected_backendreadsselectable_backends()per call, so it degrades apolicy-denied persisted value to the floor with a logged reason for free — which also
closes the "
config.jsonwritten before the policy arrived" case with no new code.The scope is asked with
HOST_SESSION_KEY, not an empty key: an empty key classifiesto surface
unknownand matches no profile, so a host-bound profile would be silentlyignored. Every decision is SEL-audited in both directions, which is cheap because this
runs once per gateway start.
What the card's last line means. The set of harnesses offered is decided when the
gateway starts, and editing the policy takes effect on the next start. The panel also
cannot detect a policy file edited by hand and not yet distributed — nothing in the UI
can, because seeing it would mean reading the trust-root policy on a request path,
which H3/H13 forbid. Stating the semantics is the honest substitute for a freshness
signal the panel is not allowed to have.
When a policy change binds, and why that is a decision. The set is recomputed at
gateway start only.
apply_ceilingdoes replacecurrent_context().governancemid-process —
policy_distribution's poll thread reaches it throughrefresh_now()—and every other scope picks that up on its next decision. This one deliberately does
not.
An earlier revision of this branch DID re-derive the registry there. Re-deriving binds
the new ceiling for backend SELECTION and nothing else: sessions already running the
denied harness, and providers already in the warm pool, keep going, because retiring
live work is a session-lifecycle capability that does not exist yet. The
operator-visible result is the worst of both — the option vanishes from the panel while
the harness is still in use, which reads as "it stopped being used". So the promise is
the narrow one that can be kept, and it is stated rather than implied: the panel says
the set is decided at gateway start and that editing policy takes effect on the next
start, and
test_apply_ceiling_does_not_renarrow_the_registrypins the omission so itcannot be mistaken for an oversight and quietly closed without the retirement half.
The recompute primitive is still non-destructive:
apply_selectable_denials(denied)ASSIGNSbaseline - deniedrather than mutating,keeping the edition-registered baseline apart from the effective set. That is
what Design Review asked for, it makes the boot pass idempotent and order-independent,
and it means adding the runtime call site once retirement exists is a one-line change
instead of a redesign.
narrow_selectable_backends()iterates the baseline, never thealready-narrowed set, because asking the narrowed set what to narrow is how a one-way
ratchet gets built by accident.
Probe vs spawn cache.
_claude_acp_argv_cacheis a process-lifetime cache behind an_UNRESOLVEDsentinel, never invalidated, so a fresh probe could reportinstalledwhile every spawn reused a cached
Noneand died withAcpError— the exact"told you it was ready, then failed" trap the probe exists to prevent. The probe
therefore consults that cache rather than bypassing or invalidating it (invalidating
would make a dashboard GET mutate a spawn-path global) and reports
installed+restart_required, which disables the option and says so. The oppositeskew (cached positive, adapter since removed) needs no special case: the fresh resolve
reports
missing, which is what the spawn will effectively be.Where the probe lives, and why it is not a free choice. The probe must reach the
spawn's own argv resolvers, and
scripts/check_agent_sdk_boundary.pyforbidsapplication code from importing
kiro_crew.acp— a baselined gate whose own docstringsays never raise a baseline line to make it green. So the probe sits inside the
boundary, in the shape the RFC prescribes rather than one I picked:
docs/request-for-change/rfc-crew-agent-sdk-boundary.md§5.5 puts argv resolutioninside the driver and names
agent_sdk/drivers/acpas the only module permitted toimport
kiro_crew.acp, and §6.1 bucket 8 ("a second native binary the adapter's ownSDK will not find") is literally the
claude-agent-acp+claudepair being probed.agent_sdk/drivers/acp.pytherefore owns the resolver calls and returns plain data;agent_sdk/backend_install.pyowns the states, components, TTL cache and per-backendregistry and imports no ACP;
agent_sdk/__init__.pyexports the capability. This makesthe install probe the SDK's first tenant — the package was declared empty on purpose,
and the boundary it names is what decided the placement.
Relationship to #6307. That open PR contains a
_probe_installedof similar shape.This implements independently on merged
main— #6307 restructuresacp_backends.pyinto
acp/backends.pyand holds its admitted set at kiro+kas behind documented releaseblockers, so depending on it would block this indefinitely. The probe's shape (three
states, spawn-resolver parity, owner-only endpoint) deliberately mirrors it so a later
merge collapses cleanly.
Tests
test/test_agent_backend_governance.py(28) — the scope row and thekirowirespelling; additive-allow (an
allowlist that would be exclusive under the otherreading); an empty
allowstill leaving the floor; deny removing a non-floor backendand being inert on the floor; fail-closed leaving exactly the floor; narrowing never
raising into boot. The registry recompute has its own class: denials are assigned
not subtracted (so a looser second pass RESTORES what a stricter first pass removed),
the recompute is idempotent, the floor is force-kept even when a caller names it,
registering writes the baseline too, and
registered_backends()stays thepre-narrowing answer. When a policy change binds has three: a ceiling installed
mid-process does NOT re-derive the set (the contract, with the comment naming what
else must land before that changes), the ceiling itself is still installed for every
scope that reads it per decision, and the same denied policy taken at boot does bind.
Plus the
enforcement position: the dashboard allowlist deriving only from the registry, the
single gate degrading a denied persisted value, and two source-shape assertions — that
create_provider_factorycontains no governance call (H13) and thatacp_backendsneither imports
kiro_crew.platformnor callscurrent_context(H3). The latter isparsed with
ast, not grepped: that module's docstring discussescurrent_context()at length to explain why it must not reach it, so a substring assertion would fail on
the documentation while a real call added inside a function would pass once the prose
was reworded.
surface: hostdenyingkasmust actually remove it. With an empty session key that test fails.
test/test_agent_sdk_backend_install.py— installed/missing/unknown per backend; araising resolver yielding
unknownand nevermissing; the specific absent claudecomponent; kas tracking kiro; TTL cache used and clearable; owner-gate refusal and the
payload shape. Six cover the spawn-cache divergence, including three false-positive
guards (the
_UNRESOLVEDsentinel and a malformed cache value are not negatives — arestart prompt shown to every freshly started gateway would make the flag ignorable)
and one pinning that the probe does not mutate the cache.
website/src/test/AgentBackendTab.test.tsx(25) — missing disables and rendersthe command;
unknownstays ENABLED and does not read as missing; a 403 and anin-flight query behave exactly as before (no disabled flash); not-selectable wins over
any install verdict and over the restart line; the restart case disables and explains;
and a selectable+installed backend reads exactly as it did before. One test builds its
client with the app's REAL
staleTime: Infinityrather than the convenient default,because that is the only shape in which the refetch gap is visible at all.
restart_required=False, swappingHOST_SESSION_KEYfor"", and dropping theprobe query's
refetchIntervaleach redden their own tests and nothing else.Manual verification
kiroandkas→installed;claude→missing(["claude-agent-acp"]). That host has theclaudeCLI(
_resolve_claude_code_executableresolves it) and not the adapter — the exacthalf-install the two-component split exists for.
CLAUDE_AGENT_ACP_BIN(step 1 of_resolve_claude_acp_bin, which the spawn honoursidentically) at a real file turns the verdict to
installedwithmissing_componentsempty — without touching the machine's global npm environment.website/scripts/capture-agent-backend-probe.mjs,committed), serving the real built bundle with fixture APIs. The first scene is this
host's actual answer; the others vary one field, because
installed: "unknown"onlyhappens when the probe itself raises and a public build never reports claude as
selectable — so shooting only local truth would leave the lines this PR exists for
undocumented.
Screenshots / video
The two states this change exists to distinguish. Claude Code is disabled in both, for
different reasons, and the wording says which:
The check-failed state, and this host's real answer
unknownleaves the option enabled and must not read as missing — the check failed,which is not evidence the binary is absent:
This host's actual answer on a public build — claude is not selectable at all, so the
build reason outranks the machine reason:
Related Issues
no linked issue: this closes nothing on merge, deliberately. It settles the semantics
question in #6622 (the
agent_backendscope and the allow-is-additive ruling) andenforces the scope at the registry, but that issue also asks for enforcement at every
chokepoint, so it stays open.
Related: #6593
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)