mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
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:
@@ -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"
|
||||
>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user