mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
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:
@@ -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({
|
||||
|
||||
@@ -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" } } };
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
Reference in New Issue
Block a user