test: inject thread-safe gateway and ACP seams

This commit is contained in:
Peter Steinberger
2026-03-23 04:34:42 -07:00
parent d841d02439
commit 6bcd9a801a
10 changed files with 330 additions and 101 deletions

View File

@@ -3,16 +3,21 @@ import {
listSkillCommandsForAgents,
} from "openclaw/plugin-sdk/command-auth";
import { loadConfig, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
import { loadSessionStore } from "openclaw/plugin-sdk/config-runtime";
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime";
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
import { deliverReplies, emitInternalMessageSentHook } from "./bot/delivery.js";
import { createTelegramDraftStream } from "./draft-stream.js";
import { editMessageTelegram } from "./send.js";
import { wasSentByBot } from "./sent-message-cache.js";
export type TelegramBotDeps = {
loadConfig: typeof loadConfig;
resolveStorePath: typeof resolveStorePath;
loadSessionStore?: typeof loadSessionStore;
readChannelAllowFromStore: typeof readChannelAllowFromStore;
upsertChannelPairingRequest: typeof upsertChannelPairingRequest;
enqueueSystemEvent: typeof enqueueSystemEvent;
@@ -21,6 +26,10 @@ export type TelegramBotDeps = {
buildModelsProviderData: typeof buildModelsProviderData;
listSkillCommandsForAgents: typeof listSkillCommandsForAgents;
wasSentByBot: typeof wasSentByBot;
createTelegramDraftStream?: typeof createTelegramDraftStream;
deliverReplies?: typeof deliverReplies;
emitInternalMessageSentHook?: typeof emitInternalMessageSentHook;
editMessageTelegram?: typeof editMessageTelegram;
};
export const defaultTelegramBotDeps: TelegramBotDeps = {
@@ -33,6 +42,9 @@ export const defaultTelegramBotDeps: TelegramBotDeps = {
get readChannelAllowFromStore() {
return readChannelAllowFromStore;
},
get loadSessionStore() {
return loadSessionStore;
},
get upsertChannelPairingRequest() {
return upsertChannelPairingRequest;
},
@@ -54,4 +66,16 @@ export const defaultTelegramBotDeps: TelegramBotDeps = {
get wasSentByBot() {
return wasSentByBot;
},
get createTelegramDraftStream() {
return createTelegramDraftStream;
},
get deliverReplies() {
return deliverReplies;
},
get emitInternalMessageSentHook() {
return emitInternalMessageSentHook;
},
get editMessageTelegram() {
return editMessageTelegram;
},
};

View File

@@ -1,4 +1,3 @@
import path from "node:path";
import type { Bot } from "grammy";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TelegramBotDeps } from "./bot-deps.js";
@@ -98,6 +97,7 @@ let dispatchTelegramMessage: typeof import("./bot-message-dispatch.js").dispatch
const telegramDepsForTest: TelegramBotDeps = {
loadConfig: loadConfig as TelegramBotDeps["loadConfig"],
resolveStorePath: resolveStorePath as TelegramBotDeps["resolveStorePath"],
loadSessionStore: loadSessionStore as TelegramBotDeps["loadSessionStore"],
readChannelAllowFromStore:
readChannelAllowFromStore as TelegramBotDeps["readChannelAllowFromStore"],
upsertChannelPairingRequest:
@@ -109,6 +109,12 @@ const telegramDepsForTest: TelegramBotDeps = {
listSkillCommandsForAgents:
listSkillCommandsForAgents as TelegramBotDeps["listSkillCommandsForAgents"],
wasSentByBot: wasSentByBot as TelegramBotDeps["wasSentByBot"],
createTelegramDraftStream:
createTelegramDraftStream as TelegramBotDeps["createTelegramDraftStream"],
deliverReplies: deliverReplies as TelegramBotDeps["deliverReplies"],
emitInternalMessageSentHook:
emitInternalMessageSentHook as TelegramBotDeps["emitInternalMessageSentHook"],
editMessageTelegram: editMessageTelegram as TelegramBotDeps["editMessageTelegram"],
};
describe("dispatchTelegramMessage draft streaming", () => {
@@ -211,8 +217,11 @@ describe("dispatchTelegramMessage draft streaming", () => {
function createBot(): Bot {
return {
api: {
sendMessage: vi.fn(),
editMessageText: vi.fn(),
sendMessage: vi.fn(async (_chatId, _text, params) => ({
message_id:
typeof params?.message_thread_id === "number" ? params.message_thread_id : 1001,
})),
editMessageText: vi.fn(async () => ({ message_id: 1001 })),
deleteMessage: vi.fn().mockResolvedValue(true),
editForumTopic: vi.fn().mockResolvedValue(true),
},

View File

@@ -126,14 +126,17 @@ function resolveTelegramReasoningLevel(params: {
cfg: OpenClawConfig;
sessionKey?: string;
agentId: string;
telegramDeps: TelegramBotDeps;
}): TelegramReasoningLevel {
const { cfg, sessionKey, agentId } = params;
const { cfg, sessionKey, agentId, telegramDeps } = params;
if (!sessionKey) {
return "off";
}
try {
const storePath = resolveStorePath(cfg.session?.store, { agentId });
const store = loadSessionStore(storePath, { skipCache: true });
const storePath = telegramDeps.resolveStorePath(cfg.session?.store, { agentId });
const store = (telegramDeps.loadSessionStore ?? loadSessionStore)(storePath, {
skipCache: true,
});
const entry = resolveSessionStoreEntry({ store, sessionKey }).existing;
const level = entry?.reasoningLevel;
if (level === "on" || level === "stream") {
@@ -195,6 +198,7 @@ export const dispatchTelegramMessage = async ({
cfg,
sessionKey: ctxPayload.SessionKey,
agentId: route.agentId,
telegramDeps,
});
const forceBlockStreamingForReasoning = resolvedReasoningLevel === "on";
const streamReasoningDraft = resolvedReasoningLevel === "stream";
@@ -214,7 +218,7 @@ export const dispatchTelegramMessage = async ({
const archivedReasoningPreviewIds: number[] = [];
const createDraftLane = (laneName: LaneName, enabled: boolean): DraftLaneState => {
const stream = enabled
? createTelegramDraftStream({
? (telegramDeps.createTelegramDraftStream ?? createTelegramDraftStream)({
api: bot.api,
chatId,
maxChars: draftMaxChars,
@@ -476,7 +480,7 @@ export const dispatchTelegramMessage = async ({
return { ...payload, text };
};
const sendPayload = async (payload: ReplyPayload) => {
const result = await deliverReplies({
const result = await (telegramDeps.deliverReplies ?? deliverReplies)({
...deliveryBaseOptions,
replies: [payload],
onVoiceRecording: sendRecordVoice,
@@ -492,7 +496,7 @@ export const dispatchTelegramMessage = async ({
if (result.kind !== "preview-finalized") {
return;
}
emitInternalMessageSentHook({
(telegramDeps.emitInternalMessageSentHook ?? emitInternalMessageSentHook)({
sessionKeyForInternalHooks: deliveryBaseOptions.sessionKeyForInternalHooks,
chatId: deliveryBaseOptions.chatId,
accountId: deliveryBaseOptions.accountId,
@@ -516,7 +520,7 @@ export const dispatchTelegramMessage = async ({
await lane.stream?.stop();
},
editPreview: async ({ messageId, text, previewButtons }) => {
await editMessageTelegram(chatId, messageId, text, {
await (telegramDeps.editMessageTelegram ?? editMessageTelegram)(chatId, messageId, text, {
api: bot.api,
cfg,
accountId: route.accountId,
@@ -542,8 +546,12 @@ export const dispatchTelegramMessage = async ({
let isFirstTurnInSession = false;
if (isDmTopic) {
try {
const storePath = resolveStorePath(cfg.session?.store, { agentId: route.agentId });
const store = loadSessionStore(storePath, { skipCache: true });
const storePath = telegramDeps.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const store = (telegramDeps.loadSessionStore ?? loadSessionStore)(storePath, {
skipCache: true,
});
const sessionKey = ctxPayload.SessionKey;
if (sessionKey) {
const entry = resolveSessionStoreEntry({ store, sessionKey }).existing;
@@ -864,7 +872,7 @@ export const dispatchTelegramMessage = async ({
const fallbackText = dispatchError
? "Something went wrong while processing your request. Please try again."
: EMPTY_RESPONSE_FALLBACK;
const result = await deliverReplies({
const result = await (telegramDeps.deliverReplies ?? deliverReplies)({
replies: [{ text: fallbackText }],
...deliveryBaseOptions,
silent: silentErrorReplies && (dispatchError != null || hadErrorReplyFailureOrSkip),

View File

@@ -26,4 +26,7 @@ export const __testing = {
resetAcpSessionManagerForTests() {
ACP_SESSION_MANAGER_SINGLETON = null;
},
setAcpSessionManagerForTests(manager: unknown) {
ACP_SESSION_MANAGER_SINGLETON = manager as AcpSessionManager | null;
},
};

View File

@@ -151,6 +151,7 @@ async function executeSend(params: {
}) {
const tool = createMessageTool({
config: {} as never,
runMessageAction: mocks.runMessageAction as never,
...params.toolOptions,
});
await tool.execute("1", {
@@ -187,6 +188,9 @@ describe("message tool secret scoping", () => {
const tool = createMessageTool({
currentChannelProvider: "discord",
agentAccountId: "ops",
loadConfig: mocks.loadConfig as never,
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway as never,
runMessageAction: mocks.runMessageAction as never,
});
await tool.execute("1", {
@@ -216,6 +220,7 @@ describe("message tool agent routing", () => {
const tool = createMessageTool({
agentSessionKey: "agent:alpha:main",
config: {} as never,
runMessageAction: mocks.runMessageAction as never,
});
await tool.execute("1", {

View File

@@ -386,6 +386,9 @@ type MessageToolOptions = {
agentSessionKey?: string;
sessionId?: string;
config?: OpenClawConfig;
loadConfig?: () => OpenClawConfig;
resolveCommandSecretRefsViaGateway?: typeof resolveCommandSecretRefsViaGateway;
runMessageAction?: typeof runMessageAction;
currentChannelId?: string;
currentChannelProvider?: string;
currentThreadTs?: string;
@@ -621,6 +624,10 @@ function buildMessageToolDescription(options?: {
}
export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
const loadConfigForTool = options?.loadConfig ?? loadConfig;
const resolveSecretRefsForTool =
options?.resolveCommandSecretRefsViaGateway ?? resolveCommandSecretRefsViaGateway;
const runMessageActionForTool = options?.runMessageAction ?? runMessageAction;
const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
const resolvedAgentId = options?.agentSessionKey
? resolveSessionAgentId({
@@ -683,7 +690,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
}) as ChannelMessageActionName;
let cfg = options?.config;
if (!cfg) {
const loadedRaw = loadConfig();
const loadedRaw = loadConfigForTool();
const scope = resolveMessageSecretScope({
channel: params.channel,
target: params.target,
@@ -698,7 +705,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
accountId: scope.accountId,
});
cfg = (
await resolveCommandSecretRefsViaGateway({
await resolveSecretRefsForTool({
config: loadedRaw,
commandName: "tools.message",
targetIds: scopedTargets.targetIds,
@@ -765,7 +772,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
}
: undefined;
const result = await runMessageAction({
const result = await runMessageActionForTool({
cfg,
action,
params,

View File

@@ -9,6 +9,28 @@ import type { OpenClawConfig } from "../../config/config.js";
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
import { DEFAULT_ACCOUNT_ID, isAcpSessionKey } from "../../routing/session-key.js";
const acpResetTargetDeps = {
getSessionBindingService,
listAcpBindings,
resolveConfiguredBindingRecord,
};
export const __testing = {
setDepsForTest(
overrides?: Partial<{
getSessionBindingService: typeof getSessionBindingService;
listAcpBindings: typeof listAcpBindings;
resolveConfiguredBindingRecord: typeof resolveConfiguredBindingRecord;
}>,
) {
acpResetTargetDeps.getSessionBindingService =
overrides?.getSessionBindingService ?? getSessionBindingService;
acpResetTargetDeps.listAcpBindings = overrides?.listAcpBindings ?? listAcpBindings;
acpResetTargetDeps.resolveConfiguredBindingRecord =
overrides?.resolveConfiguredBindingRecord ?? resolveConfiguredBindingRecord;
},
};
function normalizeText(value: string | undefined | null): string {
return value?.trim() ?? "";
}
@@ -20,7 +42,7 @@ function resolveRawConfiguredAcpSessionKey(params: {
conversationId: string;
parentConversationId?: string;
}): string | undefined {
for (const binding of listAcpBindings(params.cfg)) {
for (const binding of acpResetTargetDeps.listAcpBindings(params.cfg)) {
const bindingChannel = normalizeText(binding.match.channel).toLowerCase();
if (!bindingChannel || bindingChannel !== params.channel) {
continue;
@@ -84,7 +106,7 @@ export function resolveEffectiveResetTargetSessionKey(params: {
const parentConversationId = normalizeText(params.parentConversationId) || undefined;
const allowNonAcpBindingSessionKey = Boolean(params.allowNonAcpBindingSessionKey);
const serviceBinding = getSessionBindingService().resolveByConversation({
const serviceBinding = acpResetTargetDeps.getSessionBindingService().resolveByConversation({
channel,
accountId,
conversationId,
@@ -103,7 +125,7 @@ export function resolveEffectiveResetTargetSessionKey(params: {
return undefined;
}
const configuredBinding = resolveConfiguredBindingRecord({
const configuredBinding = acpResetTargetDeps.resolveConfiguredBindingRecord({
cfg: params.cfg,
channel,
accountId,

View File

@@ -114,6 +114,7 @@ vi.mock("../../../extensions/discord/src/monitor/gateway-plugin.js", () => ({
const { handleAcpCommand } = await import("./commands-acp.js");
const { buildCommandTestParams } = await import("./commands-spawn.test-harness.js");
const { __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js");
const { __testing: acpResetTargetTesting } = await import("./acp-reset-target.js");
type FakeBinding = {
bindingId: string;
@@ -435,6 +436,9 @@ describe("/acp command", () => {
beforeEach(() => {
setDefaultChannelPluginRegistryForTests();
acpManagerTesting.resetAcpSessionManagerForTests();
acpResetTargetTesting.setDepsForTest({
getSessionBindingService: () => createAcpCommandSessionBindingService() as never,
});
hoisted.listAcpSessionEntriesMock.mockReset().mockResolvedValue([]);
hoisted.callGatewayMock.mockReset().mockResolvedValue({ ok: true });
hoisted.readAcpSessionEntryMock.mockReset().mockReturnValue(null);
@@ -507,6 +511,150 @@ describe("/acp command", () => {
};
hoisted.requireAcpRuntimeBackendMock.mockReset().mockReturnValue(runtimeBackend);
hoisted.getAcpRuntimeBackendMock.mockReset().mockReturnValue(runtimeBackend);
acpManagerTesting.setAcpSessionManagerForTests({
initializeSession: async (input: {
sessionKey: string;
agent: string;
mode: "persistent" | "oneshot";
cwd?: string;
}) => {
const backend = hoisted.requireAcpRuntimeBackendMock("acpx") as {
id?: string;
runtime: typeof runtimeBackend.runtime;
};
const ensured = await hoisted.ensureSessionMock({
sessionKey: input.sessionKey,
agent: input.agent,
mode: input.mode,
cwd: input.cwd,
});
const now = Date.now();
const meta = {
backend: ensured.backend ?? "acpx",
agent: input.agent,
runtimeSessionName: ensured.runtimeSessionName ?? `${input.sessionKey}:runtime`,
mode: input.mode,
state: "idle" as const,
lastActivityAt: now,
...(input.cwd ? { cwd: input.cwd, runtimeOptions: { cwd: input.cwd } } : {}),
...(typeof ensured.agentSessionId === "string" ||
typeof ensured.backendSessionId === "string"
? {
identity: {
state: "resolved" as const,
source: "status" as const,
acpxSessionId:
typeof ensured.backendSessionId === "string"
? ensured.backendSessionId
: "acpx-1",
agentSessionId:
typeof ensured.agentSessionId === "string"
? ensured.agentSessionId
: input.sessionKey,
lastUpdatedAt: now,
},
}
: {}),
};
await hoisted.upsertAcpSessionMetaMock({
sessionKey: input.sessionKey,
mutate: () => meta,
});
return {
runtime: backend.runtime,
handle: {
backend: meta.backend,
runtimeSessionName: meta.runtimeSessionName,
},
meta,
};
},
resolveSession: (input: { sessionKey: string }) => {
const entry = hoisted.readAcpSessionEntryMock({
sessionKey: input.sessionKey,
}) as { acp?: Record<string, unknown> } | null;
const meta =
entry?.acp ??
({
backend: "acpx",
agent: "codex",
runtimeSessionName: `${input.sessionKey}:runtime`,
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
} as const);
return {
kind: "ready" as const,
sessionKey: input.sessionKey,
meta,
};
},
cancelSession: async (input: unknown) => {
await hoisted.cancelMock(input);
},
getSessionStatus: async (input: { sessionKey: string }) => {
const status = await hoisted.getStatusMock(input);
const entry = hoisted.readAcpSessionEntryMock({
sessionKey: input.sessionKey,
}) as { acp?: Record<string, unknown> } | null;
const meta = entry?.acp ?? {};
return {
sessionKey: input.sessionKey,
backend: typeof meta.backend === "string" ? meta.backend : "acpx",
agent: typeof meta.agent === "string" ? meta.agent : "codex",
identity: meta.identity,
state: meta.state ?? "idle",
mode: meta.mode ?? "persistent",
runtimeOptions: meta.runtimeOptions ?? {},
capabilities: {
controls: ["session/set_mode", "session/set_config_option", "session/status"],
},
runtimeStatus: status,
lastActivityAt:
typeof meta.lastActivityAt === "number" ? meta.lastActivityAt : Date.now(),
...(typeof meta.lastError === "string" ? { lastError: meta.lastError } : {}),
};
},
getObservabilitySnapshot: () => ({
runtimeCache: { activeSessions: 0, idleTtlMs: 0, evictedTotal: 0 },
turns: {
active: 0,
queueDepth: 0,
completed: 0,
failed: 0,
averageLatencyMs: 0,
maxLatencyMs: 0,
},
errorsByCode: {},
}),
runTurn: async (input: { onEvent?: (event: unknown) => Promise<void> | void }) => {
for await (const event of hoisted.runTurnMock(input) as AsyncIterable<unknown>) {
await input.onEvent?.(event);
}
},
setSessionRuntimeMode: async (input: { sessionKey: string; runtimeMode: string }) => {
await hoisted.setModeMock(input);
return { mode: input.runtimeMode };
},
setSessionConfigOption: async (input: { key: string; value: string }) => {
await hoisted.setConfigOptionMock(input);
return { [input.key]: input.value };
},
updateSessionRuntimeOptions: async (input: { patch: Record<string, unknown> }) => input.patch,
closeSession: async (input: { clearMeta?: boolean; sessionKey: string }) => {
await hoisted.closeMock(input);
if (input.clearMeta === true) {
await hoisted.upsertAcpSessionMetaMock({
sessionKey: input.sessionKey,
mutate: () => null,
});
}
return {
runtimeClosed: true,
metaCleared: input.clearMeta === true,
};
},
});
});
it("returns null when the message is not /acp", async () => {
@@ -732,17 +880,8 @@ describe("/acp command", () => {
const result = await runDiscordAcpCommand("/acp spawn codex", cfg);
expect(result?.reply?.text).toContain("spawnAcpSessions=true");
expect(hoisted.closeMock).toHaveBeenCalledTimes(1);
expect(hoisted.callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "sessions.delete",
params: expect.objectContaining({
key: expect.stringMatching(/^agent:codex:acp:/),
deleteTranscript: false,
emitLifecycleHooks: false,
}),
}),
);
expect(hoisted.closeMock).toHaveBeenCalledTimes(2);
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
expect(hoisted.callGatewayMock).not.toHaveBeenCalledWith(
expect.objectContaining({ method: "sessions.patch" }),
);
@@ -792,11 +931,9 @@ describe("/acp command", () => {
`Cancel requested for ACP session ${defaultAcpSessionKey}`,
);
expect(hoisted.cancelMock).toHaveBeenCalledWith({
handle: expect.objectContaining({
sessionKey: defaultAcpSessionKey,
backend: "acpx",
}),
cfg: baseCfg,
reason: "manual-cancel",
sessionKey: defaultAcpSessionKey,
});
});
@@ -852,10 +989,9 @@ describe("/acp command", () => {
expect(hoisted.runTurnMock).toHaveBeenCalledWith(
expect.objectContaining({
handle: expect.objectContaining({
sessionKey: defaultAcpSessionKey,
}),
cfg: baseCfg,
mode: "steer",
sessionKey: defaultAcpSessionKey,
text: "use npm to view package diver",
}),
);
@@ -951,7 +1087,9 @@ describe("/acp command", () => {
expect(hoisted.setModeMock).toHaveBeenCalledWith(
expect.objectContaining({
mode: "plan",
cfg: baseCfg,
runtimeMode: "plan",
sessionKey: defaultAcpSessionKey,
}),
);
expect(result?.reply?.text).toContain("Updated ACP runtime mode");
@@ -1009,7 +1147,8 @@ describe("/acp command", () => {
expect(hoisted.setModeMock).toHaveBeenCalledWith(
expect.objectContaining({
mode: "plan",
cfg: baseCfg,
runtimeMode: "plan",
}),
);
expect(result?.reply?.text).toContain("Updated ACP runtime mode");

View File

@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
pruneAgentConfig: vi.fn(() => ({ config: {}, removedBindings: 0 })),
writeConfigFile: vi.fn(async () => {}),
ensureAgentWorkspace: vi.fn(async () => {}),
isWorkspaceSetupCompleted: vi.fn(async () => false),
resolveAgentDir: vi.fn(() => "/agents/test-agent"),
resolveAgentWorkspaceDir: vi.fn(() => "/workspace/test-agent"),
resolveSessionTranscriptsDirForAgent: vi.fn(() => "/transcripts/test-agent"),
@@ -30,6 +31,7 @@ const mocks = vi.hoisted(() => ({
fsStat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null),
fsLstat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null),
fsRealpath: vi.fn(async (p: string) => p),
fsReadlink: vi.fn(async () => ""),
fsOpen: vi.fn(async () => ({}) as unknown),
writeFileWithinRoot: vi.fn(async () => {}),
}));
@@ -59,6 +61,7 @@ vi.mock("../../agents/workspace.js", async () => {
return {
...actual,
ensureAgentWorkspace: mocks.ensureAgentWorkspace,
isWorkspaceSetupCompleted: mocks.isWorkspaceSetupCompleted,
};
});
@@ -101,6 +104,7 @@ vi.mock("node:fs/promises", async () => {
stat: mocks.fsStat,
lstat: mocks.fsLstat,
realpath: mocks.fsRealpath,
readlink: mocks.fsReadlink,
open: mocks.fsOpen,
};
return { ...patched, default: patched };
@@ -110,12 +114,16 @@ vi.mock("node:fs/promises", async () => {
/* Import after mocks are set up */
/* ------------------------------------------------------------------ */
const { agentsHandlers } = await import("./agents.js");
const { __testing: agentsTesting, agentsHandlers } = await import("./agents.js");
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
beforeEach(() => {
agentsTesting.resetDepsForTests();
});
function makeCall(method: keyof typeof agentsHandlers, params: Record<string, unknown>) {
const respond = vi.fn();
const handler = agentsHandlers[method];
@@ -160,36 +168,32 @@ function makeFileStat(params?: {
} as unknown as import("node:fs").Stats;
}
function makeSymlinkStat(params?: { dev?: number; ino?: number }): import("node:fs").Stats {
return {
isFile: () => false,
isSymbolicLink: () => true,
size: 0,
mtimeMs: 0,
dev: params?.dev ?? 1,
ino: params?.ino ?? 2,
} as unknown as import("node:fs").Stats;
}
function mockWorkspaceStateRead(params: {
setupCompletedAt?: string;
errorCode?: string;
rawContent?: string;
}) {
mocks.fsReadFile.mockImplementation(async (...args: unknown[]) => {
const filePath = args[0];
if (String(filePath).endsWith("workspace-state.json")) {
agentsTesting.setDepsForTests({
isWorkspaceSetupCompleted: async () => {
if (params.errorCode) {
throw createErrnoError(params.errorCode);
}
if (typeof params.rawContent === "string") {
return params.rawContent;
throw new SyntaxError("Expected property name or '}' in JSON");
}
return JSON.stringify({
setupCompletedAt: params.setupCompletedAt ?? "2026-02-15T14:00:00.000Z",
});
return (
typeof params.setupCompletedAt === "string" && params.setupCompletedAt.trim().length > 0
);
},
});
mocks.isWorkspaceSetupCompleted.mockImplementation(async () => {
if (params.errorCode) {
throw createErrnoError(params.errorCode);
}
throw createEnoentError();
if (typeof params.rawContent === "string") {
throw new SyntaxError("Expected property name or '}' in JSON");
}
return typeof params.setupCompletedAt === "string" && params.setupCompletedAt.trim().length > 0;
});
}
@@ -505,6 +509,8 @@ describe("agents.files.list", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.loadConfigReturn = {};
mocks.isWorkspaceSetupCompleted.mockReset().mockResolvedValue(false);
mocks.fsReadlink.mockReset().mockResolvedValue("");
});
it("includes BOOTSTRAP.md when setup has not completed", async () => {
@@ -543,22 +549,12 @@ describe("agents.files.get/set symlink safety", () => {
function mockWorkspaceEscapeSymlink() {
const workspace = "/workspace/test-agent";
const candidate = path.resolve(workspace, "AGENTS.md");
mocks.fsRealpath.mockImplementation(async (p: string) => {
if (p === workspace) {
return workspace;
}
if (p === candidate) {
return "/outside/secret.txt";
}
return p;
});
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
const p = typeof args[0] === "string" ? args[0] : "";
if (p === candidate) {
return makeSymlinkStat();
}
throw createEnoentError();
agentsTesting.setDepsForTests({
resolveAgentWorkspaceFilePath: async ({ name }) => ({
kind: "invalid",
requestPath: path.join(workspace, name),
reason: "path escapes workspace root",
}),
});
}
@@ -578,24 +574,24 @@ describe("agents.files.get/set symlink safety", () => {
it("allows in-workspace symlink reads and writes through symlink aliases", async () => {
const workspace = "/workspace/test-agent";
const candidate = path.resolve(workspace, "AGENTS.md");
const target = path.resolve(workspace, "policies", "AGENTS.md");
const targetStat = makeFileStat({ size: 7, mtimeMs: 1700, dev: 9, ino: 42 });
mocks.fsRealpath.mockImplementation(async (p: string) => {
if (p === workspace) {
return workspace;
}
if (p === candidate) {
return target;
}
return p;
agentsTesting.setDepsForTests({
readLocalFileSafely: async () => ({
buffer: Buffer.from("inside\n"),
realPath: target,
stat: targetStat,
}),
resolveAgentWorkspaceFilePath: async ({ name }) => ({
kind: "ready",
requestPath: path.join(workspace, name),
ioPath: target,
workspaceReal: workspace,
}),
});
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
const p = typeof args[0] === "string" ? args[0] : "";
if (p === candidate) {
return makeSymlinkStat({ dev: 9, ino: 41 });
}
if (p === target) {
return targetStat;
}
@@ -608,16 +604,6 @@ describe("agents.files.get/set symlink safety", () => {
}
throw createEnoentError();
});
mocks.fsOpen.mockImplementation(
async () =>
({
stat: async () => targetStat,
readFile: async () => Buffer.from("inside\n"),
truncate: async () => {},
writeFile: async () => {},
close: async () => {},
}) as unknown,
);
const getCall = makeCall("agents.files.get", { agentId: "main", name: "AGENTS.md" });
await getCall.promise;

View File

@@ -61,6 +61,32 @@ const BOOTSTRAP_FILE_NAMES_POST_ONBOARDING = BOOTSTRAP_FILE_NAMES.filter(
(name) => name !== DEFAULT_BOOTSTRAP_FILENAME,
);
const agentsHandlerDeps = {
isWorkspaceSetupCompleted,
readLocalFileSafely,
resolveAgentWorkspaceFilePath,
writeFileWithinRoot,
};
export const __testing = {
setDepsForTests(
overrides: Partial<{
isWorkspaceSetupCompleted: typeof isWorkspaceSetupCompleted;
readLocalFileSafely: typeof readLocalFileSafely;
resolveAgentWorkspaceFilePath: typeof resolveAgentWorkspaceFilePath;
writeFileWithinRoot: typeof writeFileWithinRoot;
}>,
) {
Object.assign(agentsHandlerDeps, overrides);
},
resetDepsForTests() {
agentsHandlerDeps.isWorkspaceSetupCompleted = isWorkspaceSetupCompleted;
agentsHandlerDeps.readLocalFileSafely = readLocalFileSafely;
agentsHandlerDeps.resolveAgentWorkspaceFilePath = resolveAgentWorkspaceFilePath;
agentsHandlerDeps.writeFileWithinRoot = writeFileWithinRoot;
},
};
const MEMORY_FILE_NAMES = [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME] as const;
const ALLOWED_FILE_NAMES = new Set<string>([...BOOTSTRAP_FILE_NAMES, ...MEMORY_FILE_NAMES]);
@@ -417,7 +443,7 @@ async function resolveWorkspaceFilePathOrRespond(params: {
workspaceDir: string;
name: string;
}): Promise<ResolvedWorkspaceFilePath | undefined> {
const resolvedPath = await resolveAgentWorkspaceFilePath({
const resolvedPath = await agentsHandlerDeps.resolveAgentWorkspaceFilePath({
workspaceDir: params.workspaceDir,
name: params.name,
allowMissing: true,
@@ -653,7 +679,7 @@ export const agentsHandlers: GatewayRequestHandlers = {
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
let hideBootstrap = false;
try {
hideBootstrap = await isWorkspaceSetupCompleted(workspaceDir);
hideBootstrap = await agentsHandlerDeps.isWorkspaceSetupCompleted(workspaceDir);
} catch {
// Fall back to showing BOOTSTRAP if workspace state cannot be read.
}
@@ -685,7 +711,7 @@ export const agentsHandlers: GatewayRequestHandlers = {
}
let safeRead: Awaited<ReturnType<typeof readLocalFileSafely>>;
try {
safeRead = await readLocalFileSafely({ filePath: resolvedPath.ioPath });
safeRead = await agentsHandlerDeps.readLocalFileSafely({ filePath: resolvedPath.ioPath });
} catch (err) {
if (err instanceof SafeOpenError && err.code === "not-found") {
respondWorkspaceFileMissing({ respond, agentId, workspaceDir, name, filePath });
@@ -742,7 +768,7 @@ export const agentsHandlers: GatewayRequestHandlers = {
return;
}
try {
await writeFileWithinRoot({
await agentsHandlerDeps.writeFileWithinRoot({
rootDir: resolvedPath.workspaceReal,
relativePath: relativeWritePath,
data: content,