Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/kiro_crew/dashboard/handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ def sel():
from kiro_crew.dashboard.handlers.kiro_prerequisite import ( # noqa: E402, F401
api_kiro_prerequisite_repair_specs,
api_kiro_prerequisite_status,
api_kiro_prerequisite_update_cli,
)
from kiro_crew.dashboard.handlers.mcp import ( # noqa: E402, F401
_bg_mcp_probe,
Expand Down
30 changes: 30 additions & 0 deletions src/kiro_crew/dashboard/handlers/kiro_prerequisite.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from kiro_crew.kiro_prerequisite import (
KIRO_CLI_LOGIN_COMMAND,
KIRO_CLI_SSO_LOGIN_COMMAND,
KIRO_CLI_UPDATE_COMMAND,
OFFICIAL_INSTALL_DOCS_URL,
KiroPrerequisiteService,
PrerequisiteStatus,
Expand Down Expand Up @@ -185,6 +186,14 @@ async def api_kiro_prerequisite_status(request: web.Request) -> web.Response:
# withholding them costs them nothing.
"missing_agent_specs": [],
"agent_spec_repair_error": "",
# Same shape-stability reason: a non-owner cannot act on an outdated
# CLI (the update is an owner-gated POST), so acp_supported is
# reported healthy, update_command carries the standard command
# string, and cli_update_error is blank. Present rather than omitted
# so a consumer reading these keys never branches on ``undefined``.
"acp_supported": True,
"update_command": KIRO_CLI_UPDATE_COMMAND,
"cli_update_error": "",
# See _LEGACY_IDLE_OPERATION: a pre-upgrade tab crashes without this,
# and the payload shape must not vary by caller either way.
"operation": legacy_idle_operation(),
Expand All @@ -211,3 +220,24 @@ async def api_kiro_prerequisite_repair_specs(request: web.Request) -> web.Respon
return denied
snapshot = await _service(request).repair_agent_specs(_caller(request))
return web.json_response({**snapshot, "setup_allowed": True})


async def api_kiro_prerequisite_update_cli(request: web.Request) -> web.Response:
"""POST /api/kiro-prerequisite/update-cli — run the CLI's in-place self-update.

A POST for the same reason as repair-specs: the spawn must be origin-checked
and audited, and both barriers are method-scoped. Offered only to remedy a
too-old kiro-cli that lacks the ``acp`` subcommand Kiro Crew launches every
session through. Unlike the install/sign-in steps (which Kiro Crew only
names), this one runs ``kiro-cli update`` FOR the owner — the CLI's own
self-update, the same command the auto-update path already runs.

Returns 200 with the post-update snapshot (``cli_update_error`` empty on
success): it runs to completion within the request, like repair-specs.
"""

denied = await _dashboard_owner_only(request)
if denied is not None:
return denied
snapshot = await _service(request).update_cli(_caller(request))
return web.json_response({**snapshot, "setup_allowed": True})
6 changes: 6 additions & 0 deletions src/kiro_crew/dashboard/routes/realtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ def register(app: web.Application) -> None:
"/api/kiro-prerequisite/repair-specs",
handlers.api_kiro_prerequisite_repair_specs,
)
# POST for the same reason as repair-specs above: it spawns `kiro-cli update`
# on the host, so it must be origin-checked and audited.
app.router.add_post(
"/api/kiro-prerequisite/update-cli",
handlers.api_kiro_prerequisite_update_cli,
)

# KAS-mode interactive login (no kiro-cli): status is a read; the device-code
# begin/poll/logout mutations are POSTs so they stay origin-checked and audited.
Expand Down
234 changes: 232 additions & 2 deletions src/kiro_crew/kiro_prerequisite.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,25 @@
# visual peer of organization SSO, so a user on an SSO plan can sign in to the
# wrong tier and only discover it when models are missing.
KIRO_CLI_SSO_LOGIN_COMMAND = "kiro-cli login --use-device-flow --license pro"
# The command that updates the CLI in place. Unlike the install/sign-in steps
# above, which Kiro Crew only ever NAMES for the user to run, this one IS run on
# the user's behalf (see :meth:`KiroPrerequisiteService.update_cli`): it is the
# CLI's own self-update subcommand, the same one the auto-update path already
# invokes, so executing it introduces no new privileged surface — it is not a
# remote installer script, just the installed binary updating itself. Offered
# when the installed CLI is too old to expose the ``acp`` subcommand Kiro Crew
# drives every session through. A CODE CONSTANT, never a catalog value: a
# translated command cannot be typed or executed.
KIRO_CLI_UPDATE_COMMAND = "kiro-cli update"
# The subcommand Kiro Crew launches every ACP session through (see
# ``acp.client.KIRO_CLI_SUBCMD`` / ``acp.runtime.KIRO_CLI_SUBCMD``). Probed by
# name so the readiness check learns of a rename the same way a spawn would fail.
_ACP_SUBCOMMAND = "acp"
# How long the self-update is allowed to run before the probe gives up on it.
# ``kiro-cli update`` downloads and swaps a binary, so it is far slower than the
# read-only probes; sized to match the auto-update path's own 120s budget in
# ``slack/gateway.py`` rather than the 10s probe ceiling.
_UPDATE_TIMEOUT_SECS = 120
# Compatibility shim, not live state. Nothing performs an operation any more, but a
# dashboard loaded BEFORE this change reads ``status.operation.status``
# unconditionally in its refetch-interval callback — the optional chain there
Expand Down Expand Up @@ -132,6 +151,27 @@ def legacy_idle_operation() -> dict[str, str]:
#: treated as acceptance: this module's rule is to report nothing rather than
#: block a working install behind a repair card it cannot clear.
_SPEC_REJECTION_MARKER = "is invalid"
# Substrings that identify an "unknown subcommand" rejection in the captured
# output of an ``acp --help`` probe. A kiro-cli that HAS the ``acp`` subcommand
# exits 0 and prints its help; one too old to have it exits nonzero and (being a
# clap CLI) prints one of these. Matched case-insensitively.
#
# This is deliberately a POSITIVE match on the rejection wording rather than
# "the probe printed something" or "exit was nonzero", for the same reason the
# spec probe matches its rejection marker: a probe that failed for an unrelated
# reason (a sandbox denial, a hung binary) also produces a nonzero exit and
# captured text, and reporting THAT as "your CLI is too old" would push the user
# to run an update that cannot fix it. Anything unrecognized is therefore treated
# as supported — the module's rule is to report nothing rather than block a
# working install behind a card it cannot clear (see :meth:`_probe_acp_support`).
_ACP_UNSUPPORTED_MARKERS = (
"unrecognized subcommand",
"unknown subcommand",
"unrecognized command",
"unknown command",
"invalid subcommand",
"no such subcommand",
)
# The identity probe's own budget, deliberately separate from
# _PROBE_TIMEOUT_SECS. ``whoami`` is not a local read: when the cached token has
# expired, Kiro CLI refreshes an OIDC token against the organization's IdP —
Expand Down Expand Up @@ -384,6 +424,26 @@ class PrerequisiteStatus:
# tier is an explicit choice rather than whichever option the sign-in page
# happens to make prominent.
sso_login_command: str = KIRO_CLI_SSO_LOGIN_COMMAND
# Whether the installed CLI exposes the ``acp`` subcommand every Kiro Crew
# session is launched through. Defaults True so nothing regresses when the
# probe cannot answer (a sandbox refusal, a timeout): those are reported by
# their own fields, and asserting "too old" off a probe that never ran would
# push the user to update a CLI that is fine. Only a CLEAN "unknown
# subcommand" verdict from ``acp --help`` sets it False — at which point the
# CLI runs and is signed in, but cannot start a single session, so it narrows
# ``ready`` exactly like a rejected spec. The remedy is an UPDATE, not a
# reinstall: the binary is present and only out of date.
acp_supported: bool = True
# What the user's CLI is updated with when ``acp_supported`` is False. Unlike
# ``login_command`` (which Kiro Crew only names), this one is also run FOR the
# user by ``update_cli`` — it is the CLI's own in-place self-update. Served in
# the payload so the UI has one source of truth for the string.
update_command: str = KIRO_CLI_UPDATE_COMMAND
# Exception / failure text from an ``update_cli`` attempt. Empty when no
# update was attempted or it succeeded. Shown verbatim, untranslated: it
# names why the self-update did not complete, which is what a support
# conversation needs.
cli_update_error: str = ""
# A Kiro CLI binary that is present and executable but could not be VERIFIED
# (verification runs the binary inside the sandbox) is a categorically
# different condition from a missing binary, and a failed sandbox build
Expand Down Expand Up @@ -2700,21 +2760,27 @@ async def _probe(self, *, force: bool = False) -> PrerequisiteStatus:
# answer would not be actionable until sign-in is fixed anyway.
rejected: list[str] = []
rejection_detail = ""
acp_supported = True
if whoami.ok:
rejected, rejection_detail = await self._probe_spec_acceptance(
self._viable_binary
)
acp_supported = await self._probe_acp_support(self._viable_binary)
self._status = PrerequisiteStatus(
platform=_platform_label(self._platform),
installed=True,
authenticated=whoami.ok,
# A required spec the CLI refuses fails every turn, exactly like
# one that is absent, so it narrows readiness the same way.
ready=whoami.ok and not rejected,
# one that is absent, so it narrows readiness the same way. A CLI
# without the ``acp`` subcommand cannot start ANY session, so it
# narrows readiness too — but its remedy is an update, not a spec
# rewrite, so it is tracked separately from ``repair_required``.
ready=whoami.ok and not rejected and acp_supported,
repair_required=bool(rejected),
initial_setup_complete=self._initial_setup_complete,
rejected_agent_specs=rejected,
agent_spec_rejection_detail=rejection_detail,
acp_supported=acp_supported,
)
self._stamp_probe(probe_identity)
return self._status
Expand Down Expand Up @@ -2781,6 +2847,170 @@ def _present() -> list[tuple[str, Path]]:
detail = _sanitize_detail((result.output or "").strip())
return rejected, detail

async def _probe_acp_support(self, executable: str) -> bool:
"""Ask kiro-cli whether it exposes the ``acp`` subcommand Kiro Crew uses.

Returns ``True`` when the subcommand is present OR when the probe could
not establish otherwise; ``False`` only on a CLEAN "unknown subcommand"
rejection.

Kiro Crew launches every session as ``kiro-cli acp ...`` (see
``acp.client`` / ``acp.runtime``). A CLI too old to have that subcommand
runs fine and signs in fine, then fails at session-create with an opaque
``process exited (rc=None)`` — the same class of silent, hard-to-place
failure the rest of this module exists to turn into an actionable card.
``acp --help`` is the read-only way to ask: a CLI that HAS the subcommand
exits 0 and prints its help, one that lacks it exits nonzero with an
"unknown subcommand" line (kiro-cli is a clap CLI).

The verdict is deliberately conservative in ONE direction. A timeout, a
sandbox refusal, or any nonzero exit whose text is not a recognized
rejection is treated as SUPPORTED, so a probe that merely failed to run
never blocks a working, up-to-date install behind an update card it does
not need. The cost is that a genuinely-too-old CLI whose rejection wording
is unrecognized would slip through here — but that install then fails at
session-create with the pre-existing error, i.e. no worse than before this
check existed, whereas a false positive would strand a healthy install.

A spawn, so it belongs to the probe's boot-and-explicit-action budget and
is gated on a successful ``whoami`` by the caller, matching the spec
acceptance probe.
"""

result = await self._audited_probe(
"probe_acp_support",
executable,
[_ACP_SUBCOMMAND, "--help"],
)
if result.ok:
return True
# A probe that could not even run (sandbox refusal, timeout, spawn error)
# is not evidence the subcommand is missing. Only a clean rejection counts.
if result.timed_out or result.sandbox_failure is not None:
return True
haystack = (result.output or "").lower()
return not any(marker in haystack for marker in _ACP_UNSUPPORTED_MARKERS)

async def update_cli(self, caller: str = "") -> dict[str, Any]:
"""Run the CLI's own in-place self-update, then return a fresh snapshot.

The Update button's action, behind an owner-gated POST so the spawn is
origin-checked and audited. This is the ONE place this module runs a Kiro
CLI subcommand that is not a read-only probe — justified because
``kiro-cli update`` is the CLI updating ITSELF in place (the same command
the auto-update path in ``slack/gateway.py`` already runs unattended), not
a remote installer script or a credential-writing flow, so it adds no new
privileged surface. It is offered only to remedy a too-old CLI that lacks
the ``acp`` subcommand.

Returns a snapshot with ``cli_update_error`` set — empty on success. The
error is returned rather than raised so the gate can render it in place,
the same contract as ``repair_agent_specs``. Runs to completion within the
request (bounded by :data:`_UPDATE_TIMEOUT_SECS`); a re-probe follows so a
successful update clears the card instead of leaving stale state up.
"""

del caller # the SEL record is written by the route's audit middleware
if self._assume_ready:
# A test / offline gateway asserts its own readiness and has no real
# CLI to update; running an update there is meaningless.
result = await self._agent_spec_overlay(self._snapshot_dict())
result["cli_update_error"] = ""
return result
# Resolve the binary the way the probe does, off-loop.
probe_environment, candidates = await asyncio.to_thread(
_probe_filesystem_state,
self._platform,
self._home,
self._environ,
)
executable = candidates[0] if candidates else ""
error = ""
if not executable:
error = "Kiro CLI could not be found to update."
else:
# The resolved binary is UNVERIFIED (candidates[0] is whatever sits
# first on PATH; an agent that can plant ~/.local/bin/kiro-cli would
# otherwise have it run against the real home). Run it under the
# strict sandbox — the same posture verification uses for an
# unverified candidate — so ~/.aws / ~/.ssh stay hidden even though
# the owner clicked Update. Network reach for the self-update comes
# from the proxy keys (which govern egress), NOT from the standard
# sandbox's real-home exposure; the identity credential is
# deliberately omitted because `update` fetches a binary, it does
# not authenticate.
update_environment = dict(probe_environment)
update_environment.update(
_allowlisted_env(self._environ, _IDENTITY_PROXY_ENV_KEYS)
)
await self._audit(
action="update_cli",
outcome="invoked",
caller="gateway-setup",
critical=True,
)
try:
update = await self._run(
executable,
["update"],
env=update_environment,
timeout_secs=_UPDATE_TIMEOUT_SECS,
sandbox_mode=_UNVERIFIED_SANDBOX_MODE,
# _hidden_probe_dirs (not _crew_hidden_dirs) so the unverified
# binary cannot read the Kiro identity token store either — the
# same isolation the read-only probe applies. `update` fetches a
# binary; it has no need for the on-disk identity credential.
extra_hidden_dirs=self._hidden_probe_dirs,
)
except asyncio.CancelledError:
await self._set_terminal_audit(
"update_cli", "failed", "gateway-setup", "cancelled"
)
raise
except Exception as exc:
logger.warning("kiro-cli update failed to run", exc_info=True)
await self._set_terminal_audit(
"update_cli", "failed", "gateway-setup", "update execution failed"
)
error = _sanitize_detail(f"{type(exc).__name__}: {exc}")
else:
if update.timed_out:
error = (
"kiro-cli update did not finish in time. Run "
f"`{KIRO_CLI_UPDATE_COMMAND}` on the gateway host directly."
)
elif not update.ok:
error = _sanitize_detail(
(update.output or "").strip()
or f"kiro-cli update exited with code {update.returncode}"
)
await self._set_terminal_audit(
"update_cli",
"completed" if update.ok and not error else "failed",
"gateway-setup",
(
""
if update.ok and not error
else "timeout" if update.timed_out else "nonzero exit"
),
)
if not error:
# Re-probe so a successful update flips ``acp_supported`` / ``ready``
# and the card clears. Explicit-action half of the probe budget.
try:
await self._probe(force=True)
except Exception: # noqa: BLE001 — stale state beats a 500
logger.warning("Re-probe after kiro-cli update failed", exc_info=True)
result = await self._agent_spec_overlay(self._snapshot_dict())
if not error and not result.get("acp_supported", True):
error = (
"The update ran but this kiro-cli still has no `acp` command. "
f"Update it manually with `{KIRO_CLI_UPDATE_COMMAND}` on the "
"gateway host, or reinstall from the setup page."
)
result["cli_update_error"] = error
return result

async def _audited_probe(
self,
action: str,
Expand Down
8 changes: 8 additions & 0 deletions src/kiro_crew/testing/fake_acp_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,14 @@ def main() -> None:
if args == ["whoami"]:
print(FAKE_IDENTITY)
return
if args == ["acp", "--help"]:
# The readiness probe runs this to confirm the `acp` subcommand exists
# (kiro_prerequisite._probe_acp_support). Answer success so the offline
# gateway clears the acp-support gate exactly as a real, current kiro-cli
# would; the real ACP session still drives the protocol over stdio when
# invoked as `acp` with no `--help`.
print("Usage: kiro-cli acp [OPTIONS]")
return
# Read on a daemon thread so _handle can poll _INBOX for a session/cancel
# that arrives WHILE a prompt is streaming. select() on stdin is not an
# option: the backend suite also runs on Windows.
Expand Down
Loading
Loading