fix(agents): resolve terminal from admitted gateway (#128348)

Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com>
Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
This commit is contained in:
ClawSweeper
2026-08-24 09:46:14 -07:00
committed by GitHub
parent 0fc18f7447
commit 29f39affc2
19 changed files with 349 additions and 35 deletions
@@ -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<typeof createSubsystemLogger>;
pluginCommandDispatch: PluginCommandCatalogDecision;
}): Promise<DispatchDiscordNativeAgentReplyResult> {
@@ -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) {
@@ -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" },
});
}
@@ -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<OpenClawConfig["channels"]>["discord"];
@@ -26,6 +27,7 @@ type DispatchDiscordCommandInteractionParams = {
threadBindings: ThreadBindingManager;
responseEphemeral?: boolean;
suppressReplies?: boolean;
dispatchReplyFromConfig?: DiscordDispatchReplyFromConfig;
pluginCommandDispatch: PluginCommandCatalogDecision;
};
@@ -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<OpenClawConfig["channels"]>["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,
@@ -620,6 +620,7 @@ async function handleDiscordModelPickerInteraction(params: {
accountId: ctx.accountId,
sessionPrefix: ctx.sessionPrefix,
threadBindings: ctx.threadBindings,
dispatchReplyFromConfig: ctx.dispatchReplyFromConfig,
route,
resolvedModelRef,
selectedRuntime,
@@ -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<OpenClawConfig["channels"]>["discord"];
@@ -10,6 +11,7 @@ export type DiscordCommandArgContext = {
accountId: string;
sessionPrefix: string;
threadBindings: ThreadBindingManager;
dispatchReplyFromConfig?: DiscordDispatchReplyFromConfig;
postApplySettleMs?: number;
};
@@ -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<NonNullable<CommandArgContext["dispatchReplyFromConfig"]>>();
vi.spyOn(commandRegistryModule, "findCommandByNativeName").mockReturnValue(commandDefinition);
const dispatchSpy = vi
.fn<DispatchDiscordCommandInteraction>()
.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);
});
});
@@ -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<NonNullable<ModelPickerContext["dispatchReplyFromConfig"]>>();
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<DispatchDiscordCommandInteraction>[0]
| undefined;
expect(dispatchCall?.dispatchReplyFromConfig).toBe(dispatchReplyFromConfig);
});
it("applies the selected model even when component channel.name throws on a partial channel", async () => {
@@ -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<Parameters<typeof createDiscordNativeCommand>[0]["dispatchReplyFromConfig"]>
>();
const command = await createNativeCommand(
cfg,
{ name: "new", description: "Start a new session.", acceptsArgs: true },
dispatchReplyFromConfig,
);
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
expect(observedNativeTurnDispatcher).toBe(dispatchReplyFromConfig);
});
it("refreshes native command routing config between invocations", async () => {
const sourceCfg = {
...createConfig(),
@@ -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<DispatchDiscordCommandInteractionResult> {
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,
});
@@ -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<OpenClawConfig["channels"]>["discord"];
export type DiscordDispatchReplyFromConfig = NonNullable<
ChannelInboundTurnPlan["dispatchReplyFromConfig"]
>;
export type DiscordCommandArgs = {
raw?: string;
@@ -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<CreateNativeCommand>[0]): ReturnType<CreateNativeCommand> =>
@@ -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,
);
});
});
@@ -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(
@@ -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({
@@ -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 (
@@ -280,6 +280,7 @@ type TelegramLoginFlow = NonNullable<TelegramNativeCommandDeps["runModelsAuthLog
export function registerAndResolveStatusHandler(params: {
cfg: OpenClawConfig;
dispatchReplyFromConfig?: NativeCommandTestParams["opts"]["dispatchReplyFromConfig"];
runtimeCfg?: OpenClawConfig;
allowFrom?: string[];
groupAllowFrom?: string[];
@@ -292,6 +293,7 @@ export function registerAndResolveStatusHandler(params: {
} {
const {
cfg,
dispatchReplyFromConfig,
runtimeCfg,
allowFrom,
groupAllowFrom,
@@ -302,6 +304,7 @@ export function registerAndResolveStatusHandler(params: {
return registerAndResolveCommandHandlerBase({
commandName: "status",
cfg,
dispatchReplyFromConfig,
runtimeCfg,
allowFrom: allowFrom ?? ["*"],
groupAllowFrom: groupAllowFrom ?? [],
@@ -314,6 +317,7 @@ export function registerAndResolveStatusHandler(params: {
function registerAndResolveCommandHandlerBase(params: {
commandName: string;
cfg: OpenClawConfig;
dispatchReplyFromConfig?: NativeCommandTestParams["opts"]["dispatchReplyFromConfig"];
runtimeCfg?: OpenClawConfig;
allowFrom: string[];
groupAllowFrom: string[];
@@ -329,6 +333,7 @@ function registerAndResolveCommandHandlerBase(params: {
const {
commandName,
cfg,
dispatchReplyFromConfig,
runtimeCfg,
allowFrom,
groupAllowFrom,
@@ -378,6 +383,7 @@ function registerAndResolveCommandHandlerBase(params: {
}),
} as unknown as NativeCommandTestParams["bot"],
cfg,
opts: { token: "token", dispatchReplyFromConfig },
allowFrom,
groupAllowFrom,
telegramCfg,
@@ -71,6 +71,7 @@ type RegisterTelegramNativeCommandsParams = {
| "groupAllowFrom"
| "replyToMode"
| "accountAbortSignal"
| "dispatchReplyFromConfig"
>;
};
+174 -1
View File
@@ -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<ReturnType<typeof spawnTerminalPty>>;
@@ -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<ReturnType<typeof makeBackend>>();
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 });
+58 -18
View File
@@ -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<typeof manager.open> | undefined;
let outcome: Awaited<ReturnType<typeof manager.open>>;
let openingTerminal: ReturnType<typeof openManager.open> | undefined;
let outcome: Awaited<ReturnType<typeof openManager.open>>;
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);
}
}