Summary
There are two auth classifiers in the ACP layer, and they have drifted apart by call path.
- The JSON-RPC error-frame path reaches the full vocabulary:
_is_session_expired (401/403 status, expiry wording, rejected bearer token) plus _RE_AUTH (the named service exceptions).
- The spawn /
session/new path has its own detector, AcpRuntime.saw_not_logged_in(), which matches a single literal banner: not logged in.
Real expired-token output uses none of that banner's wording, so the spawn path discards an auth signal this codebase can already read, and the operator is shown Request session/new timed out after 90s.
Where
| Anchor |
Role |
src/kiro_crew/acp/runtime.py |
saw_not_logged_in — matched only _NOT_LOGGED_IN_RE |
src/kiro_crew/acp/client.py |
_NOT_LOGGED_IN_RE = re.compile(r"not\s+logged\s+in") — the whole spawn-path vocabulary |
src/kiro_crew/acp/client.py |
_is_session_expired — the strong classifier |
src/kiro_crew/acp/client.py |
its only two call sites, both error-frame-scoped |
src/kiro_crew/providers/acp.py |
three AcpAuthRequired gates, all on the weak detector |
src/kiro_crew/acp/session_provider.py |
two more, same weak gate |
src/kiro_crew/acp/runtime.py |
emits Request session/new timed out after 90s |
Reproduction
The stderr below is real kiro-cli output captured during an actual expired IAM Identity Center bearer token (kiro-cli 2.19.1). The banner the weak detector needs appears nowhere in it.
import asyncio
from kiro_crew.acp.runtime import AcpRuntime
from kiro_crew.acp.client import _is_session_expired
STDERR = [
'GetProfile failed: AccessDeniedException: "Invalid token" (HTTP 400)',
"Failed to fetch models from API: service error, using fallback list",
"Access denied: The bearer token included in the request is invalid.",
]
class _Stderr:
def __init__(self, lines): self._lines = [f"{l}\n".encode() for l in lines]
async def readline(self): return self._lines.pop(0) if self._lines else b""
class _Proc:
def __init__(self, lines): self.stderr = _Stderr(lines)
async def main():
rt = AcpRuntime.__new__(AcpRuntime)
rt._stderr_lines = []
rt._process = _Proc(STDERR)
await rt._drain_stderr() # the real capture path
print("spawn path saw_not_logged_in() =", rt.saw_not_logged_in())
print("frame path _is_session_expired() =", _is_session_expired("\n".join(STDERR)))
asyncio.run(main())
Observed:
spawn path saw_not_logged_in() = False
frame path _is_session_expired() = True
The two classifiers disagree on the identical text. The signal is lost, not absent — the same stderr, arriving as an error frame, already produces a correct sign-in message.
Why this is not just a message
AcpAuthRequired is documented as non-retryable, and client.py already treats _RE_AUTH as terminal specifically so the retry ladder is skipped. Failing to reach it means an expired credential burns the full retry ladder and the whole 90-second budget per attempt, on a condition no retry can fix. The user-visible timeout also collides with two unrelated causes that produce the same string, so the wrong remediation gets applied.
Secondary hazard: the observation can be evicted before anyone asks
_stderr_lines is trimmed to the last 20 lines, and nothing asks about auth until a request has already timed out. On a chatty startup the auth line is gone by then, so a detector that re-scans the buffer answers "no auth problem" — indistinguishable from a real negative. Any fix needs to latch the observation at the point lines arrive, not re-scan a ring buffer.
Reproduced by putting the auth line first and following it with 40 lines of noise: the buffer holds 20 entries, none containing the auth line, and a buffer-scanning detector reports False.
Related prior work
A fix is up shortly; I have one prepared.
Summary
There are two auth classifiers in the ACP layer, and they have drifted apart by call path.
_is_session_expired(401/403 status, expiry wording, rejected bearer token) plus_RE_AUTH(the named service exceptions).session/newpath has its own detector,AcpRuntime.saw_not_logged_in(), which matches a single literal banner:not logged in.Real expired-token output uses none of that banner's wording, so the spawn path discards an auth signal this codebase can already read, and the operator is shown
Request session/new timed out after 90s.Where
src/kiro_crew/acp/runtime.pysaw_not_logged_in— matched only_NOT_LOGGED_IN_REsrc/kiro_crew/acp/client.py_NOT_LOGGED_IN_RE = re.compile(r"not\s+logged\s+in")— the whole spawn-path vocabularysrc/kiro_crew/acp/client.py_is_session_expired— the strong classifiersrc/kiro_crew/acp/client.pysrc/kiro_crew/providers/acp.pyAcpAuthRequiredgates, all on the weak detectorsrc/kiro_crew/acp/session_provider.pysrc/kiro_crew/acp/runtime.pyRequest session/new timed out after 90sReproduction
The stderr below is real kiro-cli output captured during an actual expired IAM Identity Center bearer token (kiro-cli 2.19.1). The banner the weak detector needs appears nowhere in it.
Observed:
The two classifiers disagree on the identical text. The signal is lost, not absent — the same stderr, arriving as an error frame, already produces a correct sign-in message.
Why this is not just a message
AcpAuthRequiredis documented as non-retryable, andclient.pyalready treats_RE_AUTHas terminal specifically so the retry ladder is skipped. Failing to reach it means an expired credential burns the full retry ladder and the whole 90-second budget per attempt, on a condition no retry can fix. The user-visible timeout also collides with two unrelated causes that produce the same string, so the wrong remediation gets applied.Secondary hazard: the observation can be evicted before anyone asks
_stderr_linesis trimmed to the last 20 lines, and nothing asks about auth until a request has already timed out. On a chatty startup the auth line is gone by then, so a detector that re-scans the buffer answers "no auth problem" — indistinguishable from a real negative. Any fix needs to latch the observation at the point lines arrive, not re-scan a ring buffer.Reproduced by putting the auth line first and following it with 40 lines of noise: the buffer holds 20 entries, none containing the auth line, and a buffer-scanning detector reports
False.Related prior work
_is_session_expired. This report is the same family on a different call path, where the classifier is never consulted at all and the failure shape is a timeout rather than a 5xx.session/newtimeout discarding MCP reports. That is why the timeout is enriched toward MCP debugging, which is also why an auth failure currently steers the operator toward the wrong subsystem.A fix is up shortly; I have one prepared.