From 03b156331d57af6a12ab3dca4a971416f767b00a Mon Sep 17 00:00:00 2001 From: Brad Hallett <53977268+bradhallett@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:23:31 -0400 Subject: [PATCH 1/2] Treat auth-required probe failures as expected online RPC failures The startup provider.list_models probe for ACP providers lands before the agent's auth handshake settles, so the daemon warned "online host RPC failed" with "ACP agent is not authenticated." on every daemon start. The failure is a user state, not a daemon bug: its errorCode still reaches the server, which logs and surfaces it (model load error, turn error). Add auth_required to the expected-failure codes so only the duplicate daemon-side warn is silenced. --- apps/host-daemon/src/command-dispatch-support.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/host-daemon/src/command-dispatch-support.ts b/apps/host-daemon/src/command-dispatch-support.ts index 1e140dba64..5a62cae127 100644 --- a/apps/host-daemon/src/command-dispatch-support.ts +++ b/apps/host-daemon/src/command-dispatch-support.ts @@ -133,6 +133,12 @@ export function isExpectedCommandDispatchError( const EXPECTED_ONLINE_RPC_FAILURE_CODES = new Set([ "file_too_large", "provision_cancelled", + // An unauthenticated agent is a user-state outcome, not a daemon bug: the + // errorCode travels to the server, which logs and surfaces it (model load + // error, turn error). The daemon-side "online host RPC failed" warn for it + // is duplicate noise — the startup provider.list_models probe races the + // ACP agent's auth handshake and hits this on every daemon start. + "auth_required", ]); export function isExpectedOnlineRpcFailureError(error: unknown): boolean { From e1fb36ada548a53f5e4c5991ed794a9fd9a05a25 Mon Sep 17 00:00:00 2001 From: Brad Hallett <53977268+bradhallett@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:36:32 -0400 Subject: [PATCH 2/2] Test the auth_required allow-list against the typed runtime error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regression test from the original commit passed bare Errors and leaned on the ACP_AUTH_REQUIRED_PATTERN message regex, which e42a4ef48 (#2325) deleted: on main the router sees a typed AgentRuntimeRecoveryError carrying code "auth_required". Rebuild the test on that shape at both layers — the classifier unit and the router path (typed errorCode still returned, debug accounting kept, no warn) — with a contrast case proving unclassified failures still warn. Also correct the allow-list comment: the probe execs the agent's list-models CLI with no ACP handshake, and the entry covers every online RPC that can surface the code. --- .../src/command-dispatch-support.test.ts | 42 ++++++++ .../src/command-dispatch-support.ts | 11 ++- .../test/command/command-router.test.ts | 95 ++++++++++++++++++- 3 files changed, 142 insertions(+), 6 deletions(-) diff --git a/apps/host-daemon/src/command-dispatch-support.test.ts b/apps/host-daemon/src/command-dispatch-support.test.ts index 95e785fee9..40b66d1a90 100644 --- a/apps/host-daemon/src/command-dispatch-support.test.ts +++ b/apps/host-daemon/src/command-dispatch-support.test.ts @@ -1,9 +1,33 @@ +import { AgentRuntimeRecoveryError } from "@bb/agent-runtime"; import { describe, expect, it } from "vitest"; import { CommandDispatchError, isExpectedOnlineRpcFailureError, } from "./command-dispatch-support.js"; +const ACP_MODEL_LIST_AUTH_MESSAGE = "ACP agent is not authenticated."; + +/** + * The error `provider.list_models` surfaces when an ACP bridge rejects the + * model probe with a typed `authRequired` recovery hint: the agent runtime + * turns that into an `AgentRuntimeRecoveryError` whose string `code` the + * daemon's `getErrorCode` reads. Constructed exactly as the runtime does, so + * this test fails if the classifier ever returns to message-shape matching. + */ +function createAcpAuthRequiredError(): AgentRuntimeRecoveryError { + return new AgentRuntimeRecoveryError({ + code: "auth_required", + message: ACP_MODEL_LIST_AUTH_MESSAGE, + recovery: { + kind: "authRequired", + message: ACP_MODEL_LIST_AUTH_MESSAGE, + providerId: "acp-cursor", + retryable: false, + }, + cause: new Error("Authentication required."), + }); +} + describe("command dispatch support", () => { it("classifies oversized file reads as expected RPC failures", () => { expect( @@ -12,4 +36,22 @@ describe("command dispatch support", () => { ), ).toBe(true); }); + + it("classifies typed auth_required recovery failures as expected RPC failures", () => { + expect(isExpectedOnlineRpcFailureError(createAcpAuthRequiredError())).toBe( + true, + ); + }); + + it("keeps unclassified failures unexpected", () => { + expect(isExpectedOnlineRpcFailureError(new Error("boom"))).toBe(false); + expect( + isExpectedOnlineRpcFailureError( + new CommandDispatchError( + "provider_bridge_unavailable", + "No plugin host artifact fetcher configured", + ), + ), + ).toBe(false); + }); }); diff --git a/apps/host-daemon/src/command-dispatch-support.ts b/apps/host-daemon/src/command-dispatch-support.ts index 5a62cae127..effc13702d 100644 --- a/apps/host-daemon/src/command-dispatch-support.ts +++ b/apps/host-daemon/src/command-dispatch-support.ts @@ -133,11 +133,12 @@ export function isExpectedCommandDispatchError( const EXPECTED_ONLINE_RPC_FAILURE_CODES = new Set([ "file_too_large", "provision_cancelled", - // An unauthenticated agent is a user-state outcome, not a daemon bug: the - // errorCode travels to the server, which logs and surfaces it (model load - // error, turn error). The daemon-side "online host RPC failed" warn for it - // is duplicate noise — the startup provider.list_models probe races the - // ACP agent's auth handshake and hits this on every daemon start. + // An unauthenticated agent is a user-state outcome, not a daemon bug. The + // typed code still reaches the server, which logs and surfaces it (model + // load error, turn error), so the daemon-side "online host RPC failed" + // warn would only duplicate it with two nested stacks. Scoped to the + // auth_required code, so it covers every online RPC that can surface it, + // not just the provider.list_models probe. "auth_required", ]); diff --git a/apps/host-daemon/test/command/command-router.test.ts b/apps/host-daemon/test/command/command-router.test.ts index 811d1303d2..fc643e4844 100644 --- a/apps/host-daemon/test/command/command-router.test.ts +++ b/apps/host-daemon/test/command/command-router.test.ts @@ -3,6 +3,7 @@ import type { HostDaemonOnlineRpcRequestMessage, HostDaemonOnlineRpcResponseMessage, } from "@bb/host-daemon-contract"; +import { AgentRuntimeRecoveryError } from "@bb/agent-runtime"; import { WorkspaceError } from "@bb/host-workspace"; import { encodeClientTurnRequestIdNumber, @@ -45,7 +46,7 @@ type ThreadStartCommand = Extract; type TurnSubmitCommand = Extract; interface RunRouterCommandArgs { - command: HostDaemonCommand; + command: HostDaemonOnlineRpcRequestMessage["command"]; requestId: string; router: CommandRouter; } @@ -61,6 +62,7 @@ interface CreateTurnSubmitCommandArgs { interface CreateRouterArgs { logger?: CommandRouterOptions["logger"]; + listModels?: CommandRouterOptions["listModels"]; resolveInteractiveRequest?: CommandRouterOptions["resolveInteractiveRequest"]; runtimeManager?: RuntimeManager; } @@ -85,6 +87,7 @@ function createRouter( fetchProjectAttachment: unexpectedProjectAttachmentFetch, fetchPluginHostArtifact: fetchDispatchTestArtifact, ...unexpectedProviderMaintenance, + ...(args.listModels === undefined ? {} : { listModels: args.listModels }), logger: { debug: () => undefined, warn: () => undefined, @@ -255,6 +258,96 @@ describe("CommandRouter", () => { expect(logger.warn).not.toHaveBeenCalled(); }); + it("does not warn for a typed auth_required model-list failure", async () => { + const harness = createHarness({ workspacePath: "/tmp/env-router" }); + const logger = { + debug: vi.fn(), + warn: vi.fn(), + }; + const authMessage = "ACP agent is not authenticated."; + const router = createRouter(harness, { + logger, + listModels: async () => { + // What `@bb/agent-runtime` throws when an ACP bridge rejects the + // model probe with a typed `authRequired` recovery hint. + throw new AgentRuntimeRecoveryError({ + code: "auth_required", + message: authMessage, + recovery: { + kind: "authRequired", + message: authMessage, + providerId: "acp-cursor", + retryable: false, + }, + cause: new Error("Authentication required."), + }); + }, + }); + + const response = await runRouterCommand({ + command: { + type: "provider.list_models", + providerId: "acp-cursor", + bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + }, + requestId: "auth-required-model-list", + router, + }); + + // The typed outcome still reaches the server intact. + expect(response).toMatchObject({ + ok: false, + commandType: "provider.list_models", + errorCode: "auth_required", + errorMessage: authMessage, + }); + // Only the duplicate warn is silenced; the debug accounting fires. + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + expect.objectContaining({ + commandType: "provider.list_models", + errorCode: "auth_required", + ok: false, + }), + "Online host RPC", + ); + }); + + it("still warns for an unclassified model-list failure", async () => { + const harness = createHarness({ workspacePath: "/tmp/env-router" }); + const logger = { + debug: vi.fn(), + warn: vi.fn(), + }; + const router = createRouter(harness, { + logger, + listModels: async () => { + throw new Error("model list command crashed"); + }, + }); + + const response = await runRouterCommand({ + command: { + type: "provider.list_models", + providerId: "acp-cursor", + bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH, + }, + requestId: "unclassified-model-list", + router, + }); + + expect(response).toMatchObject({ + ok: false, + errorCode: "command_failed", + errorMessage: "model list command crashed", + }); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + { type: "provider.list_models", err: expect.any(Error) }, + "online host RPC failed", + ); + }); + it("orders turn.submit after an in-flight environment destroy", async () => { const harness = createHarness({ workspacePath: "/tmp/env-router" }); await harness.manager.ensureEnvironment({