From f153cf3a9258a960c346168eb464ec5028cdbe89 Mon Sep 17 00:00:00 2001 From: Nikolai Melekhin <90771587+nvtoroy@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:36:35 -0300 Subject: [PATCH] fix(agents): answer /btw side questions on gateway-hosted sessions (#118396) /btw resolved its prepared model runtime without allowGatewaySubagentBinding, but that flag became part of the prepared-runtime owner identity in #117587, and gateway startup publishes configured owners with it set. The request therefore matched no published owner, and standalone activation is refused while the gateway lifecycle owns configured identities - so every gateway-hosted /btw failed with 'prepared model runtime owner was not published' before any side-question work was dispatched. The embedded TUI path has no gateway lifecycle and kept working. Thread the flag from the gateway-originated caller instead of granting it in the shared helper: runBtwSideQuestion takes an explicit optional allowGatewaySubagentBinding param, the /btw command handler (auto-reply/reply/commands-btw.ts) opts in the way its sibling handlers commands-compact.ts and commands-system-prompt.ts already do at the same layer, and the embedded TUI call site stays unset so local side questions keep the local-runtime boundary and cannot borrow the active registry's subagent and node capabilities. Pins: the gateway request input and the helper's no-grant default in btw.test.ts, the TUI call-site absence in embedded-backend.test.ts, and the owner-keying constraint in prepared-model-runtime.owner-selection.test.ts. --- src/agents/btw.test.ts | 33 +++++++++++++++++++ src/agents/btw.ts | 9 +++++ ...ared-model-runtime.owner-selection.test.ts | 29 ++++++++++++++++ src/auto-reply/reply/commands-btw.ts | 1 + src/tui/embedded-backend.test.ts | 29 ++++++++++++++++ 5 files changed, 101 insertions(+) diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index 05e191099e5b..4bd833610e28 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -18,6 +18,7 @@ const parseSessionEntriesMock = vi.fn(); const migrateSessionEntriesMock = vi.fn(); const buildSessionContextMock = vi.fn(); const ensureOpenClawModelsJsonMock = vi.fn(); +const loadPreparedModelRuntimeSnapshotMock = vi.fn(); const discoverAuthStorageMock = vi.fn(); const discoverModelsMock = vi.fn(); const getModelRegistryRuntimeMock = vi.fn(); @@ -95,7 +96,9 @@ vi.mock("./prepared-model-runtime.js", () => ({ config: unknown; inheritedAuthDir?: string; workspaceDir?: string; + allowGatewaySubagentBinding?: boolean; }) => { + loadPreparedModelRuntimeSnapshotMock(params); const workspaceOptions = params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}; await ensureOpenClawModelsJsonMock(params.config, params.agentDir, workspaceOptions); const authStorage = discoverAuthStorageMock(params.agentDir, { @@ -575,6 +578,7 @@ describe("runBtwSideQuestion", () => { migrateSessionEntriesMock.mockReset(); buildSessionContextMock.mockReset(); ensureOpenClawModelsJsonMock.mockReset(); + loadPreparedModelRuntimeSnapshotMock.mockReset(); discoverAuthStorageMock.mockReset(); discoverModelsMock.mockReset(); getModelRegistryRuntimeMock.mockReset(); @@ -752,6 +756,35 @@ describe("runBtwSideQuestion", () => { }); }); + it("resolves the prepared runtime the way gateway-published owners are keyed", async () => { + // Gateway startup publishes configured owners with allowGatewaySubagentBinding + // (server-startup-post-attach.ts), and that flag is part of the owner key + // (prepared-model-runtime.owner.ts). A gateway-hosted BTW request that omits + // it matches no owner, and standalone activation is refused while the gateway + // lifecycle is active, so the side question fails with "owner was not published". + mockDoneAnswer("Final answer."); + + await runSideQuestion({ allowGatewaySubagentBinding: true }); + + expect(mockCall(loadPreparedModelRuntimeSnapshotMock)?.[0]).toMatchObject({ + agentDir: DEFAULT_AGENT_DIR, + allowGatewaySubagentBinding: true, + }); + }); + + it("keeps gateway subagent binding off for local callers such as the embedded TUI", async () => { + // The embedded TUI calls runBtwSideQuestion directly and must not borrow the + // active registry's subagent and node capabilities, so the flag stays unset + // unless a gateway-hosted caller opts in. + mockDoneAnswer("Final answer."); + + await runSideQuestion(); + + expect(mockCall(loadPreparedModelRuntimeSnapshotMock)?.[0]).not.toHaveProperty( + "allowGatewaySubagentBinding", + ); + }); + it("routes Codex-selected BTW questions through the harness side-question hook", async () => { const supports = vi.fn(supportsPreparedOpenAIAuth); const codexSideQuestionMock = registerCodexSideQuestionHarness({ diff --git a/src/agents/btw.ts b/src/agents/btw.ts index d52a9c9f37b4..886038dd4fea 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -591,6 +591,12 @@ type RunBtwSideQuestionParams = { sessionStore?: Record; sessionKey?: string; sandboxSessionKey?: string; + /** + * Set by gateway-hosted callers so the prepared runtime resolves the owner the + * gateway published. Left unset by local callers such as the embedded TUI, + * which must not borrow the active registry's subagent and node capabilities. + */ + allowGatewaySubagentBinding?: boolean; storePath?: string; resolvedThinkLevel?: ThinkLevel; resolvedReasoningLevel: ReasoningLevel; @@ -715,6 +721,9 @@ export async function runBtwSideQuestion( agentDir: params.agentDir, inheritedAuthDir: resolveDefaultAgentDir(params.cfg), workspaceDir: requestedWorkspaceDir, + // Gateway-published owners are keyed with this flag, so a gateway-hosted + // request that omits it can never match one. + ...(params.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true as const } : {}), }); const sessionAgentId = preparedModelRuntime.agentId ?? diff --git a/src/agents/prepared-model-runtime.owner-selection.test.ts b/src/agents/prepared-model-runtime.owner-selection.test.ts index 613720a89c0f..473b0172ff64 100644 --- a/src/agents/prepared-model-runtime.owner-selection.test.ts +++ b/src/agents/prepared-model-runtime.owner-selection.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { acquireAgentRunPreparedModelRuntime, getPreparedModelRuntimeSnapshot, + loadPreparedModelRuntimeSnapshot, prepareModelRuntimeSnapshot, publishPreparedModelRuntimeSnapshot, refreshPreparedModelRuntimeSnapshots, @@ -93,6 +94,34 @@ describe("prepared model runtime owner selection", () => { expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce(); }); + it("resolves a gateway-published owner only for requests carrying the binding flag", async () => { + // Gateway startup publishes configured owners with allowGatewaySubagentBinding, + // and that flag is part of the owner key. A request that omits it matches no + // owner, and standalone activation stays refused while the lifecycle is active. + mocks.configuredAgentIds = ["default"]; + const config = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + await refreshPreparedModelRuntimeSnapshots(config, { + allowGatewaySubagentBinding: true, + catalogMode: "static", + gatewayLifecycle: true, + defaultWorkspaceDir: "/tmp/gateway-launch-workspace", + }); + const request = { + config, + agentId: "default", + agentDir: "/tmp/unused-agent", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/gateway-launch-workspace", + }; + + await expect( + loadPreparedModelRuntimeSnapshot({ ...request, allowGatewaySubagentBinding: true }), + ).resolves.toMatchObject({ config }); + await expect(loadPreparedModelRuntimeSnapshot(request)).rejects.toThrow( + "prepared model runtime owner was not published", + ); + }); + it("reuses the configured owner for its prepared plugin harness selections", async () => { mocks.configuredAgentIds = ["default"]; const config = { agents: { defaults: { model: "openai/gpt-5.5" } } }; diff --git a/src/auto-reply/reply/commands-btw.ts b/src/auto-reply/reply/commands-btw.ts index 5c73b169570b..80882675f608 100644 --- a/src/auto-reply/reply/commands-btw.ts +++ b/src/auto-reply/reply/commands-btw.ts @@ -87,6 +87,7 @@ export const handleBtwCommand: CommandHandler = defineAuthorizedTextCommand( sessionEntry: targetSessionEntry, sessionStore: params.sessionStore, sessionKey: params.sessionKey, + allowGatewaySubagentBinding: true, ...(params.ctx.RuntimePolicySessionKey ? { sandboxSessionKey: params.ctx.RuntimePolicySessionKey } : {}), diff --git a/src/tui/embedded-backend.test.ts b/src/tui/embedded-backend.test.ts index 80787ecc443e..58504df5bac7 100644 --- a/src/tui/embedded-backend.test.ts +++ b/src/tui/embedded-backend.test.ts @@ -958,6 +958,35 @@ describe("EmbeddedTuiBackend", () => { }); }); + it("keeps gateway subagent binding off for embedded /btw side questions", async () => { + // The embedded TUI runs the side question locally, so it must not borrow the + // active registry's subagent and node capabilities. Only gateway-hosted + // callers opt into allowGatewaySubagentBinding. + loadSessionEntryMock.mockReturnValue({ + cfg: {}, + canonicalKey: "global", + storePath: "/tmp/openclaw-btw-sessions.json", + store: {}, + entry: { sessionId: "session-btw-local" }, + }); + runBtwSideQuestionMock.mockResolvedValueOnce({ text: "side done" }); + + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + const backend = new EmbeddedTuiBackend(); + backend.start(); + await backend.sendChat({ + sessionKey: "global", + message: "/btw local only", + runId: "run-btw-local", + }); + await vi.waitFor(() => expect(runBtwSideQuestionMock).toHaveBeenCalledTimes(1)); + await backend.stop(); + + expect(runBtwSideQuestionMock.mock.calls[0]?.[0]).not.toHaveProperty( + "allowGatewaySubagentBinding", + ); + }); + it("reports the newest matching non-BTW local run in embedded history", async () => { loadSessionEntryMock.mockImplementation((sessionKey: string) => ({ cfg: {},