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: {},