diff --git a/extensions/discord/src/monitor/native-command-agent-reply.ts b/extensions/discord/src/monitor/native-command-agent-reply.ts index ebcbe8f9a5ad..dddf3af35e09 100644 --- a/extensions/discord/src/monitor/native-command-agent-reply.ts +++ b/extensions/discord/src/monitor/native-command-agent-reply.ts @@ -30,7 +30,7 @@ import { settleDiscordInteractionWithoutVisibleReply, } from "./native-command-reply.js"; import { nativeCommandRuntime } from "./native-command.runtime.js"; -import type { DiscordConfig } from "./native-command.types.js"; +import type { DiscordConfig, DiscordDispatchReplyFromConfig } from "./native-command.types.js"; type NativeCommandEffectiveRoute = { accountId: string; @@ -55,6 +55,7 @@ export async function dispatchDiscordNativeAgentReply(params: { preferFollowUp: boolean; responseEphemeral?: boolean; suppressReplies?: boolean; + dispatchReplyFromConfig?: DiscordDispatchReplyFromConfig; log: ReturnType; pluginCommandDispatch: PluginCommandCatalogDecision; }): Promise { @@ -72,6 +73,7 @@ export async function dispatchDiscordNativeAgentReply(params: { sessionKey: params.ctxPayload.SessionKey ?? params.effectiveRoute.sessionKey, }, ctxPayload: params.ctxPayload, + dispatchReplyFromConfig: params.dispatchReplyFromConfig, delivery: { deliver: async (payload) => { if (params.suppressReplies) { diff --git a/extensions/discord/src/monitor/native-command-arg-ui.ts b/extensions/discord/src/monitor/native-command-arg-ui.ts index bd2363eaf121..c9292e506028 100644 --- a/extensions/discord/src/monitor/native-command-arg-ui.ts +++ b/extensions/discord/src/monitor/native-command-arg-ui.ts @@ -124,6 +124,7 @@ async function handleDiscordCommandArgInteraction(params: { preferFollowUp: true, threadBindings: ctx.threadBindings, responseEphemeral: resolveDiscordSlashCommandConfig(ctx.discordConfig?.slashCommand).ephemeral, + dispatchReplyFromConfig: ctx.dispatchReplyFromConfig, pluginCommandDispatch: { kind: "non-plugin" }, }); } diff --git a/extensions/discord/src/monitor/native-command-dispatch.ts b/extensions/discord/src/monitor/native-command-dispatch.ts index f91411b6952d..2d68a017db56 100644 --- a/extensions/discord/src/monitor/native-command-dispatch.ts +++ b/extensions/discord/src/monitor/native-command-dispatch.ts @@ -9,6 +9,7 @@ import type { CommandInteraction, StringSelectMenuInteraction, } from "../internal/discord.js"; +import type { DiscordDispatchReplyFromConfig } from "./native-command.types.js"; import type { ThreadBindingManager } from "./thread-bindings.js"; type DiscordConfig = NonNullable["discord"]; @@ -26,6 +27,7 @@ type DispatchDiscordCommandInteractionParams = { threadBindings: ThreadBindingManager; responseEphemeral?: boolean; suppressReplies?: boolean; + dispatchReplyFromConfig?: DiscordDispatchReplyFromConfig; pluginCommandDispatch: PluginCommandCatalogDecision; }; diff --git a/extensions/discord/src/monitor/native-command-model-picker-apply.ts b/extensions/discord/src/monitor/native-command-model-picker-apply.ts index 5a08f49f6c26..fb424050d38e 100644 --- a/extensions/discord/src/monitor/native-command-model-picker-apply.ts +++ b/extensions/discord/src/monitor/native-command-model-picker-apply.ts @@ -9,6 +9,7 @@ import { type DiscordModelPickerPreferenceScope, } from "./model-picker-preferences.js"; import type { DispatchDiscordCommandInteraction } from "./native-command-dispatch.js"; +import type { DiscordDispatchReplyFromConfig } from "./native-command.types.js"; import type { ThreadBindingManager } from "./thread-bindings.js"; type DiscordConfig = NonNullable["discord"]; @@ -43,6 +44,7 @@ export async function applyDiscordModelPickerSelection(params: { accountId: string; sessionPrefix: string; threadBindings: ThreadBindingManager; + dispatchReplyFromConfig?: DiscordDispatchReplyFromConfig; route: ResolvedAgentRoute; resolvedModelRef: string; selectedRuntime?: string; @@ -65,6 +67,7 @@ export async function applyDiscordModelPickerSelection(params: { preferFollowUp: true, threadBindings: params.threadBindings, suppressReplies: true, + dispatchReplyFromConfig: params.dispatchReplyFromConfig, pluginCommandDispatch: { kind: "non-plugin" }, }), 12000, diff --git a/extensions/discord/src/monitor/native-command-model-picker-interaction.ts b/extensions/discord/src/monitor/native-command-model-picker-interaction.ts index 54216af4c404..5c7935619bf1 100644 --- a/extensions/discord/src/monitor/native-command-model-picker-interaction.ts +++ b/extensions/discord/src/monitor/native-command-model-picker-interaction.ts @@ -620,6 +620,7 @@ async function handleDiscordModelPickerInteraction(params: { accountId: ctx.accountId, sessionPrefix: ctx.sessionPrefix, threadBindings: ctx.threadBindings, + dispatchReplyFromConfig: ctx.dispatchReplyFromConfig, route, resolvedModelRef, selectedRuntime, diff --git a/extensions/discord/src/monitor/native-command-ui.types.ts b/extensions/discord/src/monitor/native-command-ui.types.ts index 12bff14896d8..6345e27566a7 100644 --- a/extensions/discord/src/monitor/native-command-ui.types.ts +++ b/extensions/discord/src/monitor/native-command-ui.types.ts @@ -1,5 +1,6 @@ // Discord type declarations define plugin contracts. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { DiscordDispatchReplyFromConfig } from "./native-command.types.js"; import type { ThreadBindingManager } from "./thread-bindings.js"; type DiscordConfig = NonNullable["discord"]; @@ -10,6 +11,7 @@ export type DiscordCommandArgContext = { accountId: string; sessionPrefix: string; threadBindings: ThreadBindingManager; + dispatchReplyFromConfig?: DiscordDispatchReplyFromConfig; postApplySettleMs?: number; }; diff --git a/extensions/discord/src/monitor/native-command.command-arg.test.ts b/extensions/discord/src/monitor/native-command.command-arg.test.ts index c57c68c3c578..8891a4c72984 100644 --- a/extensions/discord/src/monitor/native-command.command-arg.test.ts +++ b/extensions/discord/src/monitor/native-command.command-arg.test.ts @@ -79,12 +79,17 @@ describe("discord command argument fallback", () => { it("preserves public slash command visibility for selected argument follow-ups", async () => { const commandDefinition = createCommandDefinition(); + const dispatchReplyFromConfig = + vi.fn>(); vi.spyOn(commandRegistryModule, "findCommandByNativeName").mockReturnValue(commandDefinition); const dispatchSpy = vi .fn() .mockResolvedValue({ accepted: true }); const button = createDiscordCommandArgFallbackButton({ - ctx: createContext({ slashCommand: { ephemeral: false } }), + ctx: { + ...createContext({ slashCommand: { ephemeral: false } }), + dispatchReplyFromConfig, + }, safeInteractionCall, dispatchCommandInteraction: dispatchSpy, }); @@ -103,5 +108,6 @@ describe("discord command argument fallback", () => { expect(dispatchCall?.accountId).toBe("default"); expect(dispatchCall?.sessionPrefix).toBe("discord:slash"); expect(dispatchCall?.preferFollowUp).toBe(true); + expect(dispatchCall?.dispatchReplyFromConfig).toBe(dispatchReplyFromConfig); }); }); diff --git a/extensions/discord/src/monitor/native-command.model-picker.test.ts b/extensions/discord/src/monitor/native-command.model-picker.test.ts index d0084f2a359a..440e4f7ab1ef 100644 --- a/extensions/discord/src/monitor/native-command.model-picker.test.ts +++ b/extensions/discord/src/monitor/native-command.model-picker.test.ts @@ -715,8 +715,10 @@ describe("Discord model picker interactions", () => { ).toContain("selection expired"); }); - it("requires submit click before routing selected model through /model pipeline", async () => { - const context = createModelPickerContext(); + it("requires submit and retains Gateway ownership through the /model pipeline", async () => { + const dispatchReplyFromConfig = + vi.fn>(); + const context = { ...createModelPickerContext(), dispatchReplyFromConfig }; const pickerData = createDefaultModelPickerData(); const modelCommand = createModelCommandDefinition(); @@ -745,6 +747,10 @@ describe("Discord model picker interactions", () => { dispatchSpy, model: "openai/gpt-4o", }); + const dispatchCall = firstMockArg(dispatchSpy, "dispatchCommandInteraction") as + | Parameters[0] + | undefined; + expect(dispatchCall?.dispatchReplyFromConfig).toBe(dispatchReplyFromConfig); }); it("applies the selected model even when component channel.name throws on a partial channel", async () => { diff --git a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts index a1a4057446c5..4784435321a0 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -66,8 +66,10 @@ const runtimeModuleMocks = vi.hoisted(() => ({ resolveDirectStatusReplyForSession: vi.fn(), getSessionEntry: vi.fn(), })); +let observedNativeTurnDispatcher: unknown; const dispatchChannelInboundTurnForTest: typeof dispatchChannelInboundTurn = async (plan) => { + observedNativeTurnDispatcher = plan.dispatchReplyFromConfig; const dispatchResult = await runtimeModuleMocks.dispatchReplyWithDispatcher({ ctx: plan.ctxPayload, cfg: plan.cfg, @@ -173,7 +175,13 @@ function createConfiguredAcpCase(params: { }; } -async function createNativeCommand(cfg: OpenClawConfig, commandSpec: NativeCommandSpec) { +async function createNativeCommand( + cfg: OpenClawConfig, + commandSpec: NativeCommandSpec, + dispatchReplyFromConfig?: Parameters< + typeof createDiscordNativeCommand + >[0]["dispatchReplyFromConfig"], +) { return createDiscordNativeCommand({ command: commandSpec, cfg, @@ -182,6 +190,7 @@ async function createNativeCommand(cfg: OpenClawConfig, commandSpec: NativeComma sessionPrefix: "discord:slash", ephemeralDefault: true, threadBindings: createNoopThreadBindingManager("default"), + dispatchReplyFromConfig, }); } @@ -499,6 +508,7 @@ describe("Discord native plugin command dispatch", () => { }); beforeEach(() => { + observedNativeTurnDispatcher = undefined; clearRuntimeConfigSnapshot(); vi.clearAllMocks(); clearPluginCommands(); @@ -539,6 +549,24 @@ describe("Discord native plugin command dispatch", () => { clearRuntimeConfigSnapshot(); }); + it("keeps the owning Gateway dispatcher on a native slash turn", async () => { + const cfg = createConfig(); + const interaction = createInteraction(); + const dispatchReplyFromConfig = + vi.fn< + NonNullable[0]["dispatchReplyFromConfig"]> + >(); + const command = await createNativeCommand( + cfg, + { name: "new", description: "Start a new session.", acceptsArgs: true }, + dispatchReplyFromConfig, + ); + + await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); + + expect(observedNativeTurnDispatcher).toBe(dispatchReplyFromConfig); + }); + it("refreshes native command routing config between invocations", async () => { const sourceCfg = { ...createConfig(), diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 485d2e64b65f..37c094628eab 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -84,7 +84,11 @@ import { truncateDiscordCommandDescription, } from "./native-command.options.js"; import { nativeCommandRuntime } from "./native-command.runtime.js"; -import type { DiscordCommandArgs, DiscordConfig } from "./native-command.types.js"; +import type { + DiscordCommandArgs, + DiscordConfig, + DiscordDispatchReplyFromConfig, +} from "./native-command.types.js"; import { resolveDiscordNativeInteractionChannelContext } from "./native-interaction-channel-context.js"; import { resolveDiscordSenderIdentity } from "./sender-identity.js"; import type { ThreadBindingManager } from "./thread-bindings.js"; @@ -101,6 +105,7 @@ export function createDiscordNativeCommand(params: { sessionPrefix: string; ephemeralDefault: boolean; threadBindings: ThreadBindingManager; + dispatchReplyFromConfig?: DiscordDispatchReplyFromConfig; }): Command { const { command, @@ -110,6 +115,7 @@ export function createDiscordNativeCommand(params: { sessionPrefix, ephemeralDefault, threadBindings, + dispatchReplyFromConfig, } = params; const fallbackCommandDefinition = createNativeCommandDefinition(command); const pluginCommandCandidate = "prepareDispatch" in command ? command : undefined; @@ -203,6 +209,7 @@ export function createDiscordNativeCommand(params: { preferFollowUp: true, threadBindings, responseEphemeral: ephemeralDefault, + dispatchReplyFromConfig, pluginCommandDispatch: preparedPluginCommand ?? NON_PLUGIN_COMMAND_DISPATCH, }); } @@ -222,6 +229,7 @@ async function dispatchDiscordCommandInteraction(params: { threadBindings: ThreadBindingManager; responseEphemeral?: boolean; suppressReplies?: boolean; + dispatchReplyFromConfig?: DiscordDispatchReplyFromConfig; pluginCommandDispatch: PluginCommandCatalogDecision; }): Promise { const { @@ -237,6 +245,7 @@ async function dispatchDiscordCommandInteraction(params: { threadBindings, responseEphemeral, suppressReplies, + dispatchReplyFromConfig, } = params; const cfg = getRuntimeConfigSnapshot() ?? inputConfig; const commandName = command.nativeName ?? command.key; @@ -530,6 +539,7 @@ async function dispatchDiscordCommandInteraction(params: { accountId, sessionPrefix, threadBindings, + dispatchReplyFromConfig, }, safeInteractionCall: safeDiscordInteractionCall, dispatchCommandInteraction: dispatchDiscordCommandInteraction, @@ -722,6 +732,7 @@ async function dispatchDiscordCommandInteraction(params: { preferFollowUp, responseEphemeral, suppressReplies, + dispatchReplyFromConfig, log, pluginCommandDispatch: params.pluginCommandDispatch, }); diff --git a/extensions/discord/src/monitor/native-command.types.ts b/extensions/discord/src/monitor/native-command.types.ts index ebe6e9147cd7..8d1c6f3a11c4 100644 --- a/extensions/discord/src/monitor/native-command.types.ts +++ b/extensions/discord/src/monitor/native-command.types.ts @@ -1,8 +1,12 @@ // Discord type declarations define plugin contracts. +import type { ChannelInboundTurnPlan } from "openclaw/plugin-sdk/channel-inbound"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { CommandArgValues } from "openclaw/plugin-sdk/native-command-registry"; export type DiscordConfig = NonNullable["discord"]; +export type DiscordDispatchReplyFromConfig = NonNullable< + ChannelInboundTurnPlan["dispatchReplyFromConfig"] +>; export type DiscordCommandArgs = { raw?: string; diff --git a/extensions/discord/src/monitor/provider.interactions.test.ts b/extensions/discord/src/monitor/provider.interactions.test.ts index 6bb33a2dd009..fb5d5995e417 100644 --- a/extensions/discord/src/monitor/provider.interactions.test.ts +++ b/extensions/discord/src/monitor/provider.interactions.test.ts @@ -1,3 +1,4 @@ +import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers"; import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { NativeCommandSpec } from "openclaw/plugin-sdk/native-command-registry"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; @@ -18,6 +19,7 @@ const normalCommandSpec: NativeCommandSpec = { function createInteractionHarness(params: { commandSpecs: NativeCommandSpec[]; voiceEnabled: boolean; + channelRuntime?: InteractionParams["channelRuntime"]; }) { const createNativeCommand = vi.fn( (options: Parameters[0]): ReturnType => @@ -44,6 +46,7 @@ function createInteractionHarness(params: { allowFrom: [], dmPolicy: "open", runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } satisfies RuntimeEnv, + channelRuntime: params.channelRuntime, createNativeCommand, }); return { createNativeCommand, surface }; @@ -78,4 +81,20 @@ describe("createDiscordProviderInteractionSurface", () => { expect(createNativeCommand).toHaveBeenCalledOnce(); expect(surface.commands.map((command) => command.name)).toEqual(["normal"]); }); + + it("binds native slash commands to the owning Gateway dispatcher", () => { + const dispatchReplyFromConfig = vi.fn(); + const channelRuntime = createPluginRuntimeMock({ + channel: { reply: { dispatchReplyFromConfig } }, + }).channel; + const { createNativeCommand } = createInteractionHarness({ + commandSpecs: [normalCommandSpec], + voiceEnabled: false, + channelRuntime, + }); + + expect(createNativeCommand.mock.calls[0]?.[0].dispatchReplyFromConfig).toBe( + dispatchReplyFromConfig, + ); + }); }); diff --git a/extensions/discord/src/monitor/provider.interactions.ts b/extensions/discord/src/monitor/provider.interactions.ts index bd9b9b52e2de..e1573eb16874 100644 --- a/extensions/discord/src/monitor/provider.interactions.ts +++ b/extensions/discord/src/monitor/provider.interactions.ts @@ -88,6 +88,7 @@ export function createDiscordProviderInteractionSurface(params: { sessionPrefix: params.sessionPrefix, ephemeralDefault: params.ephemeralDefault, threadBindings: params.threadBindings, + dispatchReplyFromConfig: params.channelRuntime?.reply?.dispatchReplyFromConfig, }); }); @@ -138,6 +139,7 @@ export function createDiscordProviderInteractionSurface(params: { accountId: params.accountId, sessionPrefix: params.sessionPrefix, threadBindings: params.threadBindings, + dispatchReplyFromConfig: params.channelRuntime?.reply?.dispatchReplyFromConfig, }), createDiscordModelPickerFallbackButton({ cfg: params.cfg, @@ -145,6 +147,7 @@ export function createDiscordProviderInteractionSurface(params: { accountId: params.accountId, sessionPrefix: params.sessionPrefix, threadBindings: params.threadBindings, + dispatchReplyFromConfig: params.channelRuntime?.reply?.dispatchReplyFromConfig, }), createDiscordModelPickerFallbackSelect({ cfg: params.cfg, @@ -152,6 +155,7 @@ export function createDiscordProviderInteractionSurface(params: { accountId: params.accountId, sessionPrefix: params.sessionPrefix, threadBindings: params.threadBindings, + dispatchReplyFromConfig: params.channelRuntime?.reply?.dispatchReplyFromConfig, }), ]; const activityButton = createDiscordActivityButton( diff --git a/extensions/telegram/src/bot-native-command-dispatch.routing.test.ts b/extensions/telegram/src/bot-native-command-dispatch.routing.test.ts index 11c0391383c4..517d8edd598e 100644 --- a/extensions/telegram/src/bot-native-command-dispatch.routing.test.ts +++ b/extensions/telegram/src/bot-native-command-dispatch.routing.test.ts @@ -29,6 +29,17 @@ const { persistentBindingMocks, replyMocks, sessionBindingMocks, sessionMocks } describe("Telegram native command dispatch routing", () => { beforeEach(resetSessionMetaMocks); + it("keeps the owning Gateway dispatcher on a native slash turn", async () => { + const dispatchReplyFromConfig = vi.fn(); + const { handler } = registerAndResolveStatusHandler({ cfg: {}, dispatchReplyFromConfig }); + + await handler(createTelegramPrivateCommandContext()); + + expect(dispatchChannelInboundTurnMock.mock.calls[0]?.[0].dispatchReplyFromConfig).toBe( + dispatchReplyFromConfig, + ); + }); + it("calls recordSessionMetaFromInbound after a native slash command", async () => { const shadowHandler = vi.fn(async () => ({ text: "wrong plugin" })); activePluginRegistry.commands.push({ diff --git a/extensions/telegram/src/bot-native-command-dispatch.ts b/extensions/telegram/src/bot-native-command-dispatch.ts index 6f9cf4d39322..01fe78458e56 100644 --- a/extensions/telegram/src/bot-native-command-dispatch.ts +++ b/extensions/telegram/src/bot-native-command-dispatch.ts @@ -1,4 +1,3 @@ -// Telegram plugin module implements native command admission and dispatch behavior. import type { Bot, Context } from "grammy"; import { isChannelPartialDeliveryError, @@ -13,10 +12,7 @@ import type { } from "openclaw/plugin-sdk/config-contracts"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; -import { - PLUGIN_COMMAND_DISPATCH, - type PluginCommandCatalogDecision, -} from "openclaw/plugin-sdk/plugin-command-runtime"; +import { PLUGIN_COMMAND_DISPATCH } from "openclaw/plugin-sdk/plugin-command-runtime"; import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { expandTelegramAllowFromWithAccessGroups } from "./access-groups.js"; @@ -65,10 +61,6 @@ import { resolveTelegramCommandIngressAuthorization } from "./ingress.js"; import { getTopicName, resolveTopicNameCacheScope } from "./topic-name-cache.js"; const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; -const NON_PLUGIN_COMMAND_DISPATCH = Object.freeze({ - kind: "non-plugin", -}) satisfies PluginCommandCatalogDecision; - const loadTelegramNativeCommandDeliveryRuntime = createLazyRuntimeModule( () => import("./bot-native-commands.delivery.runtime.js"), ); @@ -109,6 +101,7 @@ export type TelegramCommandExecutorParams = { | "groupAllowFrom" | "replyToMode" | "accountAbortSignal" + | "dispatchReplyFromConfig" >; }; @@ -600,6 +593,7 @@ export async function dispatchTelegramBuiltinTurn(params: { accountId: dispatch.route.accountId, route: { agentId: dispatch.route.agentId, sessionKey: commandSessionKey }, ctxPayload, + dispatchReplyFromConfig: dispatch.opts.dispatchReplyFromConfig, record: { sessionKey: commandTargetSessionKey, trackSessionMetaTask: (task) => { @@ -690,7 +684,7 @@ export async function dispatchTelegramBuiltinTurn(params: { const enabled = resolveChannelStreamingBlockEnabled(dispatch.runtimeTelegramCfg); return typeof enabled === "boolean" ? !enabled : undefined; })(), - [PLUGIN_COMMAND_DISPATCH]: NON_PLUGIN_COMMAND_DISPATCH, + [PLUGIN_COMMAND_DISPATCH]: { kind: "non-plugin" }, }, }; const turnResult = await ( diff --git a/extensions/telegram/src/bot-native-command-executors.test-support.ts b/extensions/telegram/src/bot-native-command-executors.test-support.ts index 8a3b5a9722f6..c5d2af56b566 100644 --- a/extensions/telegram/src/bot-native-command-executors.test-support.ts +++ b/extensions/telegram/src/bot-native-command-executors.test-support.ts @@ -280,6 +280,7 @@ type TelegramLoginFlow = NonNullable; }; diff --git a/src/agents/tools/terminal-tool.test.ts b/src/agents/tools/terminal-tool.test.ts index f354e32c567f..4cebc223e156 100644 --- a/src/agents/tools/terminal-tool.test.ts +++ b/src/agents/tools/terminal-tool.test.ts @@ -13,13 +13,15 @@ import { import type { spawnTerminalPty } from "../../process/terminal-pty.js"; import { GATEWAY_OWNER_ONLY_CORE_TOOLS } from "../../security/dangerous-tools.js"; import { compactToolOutputHint } from "../tool-schema-hints.js"; +import { withGatewayToolCallerIdentity } from "./gateway-caller-context.js"; import { createTerminalTool } from "./terminal-tool.js"; const callInProcessGatewayTool = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); +const getInProcessGatewayToolContext = vi.hoisted(() => vi.fn()); vi.mock("./in-process-gateway.js", () => ({ callInProcessGatewayTool, - getInProcessGatewayToolContext: vi.fn(), + getInProcessGatewayToolContext, })); type TerminalPtyHandle = Awaited>; @@ -85,6 +87,7 @@ describe("terminal tool", () => { beforeEach(() => { resetAgentRunRegistryForTest(); callInProcessGatewayTool.mockClear(); + getInProcessGatewayToolContext.mockReset(); }); it("uses a flat action enum and the owner-only core gate", () => { @@ -105,6 +108,176 @@ describe("terminal tool", () => { expect(GATEWAY_OWNER_ONLY_CORE_TOOLS).toContain("terminal"); }); + it("uses the admitted caller Gateway before ambient context", async () => { + const callerManager = new TerminalSessionManager({ emit: vi.fn(), spawn: vi.fn() }); + const ambientManager = new TerminalSessionManager({ emit: vi.fn(), spawn: vi.fn() }); + const callerList = vi.spyOn(callerManager, "listAgent"); + const ambientList = vi.spyOn(ambientManager, "listAgent"); + const gatewayContextResolver = vi.fn(); + gatewayContextResolver.mockReturnValue(makeContext(callerManager)); + getInProcessGatewayToolContext.mockReturnValue(makeContext(ambientManager)); + const tool = createTerminalTool({ + agentId: "main", + agentSessionKey: "agent:main:main", + sessionId: "main-session-id", + }); + + const result = await withGatewayToolCallerIdentity( + { + agentId: "main", + sessionKey: "agent:main:main", + gatewayContextResolver, + }, + async () => await tool.execute("list", { action: "list" }), + ); + + expect(result.details).toEqual({ sessions: [] }); + expect(callerList).toHaveBeenCalledOnce(); + expect(ambientList).not.toHaveBeenCalled(); + }); + + it("fails closed when the admitted caller Gateway has retired", async () => { + const ambientManager = new TerminalSessionManager({ emit: vi.fn(), spawn: vi.fn() }); + const ambientList = vi.spyOn(ambientManager, "listAgent"); + getInProcessGatewayToolContext.mockReturnValue(makeContext(ambientManager)); + const tool = createTerminalTool({ + agentId: "main", + agentSessionKey: "agent:main:main", + sessionId: "main-session-id", + }); + + await expect( + withGatewayToolCallerIdentity( + { + agentId: "main", + sessionKey: "agent:main:main", + gatewayContextResolver: () => undefined, + }, + async () => await tool.execute("list", { action: "list" }), + ), + ).rejects.toThrow("terminal unavailable"); + expect(ambientList).not.toHaveBeenCalled(); + }); + + it("revalidates the admitted Gateway after task lookup before opening", async () => { + const callerSpawn = vi.fn(async () => makeBackend()); + const ambientSpawn = vi.fn(async () => makeBackend()); + const callerManager = new TerminalSessionManager({ emit: vi.fn(), spawn: callerSpawn }); + const ambientManager = new TerminalSessionManager({ emit: vi.fn(), spawn: ambientSpawn }); + let callerLive = true; + const gatewayContextResolver = vi.fn(); + gatewayContextResolver.mockImplementation(() => + callerLive ? makeContext(callerManager) : undefined, + ); + getInProcessGatewayToolContext.mockReturnValue(makeContext(ambientManager)); + const lookupTaskByRunIdForChildSession = vi.fn(async () => { + callerLive = false; + return undefined; + }); + const tool = createTerminalTool({ + agentId: "main", + agentSessionKey: "agent:main:main", + sessionId: "main-session-id", + runId: "run-1", + lookupTaskByRunIdForChildSession, + }); + + await expect( + withGatewayToolCallerIdentity( + { + agentId: "main", + sessionKey: "agent:main:main", + gatewayContextResolver, + }, + async () => await tool.execute("open", { action: "open" }), + ), + ).rejects.toThrow("terminal unavailable"); + expect(gatewayContextResolver).toHaveBeenCalledTimes(2); + expect(callerSpawn).not.toHaveBeenCalled(); + expect(ambientSpawn).not.toHaveBeenCalled(); + expect(getInProcessGatewayToolContext).not.toHaveBeenCalled(); + }); + + it("closes a terminal when the admitted Gateway retires during open", async () => { + const spawned = deferred>(); + const backend = makeBackend(); + const callerSpawn = vi.fn(() => spawned.promise); + const callerManager = new TerminalSessionManager({ emit: vi.fn(), spawn: callerSpawn }); + const ambientSpawn = vi.fn(async () => makeBackend()); + const ambientManager = new TerminalSessionManager({ emit: vi.fn(), spawn: ambientSpawn }); + let callerLive = true; + const gatewayContextResolver = vi.fn(); + gatewayContextResolver.mockImplementation(() => + callerLive ? makeContext(callerManager) : undefined, + ); + getInProcessGatewayToolContext.mockReturnValue(makeContext(ambientManager)); + const tool = createTerminalTool({ + agentId: "main", + agentSessionKey: "agent:main:main", + sessionId: "main-session-id", + }); + + const opening = withGatewayToolCallerIdentity( + { + agentId: "main", + sessionKey: "agent:main:main", + gatewayContextResolver, + }, + async () => await tool.execute("open", { action: "open", command: "echo unsafe" }), + ); + await vi.waitFor(() => expect(callerSpawn).toHaveBeenCalledOnce()); + callerLive = false; + spawned.resolve(backend); + + await expect(opening).rejects.toThrow("terminal unavailable"); + expect(gatewayContextResolver).toHaveBeenCalledTimes(2); + expect(backend.writes).toEqual([]); + expect(backend.killed).toBe(true); + expect(callerManager.size).toBe(0); + expect(ambientSpawn).not.toHaveBeenCalled(); + expect(getInProcessGatewayToolContext).not.toHaveBeenCalled(); + }); + + it("keeps ambient Gateway context pinned across task lookup", async () => { + const firstSpawn = vi.fn(async () => makeBackend()); + const secondSpawn = vi.fn(async () => makeBackend()); + const firstManager = new TerminalSessionManager({ emit: vi.fn(), spawn: firstSpawn }); + const secondManager = new TerminalSessionManager({ emit: vi.fn(), spawn: secondSpawn }); + getInProcessGatewayToolContext + .mockReturnValueOnce(makeContext(firstManager)) + .mockReturnValue(makeContext(secondManager)); + const tool = createTerminalTool({ + agentId: "main", + agentSessionKey: "agent:main:main", + sessionId: "main-session-id", + runId: "run-1", + lookupTaskByRunIdForChildSession: vi.fn(async () => undefined), + }); + + await expect(tool.execute("open", { action: "open" })).resolves.toMatchObject({ + details: { ok: true }, + }); + expect(getInProcessGatewayToolContext).toHaveBeenCalledOnce(); + expect(firstSpawn).toHaveBeenCalledOnce(); + expect(secondSpawn).not.toHaveBeenCalled(); + }); + + it("uses ambient Gateway context without an admitted caller", async () => { + const manager = new TerminalSessionManager({ emit: vi.fn(), spawn: vi.fn() }); + const list = vi.spyOn(manager, "listAgent"); + getInProcessGatewayToolContext.mockReturnValue(makeContext(manager)); + const tool = createTerminalTool({ + agentId: "main", + agentSessionKey: "agent:main:main", + sessionId: "main-session-id", + }); + + await expect(tool.execute("list", { action: "list" })).resolves.toMatchObject({ + details: { sessions: [] }, + }); + expect(list).toHaveBeenCalledOnce(); + }); + it("opens in the background, reads, writes, resizes, lists, and closes its terminal", async () => { const backend = makeBackend(); const manager = new TerminalSessionManager({ emit: vi.fn(), spawn: async () => backend }); diff --git a/src/agents/tools/terminal-tool.ts b/src/agents/tools/terminal-tool.ts index 20135e66140b..506852ce3718 100644 --- a/src/agents/tools/terminal-tool.ts +++ b/src/agents/tools/terminal-tool.ts @@ -19,6 +19,7 @@ import { readToolStringParam, ToolInputError, } from "./common.js"; +import { getGatewayToolCallerIdentity } from "./gateway-caller-context.js"; import { getInProcessGatewayToolContext } from "./in-process-gateway.js"; const ACTIONS = ["open", "read", "input", "resize", "close", "list"] as const; @@ -166,8 +167,32 @@ function launchBlockMessage( return `terminal unavailable: agent sandboxed (${block.mode})`; } +function resolveTerminalOpenTarget(params: { + agentId: string; + context: TerminalToolGatewayContext | undefined; + cwd?: string; +}) { + const manager = params.context?.terminalSessions; + if (!params.context || !manager) { + throw new ToolInputError("terminal unavailable"); + } + if (!params.context.isTerminalEnabled()) { + throw new ToolInputError("terminal disabled"); + } + const launch = params.context.resolveTerminalLaunchPolicy(params.agentId); + if (!launch.ok) { + throw new ToolInputError(launchBlockMessage(launch.block)); + } + return { + manager, + spawnPlan: resolveTerminalSpawnPlan({ + ...launch.plan, + ...(params.cwd ? { cwdOverride: params.cwd } : {}), + }), + }; +} + export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool { - const getContext = opts.getGatewayContext ?? getInProcessGatewayToolContext; const findOwnerTask = opts.lookupTaskByRunIdForChildSession ?? lookupTaskByRunIdForChildSession; return { label: "Terminal", @@ -189,6 +214,11 @@ export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool } const agentId = opts.agentId?.trim() || resolveAgentIdFromSessionKey(agentSessionKey); const owner = { kind: "agent", agentSessionKey, agentSessionId, agentId } as const; + const admittedResolver = opts.getGatewayContext + ? undefined + : getGatewayToolCallerIdentity()?.gatewayContextResolver; + const getContext = + opts.getGatewayContext ?? admittedResolver ?? getInProcessGatewayToolContext; const context = getContext(); const manager = context?.terminalSessions; if (!context || !manager) { @@ -204,23 +234,18 @@ export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool const cwd = readOptionalStringParam(params, "cwd"); const cols = readDimension(params, "cols", DEFAULT_COLS); const rows = readDimension(params, "rows", DEFAULT_ROWS); - if (!context.isTerminalEnabled()) { - throw new ToolInputError("terminal disabled"); - } - const launch = context.resolveTerminalLaunchPolicy(agentId); - if (!launch.ok) { - throw new ToolInputError(launchBlockMessage(launch.block)); - } - const spawnPlan = resolveTerminalSpawnPlan({ - ...launch.plan, - ...(cwd ? { cwdOverride: cwd } : {}), - }); + const initialTarget = resolveTerminalOpenTarget({ agentId, context, cwd }); const runId = opts.runId?.trim(); const taskLookupId = runId ? (getAgentRunTaskRunId(runId) ?? runId) : undefined; const task = taskLookupId ? await findOwnerTask(taskLookupId, agentSessionKey) : undefined; if (task && isTerminalTaskStatus(task.status)) { throw new ToolInputError("terminal task already ended"); } + // Refresh after task lookup so a retired admitted Gateway cannot allocate a new PTY. + const { manager: openManager, spawnPlan } = + taskLookupId && admittedResolver + ? resolveTerminalOpenTarget({ agentId, context: admittedResolver(), cwd }) + : initialTarget; const taskId = task?.taskId; const terminalOwner = { ...owner, ...(taskId ? { taskId } : {}) }; const deadline = createTerminalOpenDeadline(); @@ -234,11 +259,11 @@ export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool } else { signal?.addEventListener("abort", cancelOpen, { once: true }); } - let openingTerminal: ReturnType | undefined; - let outcome: Awaited>; + let openingTerminal: ReturnType | undefined; + let outcome: Awaited>; try { outcome = await waitForTerminalOpenDeadline(() => { - openingTerminal = manager.open({ + openingTerminal = openManager.open({ owner: terminalOwner, agentId: spawnPlan.agentId, cwd: spawnPlan.cwd, @@ -256,7 +281,7 @@ export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool void openingTerminal.then( (lateOutcome) => { if (lateOutcome.ok) { - manager.closeAgent(owner, lateOutcome.sessionId); + openManager.closeAgent(owner, lateOutcome.sessionId); } }, () => undefined, @@ -272,10 +297,25 @@ export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool if (!outcome.ok) { throw new ToolInputError(outcome.message); } + if (admittedResolver) { + try { + const liveManager = resolveTerminalOpenTarget({ + agentId, + context: admittedResolver(), + cwd, + }).manager; + if (liveManager !== openManager) { + throw new ToolInputError("terminal unavailable"); + } + } catch (error) { + openManager.closeAgent(owner, outcome.sessionId); + throw error; + } + } if (command !== undefined) { - const commandOutcome = manager.writeAgent(owner, outcome.sessionId, `${command}\r`); + const commandOutcome = openManager.writeAgent(owner, outcome.sessionId, `${command}\r`); if (!commandOutcome.ok) { - manager.closeAgent(owner, outcome.sessionId); + openManager.closeAgent(owner, outcome.sessionId); terminalActionResult("initial command", commandOutcome); } }