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.
This commit is contained in:
Nikolai Melekhin
2026-08-03 02:36:35 -03:00
committed by GitHub
parent 607cf8fb6f
commit f153cf3a92
5 changed files with 101 additions and 0 deletions
+33
View File
@@ -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({
+9
View File
@@ -591,6 +591,12 @@ type RunBtwSideQuestionParams = {
sessionStore?: Record<string, StoredSessionEntry>;
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 ??
@@ -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" } } };
+1
View File
@@ -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 }
: {}),
+29
View File
@@ -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: {},