fix(gateway): async trajectory export approvals

This commit is contained in:
Vincent Koc
2026-06-14 22:25:05 +08:00
parent b5999bc6a0
commit ccf5976d06
5 changed files with 75 additions and 8 deletions
@@ -1069,6 +1069,51 @@ EOF`,
expect(approvalInput?.outcome?.exitCode).toBe(0);
});
it("uses async agent followups for explicit webchat approval mode", async () => {
buildExecApprovalFollowupTargetMock.mockImplementation((value) => value);
resolveExecHostApprovalContextMock.mockReturnValue({
approvals: { allowlist: [], file: { version: 1, agents: {} } },
hostSecurity: "allowlist",
hostAsk: "always",
askFallback: "deny",
});
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("allow-once");
createExecApprovalDecisionStateMock.mockReturnValue({
baseDecision: { timedOut: false },
approvedByAsk: true,
deniedReason: null,
});
runExecProcessMock.mockResolvedValue({
session: { id: "sess-1" },
promise: Promise.resolve({
status: "completed",
exitCode: 0,
timedOut: false,
aggregated: "done",
}),
});
const result = await runGatewayAllowlist({
command: "openclaw sessions export-trajectory --json",
approvalFollowupMode: "agent",
sessionId: "approval-session",
sessionStore: "/tmp/openclaw-sessions.json",
turnSourceChannel: "webchat",
});
expect(result.pendingResult?.details.status).toBe("approval-pending");
await vi.waitFor(() => {
expect(sendExecApprovalFollowupResultMock).toHaveBeenCalledTimes(1);
});
expect(requireBuildFollowupTargetInput(0)).toMatchObject({
direct: false,
expectedSessionId: "approval-session",
sessionStore: "/tmp/openclaw-sessions.json",
});
expect(requireSentFollowupTarget(0)?.direct).toBe(false);
expect(requireSentFollowupText(0)).toContain("done");
});
it("waits inline for webchat approval so the exec tool can return real output to the model", async () => {
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("allow-once");
createExecApprovalDecisionStateMock.mockReturnValue({
+1 -1
View File
@@ -295,7 +295,7 @@ function shouldAwaitGatewayApprovalInline(params: {
turnSourceChannel?: string;
approvalFollowupMode?: "agent" | "direct";
}): boolean {
if (params.approvalFollowupMode === "direct") {
if (params.approvalFollowupMode !== undefined) {
return false;
}
return normalizeMessageChannel(params.turnSourceChannel) === INTERNAL_MESSAGE_CHANNEL;
@@ -89,7 +89,11 @@ function makeTempDir(): string {
function makeParams(workspaceDir = makeTempDir()): HandleCommandsParams {
return {
cfg: {},
cfg: {
session: {
store: "/tmp/openclaw-sessions.json",
},
},
ctx: {
SessionKey: "agent:main:slash-session",
AccountId: "account-1",
@@ -370,6 +374,9 @@ describe("buildExportTrajectoryCommandReply", () => {
expect(execCall.defaults.security).toBe("allowlist");
expect(execCall.defaults.ask).toBe("always");
expect(execCall.defaults.trigger).toBe("export-trajectory");
expect(execCall.defaults.approvalFollowupMode).toBe("agent");
expect(execCall.defaults.sessionId).toBe("session-1");
expect(execCall.defaults.sessionStore).toBe("/tmp/openclaw-sessions.json");
expect(execCall.defaults.currentChannelId).toBe("bot");
expect(execCall.defaults.accountId).toBe("account-1");
expect(execCall.params.security).toBe("allowlist");
@@ -244,10 +244,13 @@ async function requestTrajectoryExportApproval(
trigger: "export-trajectory",
scopeKey: EXPORT_TRAJECTORY_EXEC_SCOPE_KEY,
allowBackground: true,
approvalFollowupMode: "agent",
timeoutSec,
cwd: params.workspaceDir,
agentId,
sessionKey: params.sessionKey,
sessionId: params.sessionEntry?.sessionId,
sessionStore: params.cfg.session?.store,
mainKey: params.cfg.session?.mainKey,
sessionScope: params.cfg.session?.scope,
messageProvider: options.privateApprovalTarget?.channel ?? params.command.channel,
@@ -229,10 +229,24 @@ function getTrajectoryExportApprovalCommands(entry: TrajectoryExportApprovalEntr
].filter((value): value is string => typeof value === "string" && value.trim().length > 0);
}
function hasTrajectoryExportArgv(argv: string[] | undefined): boolean {
if (!argv) {
return false;
}
return argv.some((arg, index) => arg === "sessions" && argv[index + 1] === "export-trajectory");
}
function isTrajectoryExportApproval(entry: TrajectoryExportApprovalEntry): boolean {
return getTrajectoryExportApprovalCommands(entry).some((command) =>
command.includes("sessions export-trajectory"),
);
if (
hasTrajectoryExportArgv(entry.request?.commandArgv) ||
hasTrajectoryExportArgv(entry.commandArgv)
) {
return true;
}
return getTrajectoryExportApprovalCommands(entry).some((command) => {
const normalized = command.replaceAll(/['"]/gu, "");
return normalized.includes("sessions export-trajectory");
});
}
function summarizeTrajectoryExportApproval(
@@ -401,9 +415,7 @@ async function approveTrajectoryExport(
`expected trajectory export approval id; approvals=${JSON.stringify(lastApprovalSummaries)}`,
);
}
expect(getTrajectoryExportApprovalCommands(approval).join("\n")).toContain(
"sessions export-trajectory",
);
expect(isTrajectoryExportApproval(approval)).toBe(true);
await client.request(
"exec.approval.resolve",
{ id: approval.id, decision: "allow-once" },