Files
openclaw/src/gateway/server-methods/agent.test.ts
Vincent Koc 46a332385d Gateway: keep spawned workspace overrides internal (#43801)
* Gateway: keep spawned workspace overrides internal

* Changelog: note GHSA-2rqg agent boundary fix

* Gateway: persist spawned workspace inheritance in sessions

* Agents: clean failed lineage spawn state

* Tests: cover lineage attachment cleanup

* Tests: cover lineage thread cleanup
2026-03-12 04:20:00 -04:00

653 lines
19 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { BARE_SESSION_RESET_PROMPT } from "../../auto-reply/reply/session-reset-prompt.js";
import { agentHandlers } from "./agent.js";
import type { GatewayRequestContext } from "./types.js";
const mocks = vi.hoisted(() => ({
loadSessionEntry: vi.fn(),
updateSessionStore: vi.fn(),
agentCommand: vi.fn(),
registerAgentRunContext: vi.fn(),
performGatewaySessionReset: vi.fn(),
loadConfigReturn: {} as Record<string, unknown>,
}));
vi.mock("../session-utils.js", async () => {
const actual = await vi.importActual<typeof import("../session-utils.js")>("../session-utils.js");
return {
...actual,
loadSessionEntry: mocks.loadSessionEntry,
};
});
vi.mock("../../config/sessions.js", async () => {
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
"../../config/sessions.js",
);
return {
...actual,
updateSessionStore: mocks.updateSessionStore,
resolveAgentIdFromSessionKey: () => "main",
resolveExplicitAgentSessionKey: () => undefined,
resolveAgentMainSessionKey: ({
cfg,
agentId,
}: {
cfg?: { session?: { mainKey?: string } };
agentId: string;
}) => `agent:${agentId}:${cfg?.session?.mainKey ?? "main"}`,
};
});
vi.mock("../../commands/agent.js", () => ({
agentCommand: mocks.agentCommand,
agentCommandFromIngress: mocks.agentCommand,
}));
vi.mock("../../config/config.js", async () => {
const actual =
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
return {
...actual,
loadConfig: () => mocks.loadConfigReturn,
};
});
vi.mock("../../agents/agent-scope.js", () => ({
listAgentIds: () => ["main"],
}));
vi.mock("../../infra/agent-events.js", () => ({
registerAgentRunContext: mocks.registerAgentRunContext,
onAgentEvent: vi.fn(),
}));
vi.mock("../session-reset-service.js", () => ({
performGatewaySessionReset: (...args: unknown[]) =>
(mocks.performGatewaySessionReset as (...args: unknown[]) => unknown)(...args),
}));
vi.mock("../../sessions/send-policy.js", () => ({
resolveSendPolicy: () => "allow",
}));
vi.mock("../../utils/delivery-context.js", async () => {
const actual = await vi.importActual<typeof import("../../utils/delivery-context.js")>(
"../../utils/delivery-context.js",
);
return {
...actual,
normalizeSessionDeliveryFields: () => ({}),
};
});
const makeContext = (): GatewayRequestContext =>
({
dedupe: new Map(),
addChatRun: vi.fn(),
logGateway: { info: vi.fn(), error: vi.fn() },
}) as unknown as GatewayRequestContext;
type AgentHandlerArgs = Parameters<typeof agentHandlers.agent>[0];
type AgentParams = AgentHandlerArgs["params"];
type AgentIdentityGetHandlerArgs = Parameters<(typeof agentHandlers)["agent.identity.get"]>[0];
type AgentIdentityGetParams = AgentIdentityGetHandlerArgs["params"];
function mockMainSessionEntry(entry: Record<string, unknown>, cfg: Record<string, unknown> = {}) {
mocks.loadSessionEntry.mockReturnValue({
cfg,
storePath: "/tmp/sessions.json",
entry: {
sessionId: "existing-session-id",
updatedAt: Date.now(),
...entry,
},
canonicalKey: "agent:main:main",
});
}
function captureUpdatedMainEntry() {
let capturedEntry: Record<string, unknown> | undefined;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
const store: Record<string, unknown> = {};
await updater(store);
capturedEntry = store["agent:main:main"] as Record<string, unknown>;
});
return () => capturedEntry;
}
function buildExistingMainStoreEntry(overrides: Record<string, unknown> = {}) {
return {
sessionId: "existing-session-id",
updatedAt: Date.now(),
...overrides,
};
}
async function runMainAgentAndCaptureEntry(idempotencyKey: string) {
const getCapturedEntry = captureUpdatedMainEntry();
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
await runMainAgent("test", idempotencyKey);
expect(mocks.updateSessionStore).toHaveBeenCalled();
return getCapturedEntry();
}
function setupNewYorkTimeConfig(isoDate: string) {
vi.useFakeTimers();
vi.setSystemTime(new Date(isoDate)); // Wed Jan 28, 8:30 PM EST
mocks.agentCommand.mockClear();
mocks.loadConfigReturn = {
agents: {
defaults: {
userTimezone: "America/New_York",
},
},
};
}
function resetTimeConfig() {
mocks.loadConfigReturn = {};
vi.useRealTimers();
}
async function expectResetCall(expectedMessage: string) {
await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled());
expect(mocks.performGatewaySessionReset).toHaveBeenCalledTimes(1);
const call = readLastAgentCommandCall();
expect(call?.message).toBe(expectedMessage);
return call;
}
function primeMainAgentRun(params?: { sessionId?: string; cfg?: Record<string, unknown> }) {
mockMainSessionEntry(
{ sessionId: params?.sessionId ?? "existing-session-id" },
params?.cfg ?? {},
);
mocks.updateSessionStore.mockResolvedValue(undefined);
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
}
async function runMainAgent(message: string, idempotencyKey: string) {
const respond = vi.fn();
await invokeAgent(
{
message,
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey,
},
{ respond, reqId: idempotencyKey },
);
return respond;
}
function readLastAgentCommandCall():
| {
message?: string;
sessionId?: string;
}
| undefined {
return mocks.agentCommand.mock.calls.at(-1)?.[0] as
| { message?: string; sessionId?: string }
| undefined;
}
function mockSessionResetSuccess(params: {
reason: "new" | "reset";
key?: string;
sessionId?: string;
}) {
const key = params.key ?? "agent:main:main";
const sessionId = params.sessionId ?? "reset-session-id";
mocks.performGatewaySessionReset.mockImplementation(
async (opts: { key: string; reason: string; commandSource: string }) => {
expect(opts.key).toBe(key);
expect(opts.reason).toBe(params.reason);
expect(opts.commandSource).toBe("gateway:agent");
return {
ok: true,
key,
entry: { sessionId },
};
},
);
}
async function invokeAgent(
params: AgentParams,
options?: {
respond?: ReturnType<typeof vi.fn>;
reqId?: string;
context?: GatewayRequestContext;
client?: AgentHandlerArgs["client"];
isWebchatConnect?: AgentHandlerArgs["isWebchatConnect"];
},
) {
const respond = options?.respond ?? vi.fn();
await agentHandlers.agent({
params,
respond: respond as never,
context: options?.context ?? makeContext(),
req: { type: "req", id: options?.reqId ?? "agent-test-req", method: "agent" },
client: options?.client ?? null,
isWebchatConnect: options?.isWebchatConnect ?? (() => false),
});
return respond;
}
async function invokeAgentIdentityGet(
params: AgentIdentityGetParams,
options?: {
respond?: ReturnType<typeof vi.fn>;
reqId?: string;
context?: GatewayRequestContext;
},
) {
const respond = options?.respond ?? vi.fn();
await agentHandlers["agent.identity.get"]({
params,
respond: respond as never,
context: options?.context ?? makeContext(),
req: {
type: "req",
id: options?.reqId ?? "agent-identity-test-req",
method: "agent.identity.get",
},
client: null,
isWebchatConnect: () => false,
});
return respond;
}
describe("gateway agent handler", () => {
it("preserves ACP metadata from the current stored session entry", async () => {
const existingAcpMeta = {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
};
mockMainSessionEntry({
acp: existingAcpMeta,
});
let capturedEntry: Record<string, unknown> | undefined;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
const store: Record<string, unknown> = {
"agent:main:main": buildExistingMainStoreEntry({ acp: existingAcpMeta }),
};
const result = await updater(store);
capturedEntry = store["agent:main:main"] as Record<string, unknown>;
return result;
});
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
await runMainAgent("test", "test-idem-acp-meta");
expect(mocks.updateSessionStore).toHaveBeenCalled();
expect(capturedEntry).toBeDefined();
expect(capturedEntry?.acp).toEqual(existingAcpMeta);
});
it("preserves cliSessionIds from existing session entry", async () => {
const existingCliSessionIds = { "claude-cli": "abc-123-def" };
const existingClaudeCliSessionId = "abc-123-def";
mockMainSessionEntry({
cliSessionIds: existingCliSessionIds,
claudeCliSessionId: existingClaudeCliSessionId,
});
const capturedEntry = await runMainAgentAndCaptureEntry("test-idem");
expect(capturedEntry).toBeDefined();
expect(capturedEntry?.cliSessionIds).toEqual(existingCliSessionIds);
expect(capturedEntry?.claudeCliSessionId).toBe(existingClaudeCliSessionId);
});
it("injects a timestamp into the message passed to agentCommand", async () => {
setupNewYorkTimeConfig("2026-01-29T01:30:00.000Z");
primeMainAgentRun({ cfg: mocks.loadConfigReturn });
await invokeAgent(
{
message: "Is it the weekend?",
agentId: "main",
sessionKey: "agent:main:main",
idempotencyKey: "test-timestamp-inject",
},
{ reqId: "ts-1" },
);
// Wait for the async agentCommand call
await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled());
const callArgs = mocks.agentCommand.mock.calls[0][0];
expect(callArgs.message).toBe("[Wed 2026-01-28 20:30 EST] Is it the weekend?");
resetTimeConfig();
});
it.each([
{
name: "passes senderIsOwner=false for write-scoped gateway callers",
scopes: ["operator.write"],
idempotencyKey: "test-sender-owner-write",
senderIsOwner: false,
},
{
name: "passes senderIsOwner=true for admin-scoped gateway callers",
scopes: ["operator.admin"],
idempotencyKey: "test-sender-owner-admin",
senderIsOwner: true,
},
])("$name", async ({ scopes, idempotencyKey, senderIsOwner }) => {
primeMainAgentRun();
await invokeAgent(
{
message: "owner-tools check",
sessionKey: "agent:main:main",
idempotencyKey,
},
{
client: {
connect: {
role: "operator",
scopes,
client: { id: "test-client", mode: "gateway" },
},
} as unknown as AgentHandlerArgs["client"],
},
);
await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled());
const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as
| { senderIsOwner?: boolean }
| undefined;
expect(callArgs?.senderIsOwner).toBe(senderIsOwner);
});
it("respects explicit bestEffortDeliver=false for main session runs", async () => {
mocks.agentCommand.mockClear();
primeMainAgentRun();
await invokeAgent(
{
message: "strict delivery",
agentId: "main",
sessionKey: "agent:main:main",
deliver: true,
replyChannel: "telegram",
to: "123",
bestEffortDeliver: false,
idempotencyKey: "test-strict-delivery",
},
{ reqId: "strict-1" },
);
await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled());
const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(callArgs.bestEffortDeliver).toBe(false);
});
it("rejects public spawned-run metadata fields", async () => {
primeMainAgentRun();
mocks.agentCommand.mockClear();
const respond = vi.fn();
await invokeAgent(
{
message: "spawned run",
sessionKey: "agent:main:main",
spawnedBy: "agent:main:subagent:parent",
workspaceDir: "/tmp/injected",
idempotencyKey: "workspace-rejected",
} as AgentParams,
{ reqId: "workspace-rejected-1", respond },
);
expect(mocks.agentCommand).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: expect.stringContaining("invalid agent params"),
}),
);
});
it("only forwards workspaceDir for spawned sessions with stored workspace inheritance", async () => {
primeMainAgentRun();
mockMainSessionEntry({
spawnedBy: "agent:main:subagent:parent",
spawnedWorkspaceDir: "/tmp/inherited",
});
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
const store: Record<string, unknown> = {
"agent:main:main": buildExistingMainStoreEntry({
spawnedBy: "agent:main:subagent:parent",
spawnedWorkspaceDir: "/tmp/inherited",
}),
};
return await updater(store);
});
mocks.agentCommand.mockClear();
await invokeAgent(
{
message: "spawned run",
sessionKey: "agent:main:main",
idempotencyKey: "workspace-forwarded",
},
{ reqId: "workspace-forwarded-1" },
);
await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled());
const spawnedCall = mocks.agentCommand.mock.calls.at(-1)?.[0] as { workspaceDir?: string };
expect(spawnedCall.workspaceDir).toBe("/tmp/inherited");
});
it("keeps origin messageChannel as webchat while delivery channel uses last session channel", async () => {
mockMainSessionEntry({
sessionId: "existing-session-id",
lastChannel: "telegram",
lastTo: "12345",
});
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
const store: Record<string, unknown> = {
"agent:main:main": buildExistingMainStoreEntry({
lastChannel: "telegram",
lastTo: "12345",
}),
};
return await updater(store);
});
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
await invokeAgent(
{
message: "webchat turn",
sessionKey: "agent:main:main",
idempotencyKey: "test-webchat-origin-channel",
},
{
reqId: "webchat-origin-1",
client: {
connect: {
client: { id: "webchat-ui", mode: "webchat" },
},
} as AgentHandlerArgs["client"],
isWebchatConnect: () => true,
},
);
await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled());
const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as {
channel?: string;
messageChannel?: string;
runContext?: { messageChannel?: string };
};
expect(callArgs.channel).toBe("telegram");
expect(callArgs.messageChannel).toBe("webchat");
expect(callArgs.runContext?.messageChannel).toBe("webchat");
});
it("handles missing cliSessionIds gracefully", async () => {
mockMainSessionEntry({});
const capturedEntry = await runMainAgentAndCaptureEntry("test-idem-2");
expect(capturedEntry).toBeDefined();
// Should be undefined, not cause an error
expect(capturedEntry?.cliSessionIds).toBeUndefined();
expect(capturedEntry?.claudeCliSessionId).toBeUndefined();
});
it("prunes legacy main alias keys when writing a canonical session entry", async () => {
mocks.loadSessionEntry.mockReturnValue({
cfg: {
session: { mainKey: "work" },
agents: { list: [{ id: "main", default: true }] },
},
storePath: "/tmp/sessions.json",
entry: {
sessionId: "existing-session-id",
updatedAt: Date.now(),
},
canonicalKey: "agent:main:work",
});
let capturedStore: Record<string, unknown> | undefined;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
const store: Record<string, unknown> = {
"agent:main:work": { sessionId: "existing-session-id", updatedAt: 10 },
"agent:main:MAIN": { sessionId: "legacy-session-id", updatedAt: 5 },
};
await updater(store);
capturedStore = store;
});
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
await invokeAgent(
{
message: "test",
agentId: "main",
sessionKey: "main",
idempotencyKey: "test-idem-alias-prune",
},
{ reqId: "3" },
);
expect(mocks.updateSessionStore).toHaveBeenCalled();
expect(capturedStore).toBeDefined();
expect(capturedStore?.["agent:main:work"]).toBeDefined();
expect(capturedStore?.["agent:main:MAIN"]).toBeUndefined();
});
it("handles bare /new by resetting the same session and sending reset greeting prompt", async () => {
mockSessionResetSuccess({ reason: "new" });
primeMainAgentRun({ sessionId: "reset-session-id" });
await invokeAgent(
{
message: "/new",
sessionKey: "agent:main:main",
idempotencyKey: "test-idem-new",
},
{ reqId: "4" },
);
await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled());
expect(mocks.performGatewaySessionReset).toHaveBeenCalledTimes(1);
const call = readLastAgentCommandCall();
// Message is now dynamically built with current date — check key substrings
expect(call?.message).toContain("Execute your Session Startup sequence now");
expect(call?.message).toContain("Current time:");
expect(call?.message).not.toBe(BARE_SESSION_RESET_PROMPT);
expect(call?.sessionId).toBe("reset-session-id");
});
it("uses /reset suffix as the post-reset message and still injects timestamp", async () => {
setupNewYorkTimeConfig("2026-01-29T01:30:00.000Z");
mockSessionResetSuccess({ reason: "reset" });
mocks.performGatewaySessionReset.mockClear();
primeMainAgentRun({
sessionId: "reset-session-id",
cfg: mocks.loadConfigReturn,
});
await invokeAgent(
{
message: "/reset check status",
sessionKey: "agent:main:main",
idempotencyKey: "test-idem-reset-suffix",
},
{ reqId: "4b" },
);
const call = await expectResetCall("[Wed 2026-01-28 20:30 EST] check status");
expect(call?.sessionId).toBe("reset-session-id");
resetTimeConfig();
});
it("rejects malformed agent session keys early in agent handler", async () => {
mocks.agentCommand.mockClear();
const respond = await invokeAgent(
{
message: "test",
sessionKey: "agent:main",
idempotencyKey: "test-malformed-session-key",
},
{ reqId: "4" },
);
expect(mocks.agentCommand).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: expect.stringContaining("malformed session key"),
}),
);
});
it("rejects malformed session keys in agent.identity.get", async () => {
const respond = await invokeAgentIdentityGet(
{
sessionKey: "agent:main",
},
{ reqId: "5" },
);
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: expect.stringContaining("malformed session key"),
}),
);
});
});