Merge branch 'main' into ui/dashboard-v2.1
This commit is contained in:
commit
6f62a6eceb
@ -254,6 +254,44 @@ describe("waitForExit", () => {
|
||||
});
|
||||
|
||||
describe("spawnAndCollect", () => {
|
||||
type SpawnedEnvSnapshot = {
|
||||
openai?: string;
|
||||
github?: string;
|
||||
hf?: string;
|
||||
openclaw?: string;
|
||||
shell?: string;
|
||||
};
|
||||
|
||||
function stubProviderAuthEnv(env: Record<string, string>) {
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
vi.stubEnv(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectSpawnedEnvSnapshot(options?: {
|
||||
stripProviderAuthEnvVars?: boolean;
|
||||
openAiEnvKey?: string;
|
||||
githubEnvKey?: string;
|
||||
hfEnvKey?: string;
|
||||
}): Promise<SpawnedEnvSnapshot> {
|
||||
const openAiEnvKey = options?.openAiEnvKey ?? "OPENAI_API_KEY";
|
||||
const githubEnvKey = options?.githubEnvKey ?? "GITHUB_TOKEN";
|
||||
const hfEnvKey = options?.hfEnvKey ?? "HF_TOKEN";
|
||||
const result = await spawnAndCollect({
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
`process.stdout.write(JSON.stringify({openai:process.env.${openAiEnvKey},github:process.env.${githubEnvKey},hf:process.env.${hfEnvKey},openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))`,
|
||||
],
|
||||
cwd: process.cwd(),
|
||||
stripProviderAuthEnvVars: options?.stripProviderAuthEnvVars,
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.error).toBeNull();
|
||||
return JSON.parse(result.stdout) as SpawnedEnvSnapshot;
|
||||
}
|
||||
|
||||
it("returns abort error immediately when signal is already aborted", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
@ -292,31 +330,15 @@ describe("spawnAndCollect", () => {
|
||||
});
|
||||
|
||||
it("strips shared provider auth env vars from spawned acpx children", async () => {
|
||||
vi.stubEnv("OPENAI_API_KEY", "openai-secret");
|
||||
vi.stubEnv("GITHUB_TOKEN", "gh-secret");
|
||||
vi.stubEnv("HF_TOKEN", "hf-secret");
|
||||
vi.stubEnv("OPENCLAW_API_KEY", "keep-me");
|
||||
|
||||
const result = await spawnAndCollect({
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
"process.stdout.write(JSON.stringify({openai:process.env.OPENAI_API_KEY,github:process.env.GITHUB_TOKEN,hf:process.env.HF_TOKEN,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))",
|
||||
],
|
||||
cwd: process.cwd(),
|
||||
stubProviderAuthEnv({
|
||||
OPENAI_API_KEY: "openai-secret",
|
||||
GITHUB_TOKEN: "gh-secret",
|
||||
HF_TOKEN: "hf-secret",
|
||||
OPENCLAW_API_KEY: "keep-me",
|
||||
});
|
||||
const parsed = await collectSpawnedEnvSnapshot({
|
||||
stripProviderAuthEnvVars: true,
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.error).toBeNull();
|
||||
|
||||
const parsed = JSON.parse(result.stdout) as {
|
||||
openai?: string;
|
||||
github?: string;
|
||||
hf?: string;
|
||||
openclaw?: string;
|
||||
shell?: string;
|
||||
};
|
||||
expect(parsed.openai).toBeUndefined();
|
||||
expect(parsed.github).toBeUndefined();
|
||||
expect(parsed.hf).toBeUndefined();
|
||||
@ -325,29 +347,16 @@ describe("spawnAndCollect", () => {
|
||||
});
|
||||
|
||||
it("strips provider auth env vars case-insensitively", async () => {
|
||||
vi.stubEnv("OpenAI_Api_Key", "openai-secret");
|
||||
vi.stubEnv("Github_Token", "gh-secret");
|
||||
vi.stubEnv("OPENCLAW_API_KEY", "keep-me");
|
||||
|
||||
const result = await spawnAndCollect({
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
"process.stdout.write(JSON.stringify({openai:process.env.OpenAI_Api_Key,github:process.env.Github_Token,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))",
|
||||
],
|
||||
cwd: process.cwd(),
|
||||
stripProviderAuthEnvVars: true,
|
||||
stubProviderAuthEnv({
|
||||
OpenAI_Api_Key: "openai-secret",
|
||||
Github_Token: "gh-secret",
|
||||
OPENCLAW_API_KEY: "keep-me",
|
||||
});
|
||||
const parsed = await collectSpawnedEnvSnapshot({
|
||||
stripProviderAuthEnvVars: true,
|
||||
openAiEnvKey: "OpenAI_Api_Key",
|
||||
githubEnvKey: "Github_Token",
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.error).toBeNull();
|
||||
|
||||
const parsed = JSON.parse(result.stdout) as {
|
||||
openai?: string;
|
||||
github?: string;
|
||||
openclaw?: string;
|
||||
shell?: string;
|
||||
};
|
||||
expect(parsed.openai).toBeUndefined();
|
||||
expect(parsed.github).toBeUndefined();
|
||||
expect(parsed.openclaw).toBe("keep-me");
|
||||
@ -355,30 +364,13 @@ describe("spawnAndCollect", () => {
|
||||
});
|
||||
|
||||
it("preserves provider auth env vars for explicit custom commands by default", async () => {
|
||||
vi.stubEnv("OPENAI_API_KEY", "openai-secret");
|
||||
vi.stubEnv("GITHUB_TOKEN", "gh-secret");
|
||||
vi.stubEnv("HF_TOKEN", "hf-secret");
|
||||
vi.stubEnv("OPENCLAW_API_KEY", "keep-me");
|
||||
|
||||
const result = await spawnAndCollect({
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
"process.stdout.write(JSON.stringify({openai:process.env.OPENAI_API_KEY,github:process.env.GITHUB_TOKEN,hf:process.env.HF_TOKEN,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))",
|
||||
],
|
||||
cwd: process.cwd(),
|
||||
stubProviderAuthEnv({
|
||||
OPENAI_API_KEY: "openai-secret",
|
||||
GITHUB_TOKEN: "gh-secret",
|
||||
HF_TOKEN: "hf-secret",
|
||||
OPENCLAW_API_KEY: "keep-me",
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.error).toBeNull();
|
||||
|
||||
const parsed = JSON.parse(result.stdout) as {
|
||||
openai?: string;
|
||||
github?: string;
|
||||
hf?: string;
|
||||
openclaw?: string;
|
||||
shell?: string;
|
||||
};
|
||||
const parsed = await collectSpawnedEnvSnapshot();
|
||||
expect(parsed.openai).toBe("openai-secret");
|
||||
expect(parsed.github).toBe("gh-secret");
|
||||
expect(parsed.hf).toBe("hf-secret");
|
||||
|
||||
@ -82,6 +82,15 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
).rejects.toThrow("too large");
|
||||
}
|
||||
|
||||
function mockSuccessfulAttachmentDownload(buffer = new Uint8Array([1])) {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: () => Promise.resolve(buffer.buffer),
|
||||
});
|
||||
return buffer;
|
||||
}
|
||||
|
||||
it("throws when guid is missing", async () => {
|
||||
const attachment: BlueBubblesAttachment = {};
|
||||
await expect(
|
||||
@ -159,12 +168,7 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
});
|
||||
|
||||
it("encodes guid in URL", async () => {
|
||||
const mockBuffer = new Uint8Array([1]);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
||||
});
|
||||
mockSuccessfulAttachmentDownload();
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att/with/special chars" };
|
||||
await downloadBlueBubblesAttachment(attachment, {
|
||||
@ -244,12 +248,7 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
});
|
||||
|
||||
it("resolves credentials from config when opts not provided", async () => {
|
||||
const mockBuffer = new Uint8Array([1]);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
||||
});
|
||||
mockSuccessfulAttachmentDownload();
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-config" };
|
||||
const result = await downloadBlueBubblesAttachment(attachment, {
|
||||
@ -270,12 +269,7 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
});
|
||||
|
||||
it("passes ssrfPolicy with allowPrivateNetwork when config enables it", async () => {
|
||||
const mockBuffer = new Uint8Array([1]);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
||||
});
|
||||
mockSuccessfulAttachmentDownload();
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-ssrf" };
|
||||
await downloadBlueBubblesAttachment(attachment, {
|
||||
@ -295,12 +289,7 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
});
|
||||
|
||||
it("auto-allowlists serverUrl hostname when allowPrivateNetwork is not set", async () => {
|
||||
const mockBuffer = new Uint8Array([1]);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
||||
});
|
||||
mockSuccessfulAttachmentDownload();
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-no-ssrf" };
|
||||
await downloadBlueBubblesAttachment(attachment, {
|
||||
@ -313,12 +302,7 @@ describe("downloadBlueBubblesAttachment", () => {
|
||||
});
|
||||
|
||||
it("auto-allowlists private IP serverUrl hostname when allowPrivateNetwork is not set", async () => {
|
||||
const mockBuffer = new Uint8Array([1]);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: () => Promise.resolve(mockBuffer.buffer),
|
||||
});
|
||||
mockSuccessfulAttachmentDownload();
|
||||
|
||||
const attachment: BlueBubblesAttachment = { guid: "att-private-ip" };
|
||||
await downloadBlueBubblesAttachment(attachment, {
|
||||
@ -352,6 +336,14 @@ describe("sendBlueBubblesAttachment", () => {
|
||||
return Buffer.from(body).toString("utf8");
|
||||
}
|
||||
|
||||
function expectVoiceAttachmentBody() {
|
||||
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
||||
const bodyText = decodeBody(body);
|
||||
expect(bodyText).toContain('name="isAudioMessage"');
|
||||
expect(bodyText).toContain("true");
|
||||
return bodyText;
|
||||
}
|
||||
|
||||
it("marks voice memos when asVoice is true and mp3 is provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
@ -367,10 +359,7 @@ describe("sendBlueBubblesAttachment", () => {
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
});
|
||||
|
||||
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
||||
const bodyText = decodeBody(body);
|
||||
expect(bodyText).toContain('name="isAudioMessage"');
|
||||
expect(bodyText).toContain("true");
|
||||
const bodyText = expectVoiceAttachmentBody();
|
||||
expect(bodyText).toContain('filename="voice.mp3"');
|
||||
});
|
||||
|
||||
@ -389,8 +378,7 @@ describe("sendBlueBubblesAttachment", () => {
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
});
|
||||
|
||||
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
||||
const bodyText = decodeBody(body);
|
||||
const bodyText = expectVoiceAttachmentBody();
|
||||
expect(bodyText).toContain('filename="voice.mp3"');
|
||||
expect(bodyText).toContain('name="voice.mp3"');
|
||||
});
|
||||
|
||||
@ -26,6 +26,14 @@ function assertPrivateApiEnabled(accountId: string, feature: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertBlueBubblesActionOk(response: Response, action: string): Promise<void> {
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
const errorText = await response.text().catch(() => "");
|
||||
throw new Error(`BlueBubbles ${action} failed (${response.status}): ${errorText || "unknown"}`);
|
||||
}
|
||||
|
||||
function resolvePartIndex(partIndex: number | undefined): number {
|
||||
return typeof partIndex === "number" ? partIndex : 0;
|
||||
}
|
||||
@ -55,12 +63,7 @@ async function sendBlueBubblesChatEndpointRequest(params: {
|
||||
{ method: params.method },
|
||||
params.opts.timeoutMs,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`,
|
||||
);
|
||||
}
|
||||
await assertBlueBubblesActionOk(res, params.action);
|
||||
}
|
||||
|
||||
async function sendPrivateApiJsonRequest(params: {
|
||||
@ -86,12 +89,7 @@ async function sendPrivateApiJsonRequest(params: {
|
||||
}
|
||||
|
||||
const res = await blueBubblesFetchWithTimeout(url, request, params.opts.timeoutMs);
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`,
|
||||
);
|
||||
}
|
||||
await assertBlueBubblesActionOk(res, params.action);
|
||||
}
|
||||
|
||||
export async function markBlueBubblesChatRead(
|
||||
|
||||
@ -582,6 +582,29 @@ export function parseTapbackText(params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parseLeadingReactionAction = (
|
||||
prefix: "reacted" | "removed",
|
||||
defaultAction: "added" | "removed",
|
||||
) => {
|
||||
if (!lower.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
|
||||
if (!emoji) {
|
||||
return null;
|
||||
}
|
||||
const quotedText = extractQuotedTapbackText(trimmed);
|
||||
if (params.requireQuoted && !quotedText) {
|
||||
return null;
|
||||
}
|
||||
const fallback = trimmed.slice(prefix.length).trim();
|
||||
return {
|
||||
emoji,
|
||||
action: params.actionHint ?? defaultAction,
|
||||
quotedText: quotedText ?? fallback,
|
||||
};
|
||||
};
|
||||
|
||||
for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
|
||||
if (lower.startsWith(pattern)) {
|
||||
// Extract quoted text if present (e.g., 'Loved "hello"' -> "hello")
|
||||
@ -599,30 +622,14 @@ export function parseTapbackText(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (lower.startsWith("reacted")) {
|
||||
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
|
||||
if (!emoji) {
|
||||
return null;
|
||||
}
|
||||
const quotedText = extractQuotedTapbackText(trimmed);
|
||||
if (params.requireQuoted && !quotedText) {
|
||||
return null;
|
||||
}
|
||||
const fallback = trimmed.slice("reacted".length).trim();
|
||||
return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
|
||||
const reacted = parseLeadingReactionAction("reacted", "added");
|
||||
if (reacted) {
|
||||
return reacted;
|
||||
}
|
||||
|
||||
if (lower.startsWith("removed")) {
|
||||
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
|
||||
if (!emoji) {
|
||||
return null;
|
||||
}
|
||||
const quotedText = extractQuotedTapbackText(trimmed);
|
||||
if (params.requireQuoted && !quotedText) {
|
||||
return null;
|
||||
}
|
||||
const fallback = trimmed.slice("removed".length).trim();
|
||||
return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
|
||||
const removed = parseLeadingReactionAction("removed", "removed");
|
||||
if (removed) {
|
||||
return removed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -302,65 +302,101 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
};
|
||||
}
|
||||
|
||||
describe("webhook parsing + auth handling", () => {
|
||||
it("rejects non-POST requests", async () => {
|
||||
const account = createMockAccount();
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
async function dispatchWebhook(req: IncomingMessage) {
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
return { handled, res };
|
||||
}
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
function createWebhookRequestForTest(params?: {
|
||||
method?: string;
|
||||
url?: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
remoteAddress?: string;
|
||||
}) {
|
||||
const req = createMockRequest(
|
||||
params?.method ?? "POST",
|
||||
params?.url ?? "/bluebubbles-webhook",
|
||||
params?.body ?? {},
|
||||
params?.headers,
|
||||
);
|
||||
if (params?.remoteAddress) {
|
||||
setRequestRemoteAddress(req, params.remoteAddress);
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
function createHangingWebhookRequest(url = "/bluebubbles-webhook?password=test-password") {
|
||||
const req = new EventEmitter() as IncomingMessage & { destroy: ReturnType<typeof vi.fn> };
|
||||
req.method = "POST";
|
||||
req.url = url;
|
||||
req.headers = {};
|
||||
req.destroy = vi.fn();
|
||||
setRequestRemoteAddress(req, "127.0.0.1");
|
||||
return req;
|
||||
}
|
||||
|
||||
function registerWebhookTargets(
|
||||
params: Array<{
|
||||
account: ResolvedBlueBubblesAccount;
|
||||
statusSink?: (event: unknown) => void;
|
||||
}>,
|
||||
) {
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
const unregisterFns = params.map(({ account, statusSink }) =>
|
||||
registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
statusSink,
|
||||
}),
|
||||
);
|
||||
|
||||
const req = createMockRequest("GET", "/bluebubbles-webhook", {});
|
||||
const res = createMockResponse();
|
||||
unregister = () => {
|
||||
for (const unregisterFn of unregisterFns) {
|
||||
unregisterFn();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
async function expectWebhookStatus(
|
||||
req: IncomingMessage,
|
||||
expectedStatus: number,
|
||||
expectedBody?: string,
|
||||
) {
|
||||
const { handled, res } = await dispatchWebhook(req);
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(expectedStatus);
|
||||
if (expectedBody !== undefined) {
|
||||
expect(res.body).toBe(expectedBody);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(405);
|
||||
describe("webhook parsing + auth handling", () => {
|
||||
it("rejects non-POST requests", async () => {
|
||||
setupWebhookTarget();
|
||||
const req = createWebhookRequestForTest({ method: "GET" });
|
||||
await expectWebhookStatus(req, 405);
|
||||
});
|
||||
|
||||
it("accepts POST requests with valid JSON payload", async () => {
|
||||
setupWebhookTarget();
|
||||
const payload = createNewMessagePayload({ date: Date.now() });
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||
const res = createMockResponse();
|
||||
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("ok");
|
||||
const req = createWebhookRequestForTest({ body: payload });
|
||||
await expectWebhookStatus(req, 200, "ok");
|
||||
});
|
||||
|
||||
it("rejects requests with invalid JSON", async () => {
|
||||
const account = createMockAccount();
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{");
|
||||
const res = createMockResponse();
|
||||
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(400);
|
||||
setupWebhookTarget();
|
||||
const req = createWebhookRequestForTest({ body: "invalid json {{" });
|
||||
await expectWebhookStatus(req, 400);
|
||||
});
|
||||
|
||||
it("accepts URL-encoded payload wrappers", async () => {
|
||||
@ -369,42 +405,17 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const encodedBody = new URLSearchParams({
|
||||
payload: JSON.stringify(payload),
|
||||
}).toString();
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody);
|
||||
const res = createMockResponse();
|
||||
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("ok");
|
||||
const req = createWebhookRequestForTest({ body: encodedBody });
|
||||
await expectWebhookStatus(req, 200, "ok");
|
||||
});
|
||||
|
||||
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const account = createMockAccount();
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
setupWebhookTarget();
|
||||
|
||||
// Create a request that never sends data or ends (simulates slow-loris)
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
req.method = "POST";
|
||||
req.url = "/bluebubbles-webhook?password=test-password";
|
||||
req.headers = {};
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
req.destroy = vi.fn();
|
||||
const req = createHangingWebhookRequest();
|
||||
|
||||
const res = createMockResponse();
|
||||
|
||||
@ -424,140 +435,62 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
|
||||
it("rejects unauthorized requests before reading the body", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
req.method = "POST";
|
||||
req.url = "/bluebubbles-webhook?password=wrong-token";
|
||||
req.headers = {};
|
||||
setupWebhookTarget({ account });
|
||||
const req = createHangingWebhookRequest("/bluebubbles-webhook?password=wrong-token");
|
||||
const onSpy = vi.spyOn(req, "on");
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(401);
|
||||
await expectWebhookStatus(req, 401);
|
||||
expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
|
||||
});
|
||||
|
||||
it("authenticates via password query parameter", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
|
||||
// Mock non-localhost request
|
||||
const req = createMockRequest(
|
||||
"POST",
|
||||
"/bluebubbles-webhook?password=secret-token",
|
||||
createNewMessagePayload(),
|
||||
);
|
||||
setRequestRemoteAddress(req, "192.168.1.100");
|
||||
setupWebhookTarget({ account });
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const req = createWebhookRequestForTest({
|
||||
url: "/bluebubbles-webhook?password=secret-token",
|
||||
body: createNewMessagePayload(),
|
||||
remoteAddress: "192.168.1.100",
|
||||
});
|
||||
await expectWebhookStatus(req, 200);
|
||||
});
|
||||
|
||||
it("authenticates via x-password header", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
|
||||
const req = createMockRequest(
|
||||
"POST",
|
||||
"/bluebubbles-webhook",
|
||||
createNewMessagePayload(),
|
||||
{ "x-password": "secret-token" }, // pragma: allowlist secret
|
||||
);
|
||||
setRequestRemoteAddress(req, "192.168.1.100");
|
||||
setupWebhookTarget({ account });
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const req = createWebhookRequestForTest({
|
||||
body: createNewMessagePayload(),
|
||||
headers: { "x-password": "secret-token" }, // pragma: allowlist secret
|
||||
remoteAddress: "192.168.1.100",
|
||||
});
|
||||
await expectWebhookStatus(req, 200);
|
||||
});
|
||||
|
||||
it("rejects unauthorized requests with wrong password", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
const req = createMockRequest(
|
||||
"POST",
|
||||
"/bluebubbles-webhook?password=wrong-token",
|
||||
createNewMessagePayload(),
|
||||
);
|
||||
setRequestRemoteAddress(req, "192.168.1.100");
|
||||
setupWebhookTarget({ account });
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(401);
|
||||
const req = createWebhookRequestForTest({
|
||||
url: "/bluebubbles-webhook?password=wrong-token",
|
||||
body: createNewMessagePayload(),
|
||||
remoteAddress: "192.168.1.100",
|
||||
});
|
||||
await expectWebhookStatus(req, 401);
|
||||
});
|
||||
|
||||
it("rejects ambiguous routing when multiple targets match the same password", async () => {
|
||||
const accountA = createMockAccount({ password: "secret-token" });
|
||||
const accountB = createMockAccount({ password: "secret-token" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
const sinkA = vi.fn();
|
||||
const sinkB = vi.fn();
|
||||
registerWebhookTargets([
|
||||
{ account: accountA, statusSink: sinkA },
|
||||
{ account: accountB, statusSink: sinkB },
|
||||
]);
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
},
|
||||
});
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
const req = createWebhookRequestForTest({
|
||||
url: "/bluebubbles-webhook?password=secret-token",
|
||||
body: createNewMessagePayload(),
|
||||
remoteAddress: "192.168.1.100",
|
||||
};
|
||||
|
||||
const unregisterA = registerBlueBubblesWebhookTarget({
|
||||
account: accountA,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
statusSink: sinkA,
|
||||
});
|
||||
const unregisterB = registerBlueBubblesWebhookTarget({
|
||||
account: accountB,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
statusSink: sinkB,
|
||||
});
|
||||
unregister = () => {
|
||||
unregisterA();
|
||||
unregisterB();
|
||||
};
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(401);
|
||||
await expectWebhookStatus(req, 401);
|
||||
expect(sinkA).not.toHaveBeenCalled();
|
||||
expect(sinkB).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -565,107 +498,38 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
it("ignores targets without passwords when a password-authenticated target matches", async () => {
|
||||
const accountStrict = createMockAccount({ password: "secret-token" });
|
||||
const accountWithoutPassword = createMockAccount({ password: undefined });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
const sinkStrict = vi.fn();
|
||||
const sinkWithoutPassword = vi.fn();
|
||||
registerWebhookTargets([
|
||||
{ account: accountStrict, statusSink: sinkStrict },
|
||||
{ account: accountWithoutPassword, statusSink: sinkWithoutPassword },
|
||||
]);
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
},
|
||||
});
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
const req = createWebhookRequestForTest({
|
||||
url: "/bluebubbles-webhook?password=secret-token",
|
||||
body: createNewMessagePayload(),
|
||||
remoteAddress: "192.168.1.100",
|
||||
};
|
||||
|
||||
const unregisterStrict = registerBlueBubblesWebhookTarget({
|
||||
account: accountStrict,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
statusSink: sinkStrict,
|
||||
});
|
||||
const unregisterNoPassword = registerBlueBubblesWebhookTarget({
|
||||
account: accountWithoutPassword,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
statusSink: sinkWithoutPassword,
|
||||
});
|
||||
unregister = () => {
|
||||
unregisterStrict();
|
||||
unregisterNoPassword();
|
||||
};
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
await expectWebhookStatus(req, 200);
|
||||
expect(sinkStrict).toHaveBeenCalledTimes(1);
|
||||
expect(sinkWithoutPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires authentication for loopback requests when password is configured", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
setupWebhookTarget({ account });
|
||||
for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
},
|
||||
});
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
const req = createWebhookRequestForTest({
|
||||
body: createNewMessagePayload(),
|
||||
remoteAddress,
|
||||
};
|
||||
|
||||
const loopbackUnregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(401);
|
||||
|
||||
loopbackUnregister();
|
||||
await expectWebhookStatus(req, 401);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
|
||||
const account = createMockAccount({ password: undefined });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
setupWebhookTarget({ account });
|
||||
|
||||
const headerVariants: Record<string, string>[] = [
|
||||
{ host: "localhost" },
|
||||
@ -673,28 +537,12 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
{ host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" },
|
||||
];
|
||||
for (const headers of headerVariants) {
|
||||
const req = createMockRequest(
|
||||
"POST",
|
||||
"/bluebubbles-webhook",
|
||||
{
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
},
|
||||
},
|
||||
const req = createWebhookRequestForTest({
|
||||
body: createNewMessagePayload(),
|
||||
headers,
|
||||
);
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
await expectWebhookStatus(req, 401);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ describe("reactions", () => {
|
||||
});
|
||||
|
||||
describe("sendBlueBubblesReaction", () => {
|
||||
async function expectRemovedReaction(emoji: string) {
|
||||
async function expectRemovedReaction(emoji: string, expectedReaction = "-love") {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
@ -37,7 +37,7 @@ describe("reactions", () => {
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.reaction).toBe("-love");
|
||||
expect(body.reaction).toBe(expectedReaction);
|
||||
}
|
||||
|
||||
it("throws when chatGuid is empty", async () => {
|
||||
@ -327,45 +327,11 @@ describe("reactions", () => {
|
||||
|
||||
describe("reaction removal aliases", () => {
|
||||
it("handles emoji-based removal", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji: "👍",
|
||||
remove: true,
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.reaction).toBe("-like");
|
||||
await expectRemovedReaction("👍", "-like");
|
||||
});
|
||||
|
||||
it("handles text alias removal", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
await sendBlueBubblesReaction({
|
||||
chatGuid: "chat-123",
|
||||
messageGuid: "msg-123",
|
||||
emoji: "haha",
|
||||
remove: true,
|
||||
opts: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.reaction).toBe("-laugh");
|
||||
await expectRemovedReaction("haha", "-laugh");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -63,6 +63,8 @@ vi.mock("./streaming-card.js", () => ({
|
||||
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
||||
|
||||
describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
type ReplyDispatcherArgs = Parameters<typeof createFeishuReplyDispatcher>[0];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
streamingInstances.length = 0;
|
||||
@ -128,6 +130,25 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
return createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
}
|
||||
|
||||
function createRuntimeLogger() {
|
||||
return { log: vi.fn(), error: vi.fn() } as never;
|
||||
}
|
||||
|
||||
function createDispatcherHarness(overrides: Partial<ReplyDispatcherArgs> = {}) {
|
||||
const result = createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: {} as never,
|
||||
chatId: "oc_chat",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
return {
|
||||
result,
|
||||
options: createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0],
|
||||
};
|
||||
}
|
||||
|
||||
it("skips typing indicator when account typingIndicator is disabled", async () => {
|
||||
resolveFeishuAccountMock.mockReturnValue({
|
||||
accountId: "main",
|
||||
@ -209,14 +230,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
|
||||
it("keeps auto mode plain text on non-streaming send path", async () => {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: {} as never,
|
||||
chatId: "oc_chat",
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
const { options } = createDispatcherHarness();
|
||||
await options.deliver({ text: "plain text" }, { kind: "final" });
|
||||
|
||||
expect(streamingInstances).toHaveLength(0);
|
||||
@ -225,14 +239,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
|
||||
it("suppresses internal block payload delivery", async () => {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: {} as never,
|
||||
chatId: "oc_chat",
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
const { options } = createDispatcherHarness();
|
||||
await options.deliver({ text: "internal reasoning chunk" }, { kind: "block" });
|
||||
|
||||
expect(streamingInstances).toHaveLength(0);
|
||||
@ -253,15 +260,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
|
||||
it("uses streaming session for auto mode markdown payloads", async () => {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
||||
chatId: "oc_chat",
|
||||
const { options } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
rootId: "om_root_topic",
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
||||
|
||||
expect(streamingInstances).toHaveLength(1);
|
||||
@ -277,14 +279,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
|
||||
it("closes streaming with block text when final reply is missing", async () => {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
||||
chatId: "oc_chat",
|
||||
const { options } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
await options.deliver({ text: "```md\npartial answer\n```" }, { kind: "block" });
|
||||
await options.onIdle?.();
|
||||
|
||||
@ -295,14 +292,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
|
||||
it("delivers distinct final payloads after streaming close", async () => {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
||||
chatId: "oc_chat",
|
||||
const { options } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
await options.deliver({ text: "```md\n完整回复第一段\n```" }, { kind: "final" });
|
||||
await options.deliver({ text: "```md\n完整回复第一段 + 第二段\n```" }, { kind: "final" });
|
||||
|
||||
@ -316,14 +308,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
|
||||
it("skips exact duplicate final text after streaming close", async () => {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
||||
chatId: "oc_chat",
|
||||
const { options } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
|
||||
await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
|
||||
|
||||
@ -383,14 +370,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
||||
chatId: "oc_chat",
|
||||
const { result, options } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
await options.onReplyStart?.();
|
||||
await result.replyOptions.onPartialReply?.({ text: "hello" });
|
||||
await options.deliver({ text: "lo world" }, { kind: "block" });
|
||||
@ -402,14 +384,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
|
||||
it("sends media-only payloads as attachments", async () => {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: {} as never,
|
||||
chatId: "oc_chat",
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
const { options } = createDispatcherHarness();
|
||||
await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
|
||||
|
||||
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
|
||||
@ -424,14 +399,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
|
||||
it("falls back to legacy mediaUrl when mediaUrls is an empty array", async () => {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: {} as never,
|
||||
chatId: "oc_chat",
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
const { options } = createDispatcherHarness();
|
||||
await options.deliver(
|
||||
{ text: "caption", mediaUrl: "https://example.com/a.png", mediaUrls: [] },
|
||||
{ kind: "final" },
|
||||
@ -447,14 +415,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
|
||||
it("sends attachments after streaming final markdown replies", async () => {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
||||
chatId: "oc_chat",
|
||||
const { options } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
await options.deliver(
|
||||
{ text: "```ts\nconst x = 1\n```", mediaUrls: ["https://example.com/a.png"] },
|
||||
{ kind: "final" },
|
||||
@ -472,16 +435,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
|
||||
it("passes replyInThread to sendMessageFeishu for plain text", async () => {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: {} as never,
|
||||
chatId: "oc_chat",
|
||||
const { options } = createDispatcherHarness({
|
||||
replyToMessageId: "om_msg",
|
||||
replyInThread: true,
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
await options.deliver({ text: "plain text" }, { kind: "final" });
|
||||
|
||||
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
|
||||
@ -504,16 +461,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
},
|
||||
});
|
||||
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: {} as never,
|
||||
chatId: "oc_chat",
|
||||
const { options } = createDispatcherHarness({
|
||||
replyToMessageId: "om_msg",
|
||||
replyInThread: true,
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
await options.deliver({ text: "card text" }, { kind: "final" });
|
||||
|
||||
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
|
||||
@ -525,16 +476,11 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
|
||||
it("passes replyToMessageId and replyInThread to streaming.start()", async () => {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
||||
chatId: "oc_chat",
|
||||
const { options } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
replyToMessageId: "om_msg",
|
||||
replyInThread: true,
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
||||
|
||||
expect(streamingInstances).toHaveLength(1);
|
||||
@ -545,18 +491,13 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
|
||||
it("disables streaming for thread replies and keeps reply metadata", async () => {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
||||
chatId: "oc_chat",
|
||||
const { options } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
replyToMessageId: "om_msg",
|
||||
replyInThread: false,
|
||||
threadReply: true,
|
||||
rootId: "om_root_topic",
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
||||
|
||||
expect(streamingInstances).toHaveLength(0);
|
||||
@ -569,16 +510,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
|
||||
it("passes replyInThread to media attachments", async () => {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: {} as never,
|
||||
chatId: "oc_chat",
|
||||
const { options } = createDispatcherHarness({
|
||||
replyToMessageId: "om_msg",
|
||||
replyInThread: true,
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
|
||||
|
||||
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
||||
|
||||
@ -224,6 +224,41 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
lastPartial = "";
|
||||
};
|
||||
|
||||
const sendChunkedTextReply = async (params: {
|
||||
text: string;
|
||||
useCard: boolean;
|
||||
infoKind?: string;
|
||||
}) => {
|
||||
let first = true;
|
||||
const chunkSource = params.useCard
|
||||
? params.text
|
||||
: core.channel.text.convertMarkdownTables(params.text, tableMode);
|
||||
for (const chunk of core.channel.text.chunkTextWithMode(
|
||||
chunkSource,
|
||||
textChunkLimit,
|
||||
chunkMode,
|
||||
)) {
|
||||
const message = {
|
||||
cfg,
|
||||
to: chatId,
|
||||
text: chunk,
|
||||
replyToMessageId: sendReplyToMessageId,
|
||||
replyInThread: effectiveReplyInThread,
|
||||
mentions: first ? mentionTargets : undefined,
|
||||
accountId,
|
||||
};
|
||||
if (params.useCard) {
|
||||
await sendMarkdownCardFeishu(message);
|
||||
} else {
|
||||
await sendMessageFeishu(message);
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
if (params.infoKind === "final") {
|
||||
deliveredFinalTexts.add(params.text);
|
||||
}
|
||||
};
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
responsePrefix: prefixContext.responsePrefix,
|
||||
@ -303,48 +338,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
return;
|
||||
}
|
||||
|
||||
let first = true;
|
||||
if (useCard) {
|
||||
for (const chunk of core.channel.text.chunkTextWithMode(
|
||||
text,
|
||||
textChunkLimit,
|
||||
chunkMode,
|
||||
)) {
|
||||
await sendMarkdownCardFeishu({
|
||||
cfg,
|
||||
to: chatId,
|
||||
text: chunk,
|
||||
replyToMessageId: sendReplyToMessageId,
|
||||
replyInThread: effectiveReplyInThread,
|
||||
mentions: first ? mentionTargets : undefined,
|
||||
accountId,
|
||||
});
|
||||
first = false;
|
||||
}
|
||||
if (info?.kind === "final") {
|
||||
deliveredFinalTexts.add(text);
|
||||
}
|
||||
await sendChunkedTextReply({ text, useCard: true, infoKind: info?.kind });
|
||||
} else {
|
||||
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
|
||||
for (const chunk of core.channel.text.chunkTextWithMode(
|
||||
converted,
|
||||
textChunkLimit,
|
||||
chunkMode,
|
||||
)) {
|
||||
await sendMessageFeishu({
|
||||
cfg,
|
||||
to: chatId,
|
||||
text: chunk,
|
||||
replyToMessageId: sendReplyToMessageId,
|
||||
replyInThread: effectiveReplyInThread,
|
||||
mentions: first ? mentionTargets : undefined,
|
||||
accountId,
|
||||
});
|
||||
first = false;
|
||||
}
|
||||
if (info?.kind === "final") {
|
||||
deliveredFinalTexts.add(text);
|
||||
}
|
||||
await sendChunkedTextReply({ text, useCard: false, infoKind: info?.kind });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -25,6 +25,16 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
|
||||
const replyMock = vi.fn();
|
||||
const createMock = vi.fn();
|
||||
|
||||
async function expectFallbackResult(
|
||||
send: () => Promise<{ messageId?: string }>,
|
||||
expectedMessageId: string,
|
||||
) {
|
||||
const result = await send();
|
||||
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.messageId).toBe(expectedMessageId);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveFeishuSendTargetMock.mockReturnValue({
|
||||
@ -51,16 +61,16 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
|
||||
data: { message_id: "om_new" },
|
||||
});
|
||||
|
||||
const result = await sendMessageFeishu({
|
||||
cfg: {} as never,
|
||||
to: "user:ou_target",
|
||||
text: "hello",
|
||||
replyToMessageId: "om_parent",
|
||||
});
|
||||
|
||||
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.messageId).toBe("om_new");
|
||||
await expectFallbackResult(
|
||||
() =>
|
||||
sendMessageFeishu({
|
||||
cfg: {} as never,
|
||||
to: "user:ou_target",
|
||||
text: "hello",
|
||||
replyToMessageId: "om_parent",
|
||||
}),
|
||||
"om_new",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to create for withdrawn card replies", async () => {
|
||||
@ -73,16 +83,16 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
|
||||
data: { message_id: "om_card_new" },
|
||||
});
|
||||
|
||||
const result = await sendCardFeishu({
|
||||
cfg: {} as never,
|
||||
to: "user:ou_target",
|
||||
card: { schema: "2.0" },
|
||||
replyToMessageId: "om_parent",
|
||||
});
|
||||
|
||||
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.messageId).toBe("om_card_new");
|
||||
await expectFallbackResult(
|
||||
() =>
|
||||
sendCardFeishu({
|
||||
cfg: {} as never,
|
||||
to: "user:ou_target",
|
||||
card: { schema: "2.0" },
|
||||
replyToMessageId: "om_parent",
|
||||
}),
|
||||
"om_card_new",
|
||||
);
|
||||
});
|
||||
|
||||
it("still throws for non-withdrawn reply failures", async () => {
|
||||
@ -111,16 +121,16 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
|
||||
data: { message_id: "om_thrown_fallback" },
|
||||
});
|
||||
|
||||
const result = await sendMessageFeishu({
|
||||
cfg: {} as never,
|
||||
to: "user:ou_target",
|
||||
text: "hello",
|
||||
replyToMessageId: "om_parent",
|
||||
});
|
||||
|
||||
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.messageId).toBe("om_thrown_fallback");
|
||||
await expectFallbackResult(
|
||||
() =>
|
||||
sendMessageFeishu({
|
||||
cfg: {} as never,
|
||||
to: "user:ou_target",
|
||||
text: "hello",
|
||||
replyToMessageId: "om_parent",
|
||||
}),
|
||||
"om_thrown_fallback",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to create when card reply throws a not-found AxiosError", async () => {
|
||||
@ -133,16 +143,16 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
|
||||
data: { message_id: "om_axios_fallback" },
|
||||
});
|
||||
|
||||
const result = await sendCardFeishu({
|
||||
cfg: {} as never,
|
||||
to: "user:ou_target",
|
||||
card: { schema: "2.0" },
|
||||
replyToMessageId: "om_parent",
|
||||
});
|
||||
|
||||
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.messageId).toBe("om_axios_fallback");
|
||||
await expectFallbackResult(
|
||||
() =>
|
||||
sendCardFeishu({
|
||||
cfg: {} as never,
|
||||
to: "user:ou_target",
|
||||
card: { schema: "2.0" },
|
||||
replyToMessageId: "om_parent",
|
||||
}),
|
||||
"om_axios_fallback",
|
||||
);
|
||||
});
|
||||
|
||||
it("re-throws non-withdrawn thrown errors for text messages", async () => {
|
||||
|
||||
@ -55,6 +55,30 @@ type FeishuCreateMessageClient = {
|
||||
};
|
||||
};
|
||||
|
||||
type FeishuMessageSender = {
|
||||
id?: string;
|
||||
id_type?: string;
|
||||
sender_type?: string;
|
||||
};
|
||||
|
||||
type FeishuMessageGetItem = {
|
||||
message_id?: string;
|
||||
chat_id?: string;
|
||||
chat_type?: FeishuChatType;
|
||||
msg_type?: string;
|
||||
body?: { content?: string };
|
||||
sender?: FeishuMessageSender;
|
||||
create_time?: string;
|
||||
};
|
||||
|
||||
type FeishuGetMessageResponse = {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: FeishuMessageGetItem & {
|
||||
items?: FeishuMessageGetItem[];
|
||||
};
|
||||
};
|
||||
|
||||
/** Send a direct message as a fallback when a reply target is unavailable. */
|
||||
async function sendFallbackDirect(
|
||||
client: FeishuCreateMessageClient,
|
||||
@ -214,36 +238,7 @@ export async function getMessageFeishu(params: {
|
||||
try {
|
||||
const response = (await client.im.message.get({
|
||||
path: { message_id: messageId },
|
||||
})) as {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: {
|
||||
items?: Array<{
|
||||
message_id?: string;
|
||||
chat_id?: string;
|
||||
chat_type?: FeishuChatType;
|
||||
msg_type?: string;
|
||||
body?: { content?: string };
|
||||
sender?: {
|
||||
id?: string;
|
||||
id_type?: string;
|
||||
sender_type?: string;
|
||||
};
|
||||
create_time?: string;
|
||||
}>;
|
||||
message_id?: string;
|
||||
chat_id?: string;
|
||||
chat_type?: FeishuChatType;
|
||||
msg_type?: string;
|
||||
body?: { content?: string };
|
||||
sender?: {
|
||||
id?: string;
|
||||
id_type?: string;
|
||||
sender_type?: string;
|
||||
};
|
||||
create_time?: string;
|
||||
};
|
||||
};
|
||||
})) as FeishuGetMessageResponse;
|
||||
|
||||
if (response.code !== 0) {
|
||||
return null;
|
||||
|
||||
@ -13,6 +13,21 @@ const account = {
|
||||
config: {},
|
||||
} as ResolvedGoogleChatAccount;
|
||||
|
||||
function stubSuccessfulSend(name: string) {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(JSON.stringify({ name }), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
async function expectDownloadToRejectForResponse(response: Response) {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
|
||||
await expect(
|
||||
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
|
||||
).rejects.toThrow(/max bytes/i);
|
||||
}
|
||||
|
||||
describe("downloadGoogleChatMedia", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@ -29,11 +44,7 @@ describe("downloadGoogleChatMedia", () => {
|
||||
status: 200,
|
||||
headers: { "content-length": "50", "content-type": "application/octet-stream" },
|
||||
});
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
|
||||
|
||||
await expect(
|
||||
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
|
||||
).rejects.toThrow(/max bytes/i);
|
||||
await expectDownloadToRejectForResponse(response);
|
||||
});
|
||||
|
||||
it("rejects when streamed payload exceeds max bytes", async () => {
|
||||
@ -52,11 +63,7 @@ describe("downloadGoogleChatMedia", () => {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
});
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
|
||||
|
||||
await expect(
|
||||
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
|
||||
).rejects.toThrow(/max bytes/i);
|
||||
await expectDownloadToRejectForResponse(response);
|
||||
});
|
||||
});
|
||||
|
||||
@ -66,12 +73,7 @@ describe("sendGoogleChatMessage", () => {
|
||||
});
|
||||
|
||||
it("adds messageReplyOption when sending to an existing thread", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ name: "spaces/AAA/messages/123" }), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/123");
|
||||
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
@ -89,12 +91,7 @@ describe("sendGoogleChatMessage", () => {
|
||||
});
|
||||
|
||||
it("does not set messageReplyOption for non-thread sends", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ name: "spaces/AAA/messages/124" }), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/124");
|
||||
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
|
||||
@ -14,70 +14,24 @@ const headersToObject = (headers?: HeadersInit): Record<string, string> =>
|
||||
? Object.fromEntries(headers)
|
||||
: headers || {};
|
||||
|
||||
async function fetchJson<T>(
|
||||
account: ResolvedGoogleChatAccount,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
): Promise<T> {
|
||||
const token = await getGoogleChatAccessToken(account);
|
||||
const { response: res, release } = await fetchWithSsrFGuard({
|
||||
async function withGoogleChatResponse<T>(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
url: string;
|
||||
init?: RequestInit;
|
||||
auditContext: string;
|
||||
errorPrefix?: string;
|
||||
handleResponse: (response: Response) => Promise<T>;
|
||||
}): Promise<T> {
|
||||
const {
|
||||
account,
|
||||
url,
|
||||
init: {
|
||||
...init,
|
||||
headers: {
|
||||
...headersToObject(init.headers),
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
auditContext: "googlechat.api.json",
|
||||
});
|
||||
try {
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOk(
|
||||
account: ResolvedGoogleChatAccount,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
): Promise<void> {
|
||||
init,
|
||||
auditContext,
|
||||
errorPrefix = "Google Chat API",
|
||||
handleResponse,
|
||||
} = params;
|
||||
const token = await getGoogleChatAccessToken(account);
|
||||
const { response: res, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init: {
|
||||
...init,
|
||||
headers: {
|
||||
...headersToObject(init.headers),
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
auditContext: "googlechat.api.ok",
|
||||
});
|
||||
try {
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBuffer(
|
||||
account: ResolvedGoogleChatAccount,
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
options?: { maxBytes?: number },
|
||||
): Promise<{ buffer: Buffer; contentType?: string }> {
|
||||
const token = await getGoogleChatAccessToken(account);
|
||||
const { response: res, release } = await fetchWithSsrFGuard({
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init: {
|
||||
...init,
|
||||
@ -86,52 +40,103 @@ async function fetchBuffer(
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
auditContext: "googlechat.api.buffer",
|
||||
auditContext,
|
||||
});
|
||||
try {
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`${errorPrefix} ${response.status}: ${text || response.statusText}`);
|
||||
}
|
||||
const maxBytes = options?.maxBytes;
|
||||
const lengthHeader = res.headers.get("content-length");
|
||||
if (maxBytes && lengthHeader) {
|
||||
const length = Number(lengthHeader);
|
||||
if (Number.isFinite(length) && length > maxBytes) {
|
||||
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
|
||||
}
|
||||
}
|
||||
if (!maxBytes || !res.body) {
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
const contentType = res.headers.get("content-type") ?? undefined;
|
||||
return { buffer, contentType };
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
total += value.length;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel();
|
||||
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
const buffer = Buffer.concat(chunks, total);
|
||||
const contentType = res.headers.get("content-type") ?? undefined;
|
||||
return { buffer, contentType };
|
||||
return await handleResponse(response);
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson<T>(
|
||||
account: ResolvedGoogleChatAccount,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
): Promise<T> {
|
||||
return await withGoogleChatResponse({
|
||||
account,
|
||||
url,
|
||||
init: {
|
||||
...init,
|
||||
headers: {
|
||||
...headersToObject(init.headers),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
auditContext: "googlechat.api.json",
|
||||
handleResponse: async (response) => (await response.json()) as T,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchOk(
|
||||
account: ResolvedGoogleChatAccount,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
): Promise<void> {
|
||||
await withGoogleChatResponse({
|
||||
account,
|
||||
url,
|
||||
init,
|
||||
auditContext: "googlechat.api.ok",
|
||||
handleResponse: async () => undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchBuffer(
|
||||
account: ResolvedGoogleChatAccount,
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
options?: { maxBytes?: number },
|
||||
): Promise<{ buffer: Buffer; contentType?: string }> {
|
||||
return await withGoogleChatResponse({
|
||||
account,
|
||||
url,
|
||||
init,
|
||||
auditContext: "googlechat.api.buffer",
|
||||
handleResponse: async (res) => {
|
||||
const maxBytes = options?.maxBytes;
|
||||
const lengthHeader = res.headers.get("content-length");
|
||||
if (maxBytes && lengthHeader) {
|
||||
const length = Number(lengthHeader);
|
||||
if (Number.isFinite(length) && length > maxBytes) {
|
||||
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
|
||||
}
|
||||
}
|
||||
if (!maxBytes || !res.body) {
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
const contentType = res.headers.get("content-type") ?? undefined;
|
||||
return { buffer, contentType };
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
total += value.length;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel();
|
||||
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
const buffer = Buffer.concat(chunks, total);
|
||||
const contentType = res.headers.get("content-type") ?? undefined;
|
||||
return { buffer, contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendGoogleChatMessage(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
space: string;
|
||||
@ -208,34 +213,29 @@ export async function uploadGoogleChatAttachment(params: {
|
||||
Buffer.from(footer, "utf8"),
|
||||
]);
|
||||
|
||||
const token = await getGoogleChatAccessToken(account);
|
||||
const url = `${CHAT_UPLOAD_BASE}/${space}/attachments:upload?uploadType=multipart`;
|
||||
const { response: res, release } = await fetchWithSsrFGuard({
|
||||
const payload = await withGoogleChatResponse<{
|
||||
attachmentDataRef?: { attachmentUploadToken?: string };
|
||||
}>({
|
||||
account,
|
||||
url,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": `multipart/related; boundary=${boundary}`,
|
||||
},
|
||||
body,
|
||||
},
|
||||
auditContext: "googlechat.upload",
|
||||
errorPrefix: "Google Chat upload",
|
||||
handleResponse: async (response) =>
|
||||
(await response.json()) as {
|
||||
attachmentDataRef?: { attachmentUploadToken?: string };
|
||||
},
|
||||
});
|
||||
try {
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Google Chat upload ${res.status}: ${text || res.statusText}`);
|
||||
}
|
||||
const payload = (await res.json()) as {
|
||||
attachmentDataRef?: { attachmentUploadToken?: string };
|
||||
};
|
||||
return {
|
||||
attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
return {
|
||||
attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken,
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadGoogleChatMedia(params: {
|
||||
|
||||
@ -117,6 +117,34 @@ function registerTwoTargets() {
|
||||
};
|
||||
}
|
||||
|
||||
async function dispatchWebhookRequest(req: IncomingMessage) {
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handleGoogleChatWebhookRequest(req, res);
|
||||
expect(handled).toBe(true);
|
||||
return res;
|
||||
}
|
||||
|
||||
async function expectVerifiedRoute(params: {
|
||||
request: IncomingMessage;
|
||||
expectedStatus: number;
|
||||
sinkA: ReturnType<typeof vi.fn>;
|
||||
sinkB: ReturnType<typeof vi.fn>;
|
||||
expectedSink: "none" | "A" | "B";
|
||||
}) {
|
||||
const res = await dispatchWebhookRequest(params.request);
|
||||
expect(res.statusCode).toBe(params.expectedStatus);
|
||||
const expectedCounts =
|
||||
params.expectedSink === "A" ? [1, 0] : params.expectedSink === "B" ? [0, 1] : [0, 0];
|
||||
expect(params.sinkA).toHaveBeenCalledTimes(expectedCounts[0]);
|
||||
expect(params.sinkB).toHaveBeenCalledTimes(expectedCounts[1]);
|
||||
}
|
||||
|
||||
function mockSecondVerifierSuccess() {
|
||||
vi.mocked(verifyGoogleChatRequest)
|
||||
.mockResolvedValueOnce({ ok: false, reason: "invalid" })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
}
|
||||
|
||||
describe("Google Chat webhook routing", () => {
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
@ -165,45 +193,37 @@ describe("Google Chat webhook routing", () => {
|
||||
const { sinkA, sinkB, unregister } = registerTwoTargets();
|
||||
|
||||
try {
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handleGoogleChatWebhookRequest(
|
||||
createWebhookRequest({
|
||||
await expectVerifiedRoute({
|
||||
request: createWebhookRequest({
|
||||
authorization: "Bearer test-token",
|
||||
payload: { type: "ADDED_TO_SPACE", space: { name: "spaces/AAA" } },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(sinkA).not.toHaveBeenCalled();
|
||||
expect(sinkB).not.toHaveBeenCalled();
|
||||
expectedStatus: 401,
|
||||
sinkA,
|
||||
sinkB,
|
||||
expectedSink: "none",
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes to the single verified target when earlier targets fail verification", async () => {
|
||||
vi.mocked(verifyGoogleChatRequest)
|
||||
.mockResolvedValueOnce({ ok: false, reason: "invalid" })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
mockSecondVerifierSuccess();
|
||||
|
||||
const { sinkA, sinkB, unregister } = registerTwoTargets();
|
||||
|
||||
try {
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handleGoogleChatWebhookRequest(
|
||||
createWebhookRequest({
|
||||
await expectVerifiedRoute({
|
||||
request: createWebhookRequest({
|
||||
authorization: "Bearer test-token",
|
||||
payload: { type: "ADDED_TO_SPACE", space: { name: "spaces/BBB" } },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(sinkA).not.toHaveBeenCalled();
|
||||
expect(sinkB).toHaveBeenCalledTimes(1);
|
||||
expectedStatus: 200,
|
||||
sinkA,
|
||||
sinkB,
|
||||
expectedSink: "B",
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
@ -218,10 +238,7 @@ describe("Google Chat webhook routing", () => {
|
||||
authorization: "Bearer invalid-token",
|
||||
});
|
||||
const onSpy = vi.spyOn(req, "on");
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handleGoogleChatWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
const res = await dispatchWebhookRequest(req);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
|
||||
} finally {
|
||||
@ -230,15 +247,12 @@ describe("Google Chat webhook routing", () => {
|
||||
});
|
||||
|
||||
it("supports add-on requests that provide systemIdToken in the body", async () => {
|
||||
vi.mocked(verifyGoogleChatRequest)
|
||||
.mockResolvedValueOnce({ ok: false, reason: "invalid" })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
mockSecondVerifierSuccess();
|
||||
const { sinkA, sinkB, unregister } = registerTwoTargets();
|
||||
|
||||
try {
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handleGoogleChatWebhookRequest(
|
||||
createWebhookRequest({
|
||||
await expectVerifiedRoute({
|
||||
request: createWebhookRequest({
|
||||
payload: {
|
||||
commonEventObject: { hostApp: "CHAT" },
|
||||
authorizationEventObject: { systemIdToken: "addon-token" },
|
||||
@ -252,13 +266,11 @@ describe("Google Chat webhook routing", () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(sinkA).not.toHaveBeenCalled();
|
||||
expect(sinkB).toHaveBeenCalledTimes(1);
|
||||
expectedStatus: 200,
|
||||
sinkA,
|
||||
sinkB,
|
||||
expectedSink: "B",
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
|
||||
@ -14,6 +14,19 @@ describe("resolveWindowsLobsterSpawn", () => {
|
||||
let tempDir = "";
|
||||
const originalProcessState = snapshotPlatformPathEnv();
|
||||
|
||||
async function expectUnwrappedShim(params: {
|
||||
scriptPath: string;
|
||||
shimPath: string;
|
||||
shimLine: string;
|
||||
}) {
|
||||
await createWindowsCmdShimFixture(params);
|
||||
|
||||
const target = resolveWindowsLobsterSpawn(params.shimPath, ["run", "noop"], process.env);
|
||||
expect(target.command).toBe(process.execPath);
|
||||
expect(target.argv).toEqual([params.scriptPath, "run", "noop"]);
|
||||
expect(target.windowsHide).toBe(true);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-win-spawn-"));
|
||||
setProcessPlatform("win32");
|
||||
@ -30,31 +43,21 @@ describe("resolveWindowsLobsterSpawn", () => {
|
||||
it("unwraps cmd shim with %dp0% token", async () => {
|
||||
const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
|
||||
const shimPath = path.join(tempDir, "shim", "lobster.cmd");
|
||||
await createWindowsCmdShimFixture({
|
||||
await expectUnwrappedShim({
|
||||
shimPath,
|
||||
scriptPath,
|
||||
shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
|
||||
});
|
||||
|
||||
const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env);
|
||||
expect(target.command).toBe(process.execPath);
|
||||
expect(target.argv).toEqual([scriptPath, "run", "noop"]);
|
||||
expect(target.windowsHide).toBe(true);
|
||||
});
|
||||
|
||||
it("unwraps cmd shim with %~dp0% token", async () => {
|
||||
const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
|
||||
const shimPath = path.join(tempDir, "shim", "lobster.cmd");
|
||||
await createWindowsCmdShimFixture({
|
||||
await expectUnwrappedShim({
|
||||
shimPath,
|
||||
scriptPath,
|
||||
shimLine: `"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
|
||||
});
|
||||
|
||||
const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env);
|
||||
expect(target.command).toBe(process.execPath);
|
||||
expect(target.argv).toEqual([scriptPath, "run", "noop"]);
|
||||
expect(target.windowsHide).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores node.exe shim entries and picks lobster script", async () => {
|
||||
|
||||
@ -7,6 +7,8 @@ import { createDirectRoomTracker } from "./direct.js";
|
||||
|
||||
type StateEvent = Record<string, unknown>;
|
||||
type DmMap = Record<string, boolean>;
|
||||
const brokenDmRoomId = "!broken-dm:example.org";
|
||||
const defaultBrokenDmMembers = ["@alice:example.org", "@bot:example.org"];
|
||||
|
||||
function createMockClient(opts: {
|
||||
dmRooms?: DmMap;
|
||||
@ -50,6 +52,21 @@ function createMockClient(opts: {
|
||||
};
|
||||
}
|
||||
|
||||
function createBrokenDmClient(roomNameEvent?: StateEvent) {
|
||||
return createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: {
|
||||
[brokenDmRoomId]: defaultBrokenDmMembers,
|
||||
},
|
||||
stateEvents: {
|
||||
// is_direct not set on either member (e.g. Continuwuity bug)
|
||||
[`${brokenDmRoomId}|m.room.member|@alice:example.org`]: {},
|
||||
[`${brokenDmRoomId}|m.room.member|@bot:example.org`]: {},
|
||||
...(roomNameEvent ? { [`${brokenDmRoomId}|m.room.name|`]: roomNameEvent } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests -- isDirectMessage
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -131,22 +148,11 @@ describe("createDirectRoomTracker", () => {
|
||||
|
||||
describe("conservative fallback (memberCount + room name)", () => {
|
||||
it("returns true for 2-member room WITHOUT a room name (broken flags)", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: {
|
||||
"!broken-dm:example.org": ["@alice:example.org", "@bot:example.org"],
|
||||
},
|
||||
stateEvents: {
|
||||
// is_direct not set on either member (e.g. Continuwuity bug)
|
||||
"!broken-dm:example.org|m.room.member|@alice:example.org": {},
|
||||
"!broken-dm:example.org|m.room.member|@bot:example.org": {},
|
||||
// No m.room.name -> getRoomStateEvent will throw (event not found)
|
||||
},
|
||||
});
|
||||
const client = createBrokenDmClient();
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!broken-dm:example.org",
|
||||
roomId: brokenDmRoomId,
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
@ -154,21 +160,11 @@ describe("createDirectRoomTracker", () => {
|
||||
});
|
||||
|
||||
it("returns true for 2-member room with empty room name", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: {
|
||||
"!broken-dm:example.org": ["@alice:example.org", "@bot:example.org"],
|
||||
},
|
||||
stateEvents: {
|
||||
"!broken-dm:example.org|m.room.member|@alice:example.org": {},
|
||||
"!broken-dm:example.org|m.room.member|@bot:example.org": {},
|
||||
"!broken-dm:example.org|m.room.name|": { name: "" },
|
||||
},
|
||||
});
|
||||
const client = createBrokenDmClient({ name: "" });
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!broken-dm:example.org",
|
||||
roomId: brokenDmRoomId,
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
|
||||
@ -12,6 +12,8 @@ vi.mock("../send.js", () => ({
|
||||
}));
|
||||
|
||||
describe("registerMatrixMonitorEvents", () => {
|
||||
const roomId = "!room:example.org";
|
||||
|
||||
beforeEach(() => {
|
||||
sendReadReceiptMatrixMock.mockClear();
|
||||
});
|
||||
@ -53,6 +55,16 @@ describe("registerMatrixMonitorEvents", () => {
|
||||
return { client, getUserId, onRoomMessage, roomMessageHandler, logVerboseMessage };
|
||||
}
|
||||
|
||||
async function expectForwardedWithoutReadReceipt(event: MatrixRawEvent) {
|
||||
const { onRoomMessage, roomMessageHandler } = createHarness();
|
||||
|
||||
roomMessageHandler(roomId, event);
|
||||
await vi.waitFor(() => {
|
||||
expect(onRoomMessage).toHaveBeenCalledWith(roomId, event);
|
||||
});
|
||||
expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
it("sends read receipt immediately for non-self messages", async () => {
|
||||
const { client, onRoomMessage, roomMessageHandler } = createHarness();
|
||||
const event = {
|
||||
@ -69,30 +81,16 @@ describe("registerMatrixMonitorEvents", () => {
|
||||
});
|
||||
|
||||
it("does not send read receipts for self messages", async () => {
|
||||
const { onRoomMessage, roomMessageHandler } = createHarness();
|
||||
const event = {
|
||||
await expectForwardedWithoutReadReceipt({
|
||||
event_id: "$e2",
|
||||
sender: "@bot:example.org",
|
||||
} as MatrixRawEvent;
|
||||
|
||||
roomMessageHandler("!room:example.org", event);
|
||||
await vi.waitFor(() => {
|
||||
expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event);
|
||||
});
|
||||
expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips receipt when message lacks sender or event id", async () => {
|
||||
const { onRoomMessage, roomMessageHandler } = createHarness();
|
||||
const event = {
|
||||
await expectForwardedWithoutReadReceipt({
|
||||
sender: "@alice:example.org",
|
||||
} as MatrixRawEvent;
|
||||
|
||||
roomMessageHandler("!room:example.org", event);
|
||||
await vi.waitFor(() => {
|
||||
expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event);
|
||||
});
|
||||
expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caches self user id across messages", async () => {
|
||||
|
||||
@ -41,12 +41,12 @@ function normalizeAgentId(value: string | undefined | null): string {
|
||||
|
||||
type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number];
|
||||
|
||||
function isAgentEntry(entry: unknown): entry is AgentEntry {
|
||||
return Boolean(entry && typeof entry === "object");
|
||||
}
|
||||
|
||||
function listAgents(cfg: OpenClawConfig): AgentEntry[] {
|
||||
const list = cfg.agents?.list;
|
||||
if (!Array.isArray(list)) {
|
||||
return [];
|
||||
}
|
||||
return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
|
||||
return Array.isArray(cfg.agents?.list) ? cfg.agents.list.filter(isAgentEntry) : [];
|
||||
}
|
||||
|
||||
function resolveAgentEntry(cfg: OpenClawConfig, agentId: string): AgentEntry | undefined {
|
||||
|
||||
@ -31,6 +31,23 @@ function mockFetchWithRedirect(redirectMap: Record<string, string>, finalBody =
|
||||
});
|
||||
}
|
||||
|
||||
async function expectSafeFetchStatus(params: {
|
||||
fetchMock: ReturnType<typeof vi.fn>;
|
||||
url: string;
|
||||
allowHosts: string[];
|
||||
expectedStatus: number;
|
||||
resolveFn?: typeof publicResolve;
|
||||
}) {
|
||||
const res = await safeFetch({
|
||||
url: params.url,
|
||||
allowHosts: params.allowHosts,
|
||||
fetchFn: params.fetchMock as unknown as typeof fetch,
|
||||
resolveFn: params.resolveFn ?? publicResolve,
|
||||
});
|
||||
expect(res.status).toBe(params.expectedStatus);
|
||||
return res;
|
||||
}
|
||||
|
||||
describe("msteams attachment allowlists", () => {
|
||||
it("normalizes wildcard host lists", () => {
|
||||
expect(resolveAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]);
|
||||
@ -121,13 +138,12 @@ describe("safeFetch", () => {
|
||||
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => {
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
const res = await safeFetch({
|
||||
await expectSafeFetchStatus({
|
||||
fetchMock,
|
||||
url: "https://teams.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: publicResolve,
|
||||
expectedStatus: 200,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
// Should have used redirect: "manual"
|
||||
expect(fetchMock.mock.calls[0][1]).toHaveProperty("redirect", "manual");
|
||||
@ -137,13 +153,12 @@ describe("safeFetch", () => {
|
||||
const fetchMock = mockFetchWithRedirect({
|
||||
"https://teams.sharepoint.com/file.pdf": "https://cdn.sharepoint.com/storage/file.pdf",
|
||||
});
|
||||
const res = await safeFetch({
|
||||
await expectSafeFetchStatus({
|
||||
fetchMock,
|
||||
url: "https://teams.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: publicResolve,
|
||||
expectedStatus: 200,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
|
||||
@ -15,6 +15,18 @@ vi.mock("./runtime.js", () => ({
|
||||
|
||||
import { slackPlugin } from "./channel.js";
|
||||
|
||||
async function getSlackConfiguredState(cfg: OpenClawConfig) {
|
||||
const account = slackPlugin.config.resolveAccount(cfg, "default");
|
||||
return {
|
||||
configured: slackPlugin.config.isConfigured?.(account, cfg),
|
||||
snapshot: await slackPlugin.status?.buildAccountSnapshot?.({
|
||||
account,
|
||||
cfg,
|
||||
runtime: undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("slackPlugin actions", () => {
|
||||
it("prefers session lookup for announce target routing", () => {
|
||||
expect(slackPlugin.meta.preferSessionLookupForAnnounceTarget).toBe(true);
|
||||
@ -189,13 +201,7 @@ describe("slackPlugin config", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const account = slackPlugin.config.resolveAccount(cfg, "default");
|
||||
const configured = slackPlugin.config.isConfigured?.(account, cfg);
|
||||
const snapshot = await slackPlugin.status?.buildAccountSnapshot?.({
|
||||
account,
|
||||
cfg,
|
||||
runtime: undefined,
|
||||
});
|
||||
const { configured, snapshot } = await getSlackConfiguredState(cfg);
|
||||
|
||||
expect(configured).toBe(true);
|
||||
expect(snapshot?.configured).toBe(true);
|
||||
@ -211,13 +217,7 @@ describe("slackPlugin config", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const account = slackPlugin.config.resolveAccount(cfg, "default");
|
||||
const configured = slackPlugin.config.isConfigured?.(account, cfg);
|
||||
const snapshot = await slackPlugin.status?.buildAccountSnapshot?.({
|
||||
account,
|
||||
cfg,
|
||||
runtime: undefined,
|
||||
});
|
||||
const { configured, snapshot } = await getSlackConfiguredState(cfg);
|
||||
|
||||
expect(configured).toBe(false);
|
||||
expect(snapshot?.configured).toBe(false);
|
||||
|
||||
@ -2,8 +2,7 @@
|
||||
* Type definitions for the Synology Chat channel plugin.
|
||||
*/
|
||||
|
||||
/** Raw channel config from openclaw.json channels.synology-chat */
|
||||
export interface SynologyChatChannelConfig {
|
||||
type SynologyChatConfigFields = {
|
||||
enabled?: boolean;
|
||||
token?: string;
|
||||
incomingUrl?: string;
|
||||
@ -14,22 +13,15 @@ export interface SynologyChatChannelConfig {
|
||||
rateLimitPerMinute?: number;
|
||||
botName?: string;
|
||||
allowInsecureSsl?: boolean;
|
||||
};
|
||||
|
||||
/** Raw channel config from openclaw.json channels.synology-chat */
|
||||
export interface SynologyChatChannelConfig extends SynologyChatConfigFields {
|
||||
accounts?: Record<string, SynologyChatAccountRaw>;
|
||||
}
|
||||
|
||||
/** Raw per-account config (overrides base config) */
|
||||
export interface SynologyChatAccountRaw {
|
||||
enabled?: boolean;
|
||||
token?: string;
|
||||
incomingUrl?: string;
|
||||
nasHost?: string;
|
||||
webhookPath?: string;
|
||||
dmPolicy?: "open" | "allowlist" | "disabled";
|
||||
allowedUserIds?: string | string[];
|
||||
rateLimitPerMinute?: number;
|
||||
botName?: string;
|
||||
allowInsecureSsl?: boolean;
|
||||
}
|
||||
export interface SynologyChatAccountRaw extends SynologyChatConfigFields {}
|
||||
|
||||
/** Fully resolved account config with defaults applied */
|
||||
export interface ResolvedSynologyChatAccount {
|
||||
|
||||
@ -91,6 +91,30 @@ function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: strin
|
||||
};
|
||||
}
|
||||
|
||||
function configureOpsProxyNetwork(cfg: OpenClawConfig) {
|
||||
cfg.channels!.telegram!.accounts!.ops = {
|
||||
...cfg.channels!.telegram!.accounts!.ops,
|
||||
proxy: "http://127.0.0.1:8888",
|
||||
network: {
|
||||
autoSelectFamily: false,
|
||||
dnsResultOrder: "ipv4first",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installSendMessageRuntime(
|
||||
sendMessageTelegram: ReturnType<typeof vi.fn>,
|
||||
): ReturnType<typeof vi.fn> {
|
||||
setTelegramRuntime({
|
||||
channel: {
|
||||
telegram: {
|
||||
sendMessageTelegram,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
return sendMessageTelegram;
|
||||
}
|
||||
|
||||
describe("telegramPlugin duplicate token guard", () => {
|
||||
it("marks secondary account as not configured when token is shared", async () => {
|
||||
const cfg = createCfg();
|
||||
@ -176,14 +200,7 @@ describe("telegramPlugin duplicate token guard", () => {
|
||||
});
|
||||
|
||||
const cfg = createCfg();
|
||||
cfg.channels!.telegram!.accounts!.ops = {
|
||||
...cfg.channels!.telegram!.accounts!.ops,
|
||||
proxy: "http://127.0.0.1:8888",
|
||||
network: {
|
||||
autoSelectFamily: false,
|
||||
dnsResultOrder: "ipv4first",
|
||||
},
|
||||
};
|
||||
configureOpsProxyNetwork(cfg);
|
||||
const account = telegramPlugin.config.resolveAccount(cfg, "ops");
|
||||
|
||||
await telegramPlugin.status!.probeAccount!({
|
||||
@ -215,13 +232,9 @@ describe("telegramPlugin duplicate token guard", () => {
|
||||
});
|
||||
|
||||
const cfg = createCfg();
|
||||
configureOpsProxyNetwork(cfg);
|
||||
cfg.channels!.telegram!.accounts!.ops = {
|
||||
...cfg.channels!.telegram!.accounts!.ops,
|
||||
proxy: "http://127.0.0.1:8888",
|
||||
network: {
|
||||
autoSelectFamily: false,
|
||||
dnsResultOrder: "ipv4first",
|
||||
},
|
||||
groups: {
|
||||
"-100123": { requireMention: false },
|
||||
},
|
||||
@ -249,14 +262,9 @@ describe("telegramPlugin duplicate token guard", () => {
|
||||
});
|
||||
|
||||
it("forwards mediaLocalRoots to sendMessageTelegram for outbound media sends", async () => {
|
||||
const sendMessageTelegram = vi.fn(async () => ({ messageId: "tg-1" }));
|
||||
setTelegramRuntime({
|
||||
channel: {
|
||||
telegram: {
|
||||
sendMessageTelegram,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
const sendMessageTelegram = installSendMessageRuntime(
|
||||
vi.fn(async () => ({ messageId: "tg-1" })),
|
||||
);
|
||||
|
||||
const result = await telegramPlugin.outbound!.sendMedia!({
|
||||
cfg: createCfg(),
|
||||
@ -279,14 +287,9 @@ describe("telegramPlugin duplicate token guard", () => {
|
||||
});
|
||||
|
||||
it("preserves buttons for outbound text payload sends", async () => {
|
||||
const sendMessageTelegram = vi.fn(async () => ({ messageId: "tg-2" }));
|
||||
setTelegramRuntime({
|
||||
channel: {
|
||||
telegram: {
|
||||
sendMessageTelegram,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
const sendMessageTelegram = installSendMessageRuntime(
|
||||
vi.fn(async () => ({ messageId: "tg-2" })),
|
||||
);
|
||||
|
||||
const result = await telegramPlugin.outbound!.sendPayload!({
|
||||
cfg: createCfg(),
|
||||
@ -314,17 +317,12 @@ describe("telegramPlugin duplicate token guard", () => {
|
||||
});
|
||||
|
||||
it("sends outbound payload media lists and keeps buttons on the first message only", async () => {
|
||||
const sendMessageTelegram = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ messageId: "tg-3", chatId: "12345" })
|
||||
.mockResolvedValueOnce({ messageId: "tg-4", chatId: "12345" });
|
||||
setTelegramRuntime({
|
||||
channel: {
|
||||
telegram: {
|
||||
sendMessageTelegram,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
const sendMessageTelegram = installSendMessageRuntime(
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ messageId: "tg-3", chatId: "12345" })
|
||||
.mockResolvedValueOnce({ messageId: "tg-4", chatId: "12345" }),
|
||||
);
|
||||
|
||||
const result = await telegramPlugin.outbound!.sendPayload!({
|
||||
cfg: createCfg(),
|
||||
|
||||
@ -46,6 +46,20 @@ function assertResolvedTarget(
|
||||
return result.to;
|
||||
}
|
||||
|
||||
function expectTargetError(
|
||||
resolveTarget: NonNullable<typeof twitchOutbound.resolveTarget>,
|
||||
params: Parameters<NonNullable<typeof twitchOutbound.resolveTarget>>[0],
|
||||
expectedMessage: string,
|
||||
) {
|
||||
const result = resolveTarget(params);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("expected resolveTarget to fail");
|
||||
}
|
||||
expect(result.error.message).toContain(expectedMessage);
|
||||
}
|
||||
|
||||
describe("outbound", () => {
|
||||
const mockAccount = {
|
||||
...BASE_TWITCH_TEST_ACCOUNT,
|
||||
@ -106,17 +120,15 @@ describe("outbound", () => {
|
||||
});
|
||||
|
||||
it("should error when target not in allowlist (implicit mode)", () => {
|
||||
const result = resolveTarget({
|
||||
to: "#notallowed",
|
||||
mode: "implicit",
|
||||
allowFrom: ["#primary", "#secondary"],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("expected resolveTarget to fail");
|
||||
}
|
||||
expect(result.error.message).toContain("Twitch");
|
||||
expectTargetError(
|
||||
resolveTarget,
|
||||
{
|
||||
to: "#notallowed",
|
||||
mode: "implicit",
|
||||
allowFrom: ["#primary", "#secondary"],
|
||||
},
|
||||
"Twitch",
|
||||
);
|
||||
});
|
||||
|
||||
it("should accept any target when allowlist is empty", () => {
|
||||
@ -131,59 +143,51 @@ describe("outbound", () => {
|
||||
});
|
||||
|
||||
it("should error when no target provided with allowlist", () => {
|
||||
const result = resolveTarget({
|
||||
to: undefined,
|
||||
mode: "implicit",
|
||||
allowFrom: ["#fallback", "#other"],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("expected resolveTarget to fail");
|
||||
}
|
||||
expect(result.error.message).toContain("Twitch");
|
||||
expectTargetError(
|
||||
resolveTarget,
|
||||
{
|
||||
to: undefined,
|
||||
mode: "implicit",
|
||||
allowFrom: ["#fallback", "#other"],
|
||||
},
|
||||
"Twitch",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return error when no target and no allowlist", () => {
|
||||
const result = resolveTarget({
|
||||
to: undefined,
|
||||
mode: "explicit",
|
||||
allowFrom: [],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("expected resolveTarget to fail");
|
||||
}
|
||||
expect(result.error.message).toContain("Missing target");
|
||||
expectTargetError(
|
||||
resolveTarget,
|
||||
{
|
||||
to: undefined,
|
||||
mode: "explicit",
|
||||
allowFrom: [],
|
||||
},
|
||||
"Missing target",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle whitespace-only target", () => {
|
||||
const result = resolveTarget({
|
||||
to: " ",
|
||||
mode: "explicit",
|
||||
allowFrom: [],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("expected resolveTarget to fail");
|
||||
}
|
||||
expect(result.error.message).toContain("Missing target");
|
||||
expectTargetError(
|
||||
resolveTarget,
|
||||
{
|
||||
to: " ",
|
||||
mode: "explicit",
|
||||
allowFrom: [],
|
||||
},
|
||||
"Missing target",
|
||||
);
|
||||
});
|
||||
|
||||
it("should error when target normalizes to empty string", () => {
|
||||
const result = resolveTarget({
|
||||
to: "#",
|
||||
mode: "explicit",
|
||||
allowFrom: [],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("expected resolveTarget to fail");
|
||||
}
|
||||
expect(result.error.message).toContain("Twitch");
|
||||
expectTargetError(
|
||||
resolveTarget,
|
||||
{
|
||||
to: "#",
|
||||
mode: "explicit",
|
||||
allowFrom: [],
|
||||
},
|
||||
"Twitch",
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter wildcard from allowlist when checking membership", () => {
|
||||
|
||||
@ -55,7 +55,10 @@ describe("send", () => {
|
||||
installTwitchTestHooks();
|
||||
|
||||
describe("sendMessageTwitchInternal", () => {
|
||||
it("should send a message successfully", async () => {
|
||||
async function mockSuccessfulSend(params: {
|
||||
messageId: string;
|
||||
stripMarkdown?: (text: string) => string;
|
||||
}) {
|
||||
const { getAccountConfig } = await import("./config.js");
|
||||
const { getClientManager } = await import("./client-manager-registry.js");
|
||||
const { stripMarkdownForTwitch } = await import("./utils/markdown.js");
|
||||
@ -64,10 +67,18 @@ describe("send", () => {
|
||||
vi.mocked(getClientManager).mockReturnValue({
|
||||
sendMessage: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
messageId: "twitch-msg-123",
|
||||
messageId: params.messageId,
|
||||
}),
|
||||
} as unknown as ReturnType<typeof getClientManager>);
|
||||
vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text);
|
||||
vi.mocked(stripMarkdownForTwitch).mockImplementation(
|
||||
params.stripMarkdown ?? ((text) => text),
|
||||
);
|
||||
|
||||
return { stripMarkdownForTwitch };
|
||||
}
|
||||
|
||||
it("should send a message successfully", async () => {
|
||||
await mockSuccessfulSend({ messageId: "twitch-msg-123" });
|
||||
|
||||
const result = await sendMessageTwitchInternal(
|
||||
"#testchannel",
|
||||
@ -83,18 +94,10 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
it("should strip markdown when enabled", async () => {
|
||||
const { getAccountConfig } = await import("./config.js");
|
||||
const { getClientManager } = await import("./client-manager-registry.js");
|
||||
const { stripMarkdownForTwitch } = await import("./utils/markdown.js");
|
||||
|
||||
vi.mocked(getAccountConfig).mockReturnValue(mockAccount);
|
||||
vi.mocked(getClientManager).mockReturnValue({
|
||||
sendMessage: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
messageId: "twitch-msg-456",
|
||||
}),
|
||||
} as unknown as ReturnType<typeof getClientManager>);
|
||||
vi.mocked(stripMarkdownForTwitch).mockImplementation((text) => text.replace(/\*\*/g, ""));
|
||||
const { stripMarkdownForTwitch } = await mockSuccessfulSend({
|
||||
messageId: "twitch-msg-456",
|
||||
stripMarkdown: (text) => text.replace(/\*\*/g, ""),
|
||||
});
|
||||
|
||||
await sendMessageTwitchInternal(
|
||||
"#testchannel",
|
||||
|
||||
@ -22,6 +22,34 @@ function decodeBase64Url(input: string): Buffer {
|
||||
return Buffer.from(padded, "base64");
|
||||
}
|
||||
|
||||
function createSignedTelnyxCtx(params: {
|
||||
privateKey: crypto.KeyObject;
|
||||
rawBody: string;
|
||||
}): WebhookContext {
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const signedPayload = `${timestamp}|${params.rawBody}`;
|
||||
const signature = crypto
|
||||
.sign(null, Buffer.from(signedPayload), params.privateKey)
|
||||
.toString("base64");
|
||||
|
||||
return createCtx({
|
||||
rawBody: params.rawBody,
|
||||
headers: {
|
||||
"telnyx-signature-ed25519": signature,
|
||||
"telnyx-timestamp": timestamp,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function expectReplayVerification(
|
||||
results: Array<{ ok: boolean; isReplay?: boolean; verifiedRequestKey?: string }>,
|
||||
) {
|
||||
expect(results.map((result) => result.ok)).toEqual([true, true]);
|
||||
expect(results.map((result) => Boolean(result.isReplay))).toEqual([false, true]);
|
||||
expect(results[0]?.verifiedRequestKey).toEqual(expect.any(String));
|
||||
expect(results[1]?.verifiedRequestKey).toBe(results[0]?.verifiedRequestKey);
|
||||
}
|
||||
|
||||
function expectWebhookVerificationSucceeds(params: {
|
||||
publicKey: string;
|
||||
privateKey: crypto.KeyObject;
|
||||
@ -35,20 +63,8 @@ function expectWebhookVerificationSucceeds(params: {
|
||||
event_type: "call.initiated",
|
||||
payload: { call_control_id: "x" },
|
||||
});
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const signedPayload = `${timestamp}|${rawBody}`;
|
||||
const signature = crypto
|
||||
.sign(null, Buffer.from(signedPayload), params.privateKey)
|
||||
.toString("base64");
|
||||
|
||||
const result = provider.verifyWebhook(
|
||||
createCtx({
|
||||
rawBody,
|
||||
headers: {
|
||||
"telnyx-signature-ed25519": signature,
|
||||
"telnyx-timestamp": timestamp,
|
||||
},
|
||||
}),
|
||||
createSignedTelnyxCtx({ privateKey: params.privateKey, rawBody }),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
}
|
||||
@ -117,26 +133,12 @@ describe("TelnyxProvider.verifyWebhook", () => {
|
||||
payload: { call_control_id: "call-replay-test" },
|
||||
nonce: crypto.randomUUID(),
|
||||
});
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const signedPayload = `${timestamp}|${rawBody}`;
|
||||
const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
|
||||
const ctx = createCtx({
|
||||
rawBody,
|
||||
headers: {
|
||||
"telnyx-signature-ed25519": signature,
|
||||
"telnyx-timestamp": timestamp,
|
||||
},
|
||||
});
|
||||
const ctx = createSignedTelnyxCtx({ privateKey, rawBody });
|
||||
|
||||
const first = provider.verifyWebhook(ctx);
|
||||
const second = provider.verifyWebhook(ctx);
|
||||
|
||||
expect(first.ok).toBe(true);
|
||||
expect(first.isReplay).toBeFalsy();
|
||||
expect(first.verifiedRequestKey).toBeTruthy();
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.isReplay).toBe(true);
|
||||
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
|
||||
expectReplayVerification([first, second]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -98,6 +98,51 @@ function expectReplayResultPair(
|
||||
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
|
||||
}
|
||||
|
||||
function expectAcceptedWebhookVersion(
|
||||
result: { ok: boolean; version?: string },
|
||||
version: "v2" | "v3",
|
||||
) {
|
||||
expect(result).toMatchObject({ ok: true, version });
|
||||
}
|
||||
|
||||
function verifyTwilioNgrokLoopback(signature: string) {
|
||||
return verifyTwilioWebhook(
|
||||
{
|
||||
headers: {
|
||||
host: "127.0.0.1:3334",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-host": "local.ngrok-free.app",
|
||||
"x-twilio-signature": signature,
|
||||
},
|
||||
rawBody: "CallSid=CS123&CallStatus=completed&From=%2B15550000000",
|
||||
url: "http://127.0.0.1:3334/voice/webhook",
|
||||
method: "POST",
|
||||
remoteAddress: "127.0.0.1",
|
||||
},
|
||||
"test-auth-token",
|
||||
{ allowNgrokFreeTierLoopbackBypass: true },
|
||||
);
|
||||
}
|
||||
|
||||
function verifyTwilioSignedRequest(params: {
|
||||
headers: Record<string, string>;
|
||||
rawBody: string;
|
||||
authToken: string;
|
||||
publicUrl: string;
|
||||
}) {
|
||||
return verifyTwilioWebhook(
|
||||
{
|
||||
headers: params.headers,
|
||||
rawBody: params.rawBody,
|
||||
url: "http://local/voice/webhook?callId=abc",
|
||||
method: "POST",
|
||||
query: { callId: "abc" },
|
||||
},
|
||||
params.authToken,
|
||||
{ publicUrl: params.publicUrl },
|
||||
);
|
||||
}
|
||||
|
||||
describe("verifyPlivoWebhook", () => {
|
||||
it("accepts valid V2 signature", () => {
|
||||
const authToken = "test-auth-token";
|
||||
@ -127,8 +172,7 @@ describe("verifyPlivoWebhook", () => {
|
||||
authToken,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.version).toBe("v2");
|
||||
expectAcceptedWebhookVersion(result, "v2");
|
||||
});
|
||||
|
||||
it("accepts valid V3 signature (including multi-signature header)", () => {
|
||||
@ -161,8 +205,7 @@ describe("verifyPlivoWebhook", () => {
|
||||
authToken,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.version).toBe("v3");
|
||||
expectAcceptedWebhookVersion(result, "v3");
|
||||
});
|
||||
|
||||
it("rejects missing signatures", () => {
|
||||
@ -317,35 +360,10 @@ describe("verifyTwilioWebhook", () => {
|
||||
"i-twilio-idempotency-token": "idem-replay-1",
|
||||
};
|
||||
|
||||
const first = verifyTwilioWebhook(
|
||||
{
|
||||
headers,
|
||||
rawBody: postBody,
|
||||
url: "http://local/voice/webhook?callId=abc",
|
||||
method: "POST",
|
||||
query: { callId: "abc" },
|
||||
},
|
||||
authToken,
|
||||
{ publicUrl },
|
||||
);
|
||||
const second = verifyTwilioWebhook(
|
||||
{
|
||||
headers,
|
||||
rawBody: postBody,
|
||||
url: "http://local/voice/webhook?callId=abc",
|
||||
method: "POST",
|
||||
query: { callId: "abc" },
|
||||
},
|
||||
authToken,
|
||||
{ publicUrl },
|
||||
);
|
||||
const first = verifyTwilioSignedRequest({ headers, rawBody: postBody, authToken, publicUrl });
|
||||
const second = verifyTwilioSignedRequest({ headers, rawBody: postBody, authToken, publicUrl });
|
||||
|
||||
expect(first.ok).toBe(true);
|
||||
expect(first.isReplay).toBeFalsy();
|
||||
expect(first.verifiedRequestKey).toBeTruthy();
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.isReplay).toBe(true);
|
||||
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
|
||||
expectReplayResultPair(first, second);
|
||||
});
|
||||
|
||||
it("treats changed idempotency header as replay for identical signed requests", () => {
|
||||
@ -355,45 +373,30 @@ describe("verifyTwilioWebhook", () => {
|
||||
const postBody = "CallSid=CS778&CallStatus=completed&From=%2B15550000000";
|
||||
const signature = twilioSignature({ authToken, url: urlWithQuery, postBody });
|
||||
|
||||
const first = verifyTwilioWebhook(
|
||||
{
|
||||
headers: {
|
||||
host: "example.com",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-twilio-signature": signature,
|
||||
"i-twilio-idempotency-token": "idem-replay-a",
|
||||
},
|
||||
rawBody: postBody,
|
||||
url: "http://local/voice/webhook?callId=abc",
|
||||
method: "POST",
|
||||
query: { callId: "abc" },
|
||||
const first = verifyTwilioSignedRequest({
|
||||
headers: {
|
||||
host: "example.com",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-twilio-signature": signature,
|
||||
"i-twilio-idempotency-token": "idem-replay-a",
|
||||
},
|
||||
rawBody: postBody,
|
||||
authToken,
|
||||
{ publicUrl },
|
||||
);
|
||||
const second = verifyTwilioWebhook(
|
||||
{
|
||||
headers: {
|
||||
host: "example.com",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-twilio-signature": signature,
|
||||
"i-twilio-idempotency-token": "idem-replay-b",
|
||||
},
|
||||
rawBody: postBody,
|
||||
url: "http://local/voice/webhook?callId=abc",
|
||||
method: "POST",
|
||||
query: { callId: "abc" },
|
||||
publicUrl,
|
||||
});
|
||||
const second = verifyTwilioSignedRequest({
|
||||
headers: {
|
||||
host: "example.com",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-twilio-signature": signature,
|
||||
"i-twilio-idempotency-token": "idem-replay-b",
|
||||
},
|
||||
rawBody: postBody,
|
||||
authToken,
|
||||
{ publicUrl },
|
||||
);
|
||||
publicUrl,
|
||||
});
|
||||
|
||||
expect(first.ok).toBe(true);
|
||||
expect(first.isReplay).toBe(false);
|
||||
expect(first.verifiedRequestKey).toBeTruthy();
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.isReplay).toBe(true);
|
||||
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
|
||||
expectReplayResultPair(first, second);
|
||||
});
|
||||
|
||||
it("rejects invalid signatures even when attacker injects forwarded host", () => {
|
||||
@ -422,57 +425,22 @@ describe("verifyTwilioWebhook", () => {
|
||||
});
|
||||
|
||||
it("accepts valid signatures for ngrok free tier on loopback when compatibility mode is enabled", () => {
|
||||
const authToken = "test-auth-token";
|
||||
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
|
||||
const webhookUrl = "https://local.ngrok-free.app/voice/webhook";
|
||||
|
||||
const signature = twilioSignature({
|
||||
authToken,
|
||||
authToken: "test-auth-token",
|
||||
url: webhookUrl,
|
||||
postBody,
|
||||
postBody: "CallSid=CS123&CallStatus=completed&From=%2B15550000000",
|
||||
});
|
||||
|
||||
const result = verifyTwilioWebhook(
|
||||
{
|
||||
headers: {
|
||||
host: "127.0.0.1:3334",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-host": "local.ngrok-free.app",
|
||||
"x-twilio-signature": signature,
|
||||
},
|
||||
rawBody: postBody,
|
||||
url: "http://127.0.0.1:3334/voice/webhook",
|
||||
method: "POST",
|
||||
remoteAddress: "127.0.0.1",
|
||||
},
|
||||
authToken,
|
||||
{ allowNgrokFreeTierLoopbackBypass: true },
|
||||
);
|
||||
const result = verifyTwilioNgrokLoopback(signature);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.verificationUrl).toBe(webhookUrl);
|
||||
});
|
||||
|
||||
it("does not allow invalid signatures for ngrok free tier on loopback", () => {
|
||||
const authToken = "test-auth-token";
|
||||
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
|
||||
|
||||
const result = verifyTwilioWebhook(
|
||||
{
|
||||
headers: {
|
||||
host: "127.0.0.1:3334",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-host": "local.ngrok-free.app",
|
||||
"x-twilio-signature": "invalid",
|
||||
},
|
||||
rawBody: postBody,
|
||||
url: "http://127.0.0.1:3334/voice/webhook",
|
||||
method: "POST",
|
||||
remoteAddress: "127.0.0.1",
|
||||
},
|
||||
authToken,
|
||||
{ allowNgrokFreeTierLoopbackBypass: true },
|
||||
);
|
||||
const result = verifyTwilioNgrokLoopback("invalid");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toMatch(/Invalid signature/);
|
||||
|
||||
@ -56,6 +56,28 @@ const createManager = (calls: CallRecord[]) => {
|
||||
return { manager, endCall, processEvent };
|
||||
};
|
||||
|
||||
async function runStaleCallReaperCase(params: {
|
||||
callAgeMs: number;
|
||||
staleCallReaperSeconds: number;
|
||||
advanceMs: number;
|
||||
}) {
|
||||
const now = new Date("2026-02-16T00:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const call = createCall(now.getTime() - params.callAgeMs);
|
||||
const { manager, endCall } = createManager([call]);
|
||||
const config = createConfig({ staleCallReaperSeconds: params.staleCallReaperSeconds });
|
||||
const server = new VoiceCallWebhookServer(config, manager, provider);
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
await vi.advanceTimersByTimeAsync(params.advanceMs);
|
||||
return { call, endCall };
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async function postWebhookForm(server: VoiceCallWebhookServer, baseUrl: string, body: string) {
|
||||
const address = (
|
||||
server as unknown as { server?: { address?: () => unknown } }
|
||||
@ -81,39 +103,21 @@ describe("VoiceCallWebhookServer stale call reaper", () => {
|
||||
});
|
||||
|
||||
it("ends calls older than staleCallReaperSeconds", async () => {
|
||||
const now = new Date("2026-02-16T00:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const call = createCall(now.getTime() - 120_000);
|
||||
const { manager, endCall } = createManager([call]);
|
||||
const config = createConfig({ staleCallReaperSeconds: 60 });
|
||||
const server = new VoiceCallWebhookServer(config, manager, provider);
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(endCall).toHaveBeenCalledWith(call.callId);
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
const { call, endCall } = await runStaleCallReaperCase({
|
||||
callAgeMs: 120_000,
|
||||
staleCallReaperSeconds: 60,
|
||||
advanceMs: 30_000,
|
||||
});
|
||||
expect(endCall).toHaveBeenCalledWith(call.callId);
|
||||
});
|
||||
|
||||
it("skips calls that are younger than the threshold", async () => {
|
||||
const now = new Date("2026-02-16T00:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const call = createCall(now.getTime() - 10_000);
|
||||
const { manager, endCall } = createManager([call]);
|
||||
const config = createConfig({ staleCallReaperSeconds: 60 });
|
||||
const server = new VoiceCallWebhookServer(config, manager, provider);
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(endCall).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
const { endCall } = await runStaleCallReaperCase({
|
||||
callAgeMs: 10_000,
|
||||
staleCallReaperSeconds: 60,
|
||||
advanceMs: 30_000,
|
||||
});
|
||||
expect(endCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not run when staleCallReaperSeconds is disabled", async () => {
|
||||
|
||||
@ -1,31 +1,26 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { deleteWebhook, getWebhookInfo, sendChatAction, type ZaloFetch } from "./api.js";
|
||||
|
||||
function createOkFetcher() {
|
||||
return vi.fn<ZaloFetch>(async () => new Response(JSON.stringify({ ok: true, result: {} })));
|
||||
}
|
||||
|
||||
async function expectPostJsonRequest(run: (token: string, fetcher: ZaloFetch) => Promise<unknown>) {
|
||||
const fetcher = createOkFetcher();
|
||||
await run("test-token", fetcher);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
const [, init] = fetcher.mock.calls[0] ?? [];
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(init?.headers).toEqual({ "Content-Type": "application/json" });
|
||||
}
|
||||
|
||||
describe("Zalo API request methods", () => {
|
||||
it("uses POST for getWebhookInfo", async () => {
|
||||
const fetcher = vi.fn<ZaloFetch>(
|
||||
async () => new Response(JSON.stringify({ ok: true, result: {} })),
|
||||
);
|
||||
|
||||
await getWebhookInfo("test-token", fetcher);
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
const [, init] = fetcher.mock.calls[0] ?? [];
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(init?.headers).toEqual({ "Content-Type": "application/json" });
|
||||
await expectPostJsonRequest(getWebhookInfo);
|
||||
});
|
||||
|
||||
it("keeps POST for deleteWebhook", async () => {
|
||||
const fetcher = vi.fn<ZaloFetch>(
|
||||
async () => new Response(JSON.stringify({ ok: true, result: {} })),
|
||||
);
|
||||
|
||||
await deleteWebhook("test-token", fetcher);
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
const [, init] = fetcher.mock.calls[0] ?? [];
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(init?.headers).toEqual({ "Content-Type": "application/json" });
|
||||
await expectPostJsonRequest(deleteWebhook);
|
||||
});
|
||||
|
||||
it("aborts sendChatAction when the typing timeout elapses", async () => {
|
||||
|
||||
@ -43,17 +43,24 @@ function resolveProfile(config: ZalouserAccountConfig, accountId: string): strin
|
||||
return "default";
|
||||
}
|
||||
|
||||
export async function resolveZalouserAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): Promise<ResolvedZalouserAccount> {
|
||||
function resolveZalouserAccountBase(params: { cfg: OpenClawConfig; accountId?: string | null }) {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled =
|
||||
(params.cfg.channels?.zalouser as ZalouserConfig | undefined)?.enabled !== false;
|
||||
const merged = mergeZalouserAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const profile = resolveProfile(merged, accountId);
|
||||
return {
|
||||
accountId,
|
||||
enabled: baseEnabled && merged.enabled !== false,
|
||||
merged,
|
||||
profile: resolveProfile(merged, accountId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveZalouserAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): Promise<ResolvedZalouserAccount> {
|
||||
const { accountId, enabled, merged, profile } = resolveZalouserAccountBase(params);
|
||||
const authenticated = await checkZaloAuthenticated(profile);
|
||||
|
||||
return {
|
||||
@ -70,13 +77,7 @@ export function resolveZalouserAccountSync(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedZalouserAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled =
|
||||
(params.cfg.channels?.zalouser as ZalouserConfig | undefined)?.enabled !== false;
|
||||
const merged = mergeZalouserAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const profile = resolveProfile(merged, accountId);
|
||||
const { accountId, enabled, merged, profile } = resolveZalouserAccountBase(params);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
|
||||
@ -15,6 +15,33 @@ vi.mock("./send.js", async (importOriginal) => {
|
||||
const mockSendMessage = vi.mocked(sendMessageZalouser);
|
||||
const mockSendReaction = vi.mocked(sendReactionZalouser);
|
||||
|
||||
function getResolveToolPolicy() {
|
||||
const resolveToolPolicy = zalouserPlugin.groups?.resolveToolPolicy;
|
||||
expect(resolveToolPolicy).toBeTypeOf("function");
|
||||
if (!resolveToolPolicy) {
|
||||
throw new Error("resolveToolPolicy unavailable");
|
||||
}
|
||||
return resolveToolPolicy;
|
||||
}
|
||||
|
||||
function resolveGroupToolPolicy(
|
||||
groups: Record<string, { tools: { allow?: string[]; deny?: string[] } }>,
|
||||
groupId: string,
|
||||
) {
|
||||
return getResolveToolPolicy()({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
groups,
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
groupId,
|
||||
groupChannel: groupId,
|
||||
});
|
||||
}
|
||||
|
||||
describe("zalouser outbound", () => {
|
||||
beforeEach(() => {
|
||||
mockSendMessage.mockClear();
|
||||
@ -93,48 +120,12 @@ describe("zalouser channel policies", () => {
|
||||
});
|
||||
|
||||
it("resolves group tool policy by explicit group id", () => {
|
||||
const resolveToolPolicy = zalouserPlugin.groups?.resolveToolPolicy;
|
||||
expect(resolveToolPolicy).toBeTypeOf("function");
|
||||
if (!resolveToolPolicy) {
|
||||
return;
|
||||
}
|
||||
const policy = resolveToolPolicy({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
groups: {
|
||||
"123": { tools: { allow: ["search"] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
groupId: "123",
|
||||
groupChannel: "123",
|
||||
});
|
||||
const policy = resolveGroupToolPolicy({ "123": { tools: { allow: ["search"] } } }, "123");
|
||||
expect(policy).toEqual({ allow: ["search"] });
|
||||
});
|
||||
|
||||
it("falls back to wildcard group policy", () => {
|
||||
const resolveToolPolicy = zalouserPlugin.groups?.resolveToolPolicy;
|
||||
expect(resolveToolPolicy).toBeTypeOf("function");
|
||||
if (!resolveToolPolicy) {
|
||||
return;
|
||||
}
|
||||
const policy = resolveToolPolicy({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
groups: {
|
||||
"*": { tools: { deny: ["system.run"] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
groupId: "missing",
|
||||
groupChannel: "missing",
|
||||
});
|
||||
const policy = resolveGroupToolPolicy({ "*": { tools: { deny: ["system.run"] } } }, "missing");
|
||||
expect(policy).toEqual({ deny: ["system.run"] });
|
||||
});
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ export type ParsedTelegramTopicConversation = {
|
||||
canonicalConversationId: string;
|
||||
};
|
||||
|
||||
function normalizeText(value: unknown): string {
|
||||
export function normalizeConversationText(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
@ -15,7 +15,7 @@ function normalizeText(value: unknown): string {
|
||||
}
|
||||
|
||||
export function parseTelegramChatIdFromTarget(raw: unknown): string | undefined {
|
||||
const text = normalizeText(raw);
|
||||
const text = normalizeConversationText(raw);
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@ -117,6 +117,70 @@ function toConfiguredBindingSpec(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConfiguredBindingRecord(params: {
|
||||
cfg: OpenClawConfig;
|
||||
bindings: AgentAcpBinding[];
|
||||
channel: ConfiguredAcpBindingChannel;
|
||||
accountId: string;
|
||||
selectConversation: (
|
||||
binding: AgentAcpBinding,
|
||||
) => { conversationId: string; parentConversationId?: string } | null;
|
||||
}): ResolvedConfiguredAcpBinding | null {
|
||||
let wildcardMatch: {
|
||||
binding: AgentAcpBinding;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
} | null = null;
|
||||
for (const binding of params.bindings) {
|
||||
if (normalizeBindingChannel(binding.match.channel) !== params.channel) {
|
||||
continue;
|
||||
}
|
||||
const accountMatchPriority = resolveAccountMatchPriority(
|
||||
binding.match.accountId,
|
||||
params.accountId,
|
||||
);
|
||||
if (accountMatchPriority === 0) {
|
||||
continue;
|
||||
}
|
||||
const conversation = params.selectConversation(binding);
|
||||
if (!conversation) {
|
||||
continue;
|
||||
}
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
conversationId: conversation.conversationId,
|
||||
parentConversationId: conversation.parentConversationId,
|
||||
binding,
|
||||
});
|
||||
if (accountMatchPriority === 2) {
|
||||
return {
|
||||
spec,
|
||||
record: toConfiguredAcpBindingRecord(spec),
|
||||
};
|
||||
}
|
||||
if (!wildcardMatch) {
|
||||
wildcardMatch = { binding, ...conversation };
|
||||
}
|
||||
}
|
||||
if (!wildcardMatch) {
|
||||
return null;
|
||||
}
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
conversationId: wildcardMatch.conversationId,
|
||||
parentConversationId: wildcardMatch.parentConversationId,
|
||||
binding: wildcardMatch.binding,
|
||||
});
|
||||
return {
|
||||
spec,
|
||||
record: toConfiguredAcpBindingRecord(spec),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveConfiguredAcpBindingSpecBySessionKey(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
@ -207,57 +271,20 @@ export function resolveConfiguredAcpBindingRecord(params: {
|
||||
|
||||
if (channel === "discord") {
|
||||
const bindings = listAcpBindings(params.cfg);
|
||||
const resolveDiscordBindingForConversation = (
|
||||
targetConversationId: string,
|
||||
): ResolvedConfiguredAcpBinding | null => {
|
||||
let wildcardMatch: AgentAcpBinding | null = null;
|
||||
for (const binding of bindings) {
|
||||
if (normalizeBindingChannel(binding.match.channel) !== "discord") {
|
||||
continue;
|
||||
}
|
||||
const accountMatchPriority = resolveAccountMatchPriority(
|
||||
binding.match.accountId,
|
||||
accountId,
|
||||
);
|
||||
if (accountMatchPriority === 0) {
|
||||
continue;
|
||||
}
|
||||
const bindingConversationId = resolveBindingConversationId(binding);
|
||||
if (!bindingConversationId || bindingConversationId !== targetConversationId) {
|
||||
continue;
|
||||
}
|
||||
if (accountMatchPriority === 2) {
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: "discord",
|
||||
accountId,
|
||||
conversationId: targetConversationId,
|
||||
binding,
|
||||
});
|
||||
return {
|
||||
spec,
|
||||
record: toConfiguredAcpBindingRecord(spec),
|
||||
};
|
||||
}
|
||||
if (!wildcardMatch) {
|
||||
wildcardMatch = binding;
|
||||
}
|
||||
}
|
||||
if (wildcardMatch) {
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: "discord",
|
||||
accountId,
|
||||
conversationId: targetConversationId,
|
||||
binding: wildcardMatch,
|
||||
});
|
||||
return {
|
||||
spec,
|
||||
record: toConfiguredAcpBindingRecord(spec),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const resolveDiscordBindingForConversation = (targetConversationId: string) =>
|
||||
resolveConfiguredBindingRecord({
|
||||
cfg: params.cfg,
|
||||
bindings,
|
||||
channel: "discord",
|
||||
accountId,
|
||||
selectConversation: (binding) => {
|
||||
const bindingConversationId = resolveBindingConversationId(binding);
|
||||
if (!bindingConversationId || bindingConversationId !== targetConversationId) {
|
||||
return null;
|
||||
}
|
||||
return { conversationId: targetConversationId };
|
||||
},
|
||||
});
|
||||
|
||||
const directMatch = resolveDiscordBindingForConversation(conversationId);
|
||||
if (directMatch) {
|
||||
@ -280,61 +307,31 @@ export function resolveConfiguredAcpBindingRecord(params: {
|
||||
if (!parsed || !parsed.chatId.startsWith("-")) {
|
||||
return null;
|
||||
}
|
||||
let wildcardMatch: AgentAcpBinding | null = null;
|
||||
for (const binding of listAcpBindings(params.cfg)) {
|
||||
if (normalizeBindingChannel(binding.match.channel) !== "telegram") {
|
||||
continue;
|
||||
}
|
||||
const accountMatchPriority = resolveAccountMatchPriority(binding.match.accountId, accountId);
|
||||
if (accountMatchPriority === 0) {
|
||||
continue;
|
||||
}
|
||||
const targetConversationId = resolveBindingConversationId(binding);
|
||||
if (!targetConversationId) {
|
||||
continue;
|
||||
}
|
||||
const targetParsed = parseTelegramTopicConversation({
|
||||
conversationId: targetConversationId,
|
||||
});
|
||||
if (!targetParsed || !targetParsed.chatId.startsWith("-")) {
|
||||
continue;
|
||||
}
|
||||
if (targetParsed.canonicalConversationId !== parsed.canonicalConversationId) {
|
||||
continue;
|
||||
}
|
||||
if (accountMatchPriority === 2) {
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
return resolveConfiguredBindingRecord({
|
||||
cfg: params.cfg,
|
||||
bindings: listAcpBindings(params.cfg),
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
selectConversation: (binding) => {
|
||||
const targetConversationId = resolveBindingConversationId(binding);
|
||||
if (!targetConversationId) {
|
||||
return null;
|
||||
}
|
||||
const targetParsed = parseTelegramTopicConversation({
|
||||
conversationId: targetConversationId,
|
||||
});
|
||||
if (!targetParsed || !targetParsed.chatId.startsWith("-")) {
|
||||
return null;
|
||||
}
|
||||
if (targetParsed.canonicalConversationId !== parsed.canonicalConversationId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
conversationId: parsed.canonicalConversationId,
|
||||
parentConversationId: parsed.chatId,
|
||||
binding,
|
||||
});
|
||||
return {
|
||||
spec,
|
||||
record: toConfiguredAcpBindingRecord(spec),
|
||||
};
|
||||
}
|
||||
if (!wildcardMatch) {
|
||||
wildcardMatch = binding;
|
||||
}
|
||||
}
|
||||
if (wildcardMatch) {
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
conversationId: parsed.canonicalConversationId,
|
||||
parentConversationId: parsed.chatId,
|
||||
binding: wildcardMatch,
|
||||
});
|
||||
return {
|
||||
spec,
|
||||
record: toConfiguredAcpBindingRecord(spec),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@ -30,6 +30,10 @@ import {
|
||||
resolveConfiguredAcpBindingSpecBySessionKey,
|
||||
} from "./persistent-bindings.js";
|
||||
|
||||
type ConfiguredBinding = NonNullable<OpenClawConfig["bindings"]>[number];
|
||||
type BindingRecordInput = Parameters<typeof resolveConfiguredAcpBindingRecord>[0];
|
||||
type BindingSpec = Parameters<typeof ensureConfiguredAcpBindingSession>[0]["spec"];
|
||||
|
||||
const baseCfg = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
agents: {
|
||||
@ -37,6 +41,105 @@ const baseCfg = {
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const defaultDiscordConversationId = "1478836151241412759";
|
||||
const defaultDiscordAccountId = "default";
|
||||
|
||||
function createCfgWithBindings(
|
||||
bindings: ConfiguredBinding[],
|
||||
overrides?: Partial<OpenClawConfig>,
|
||||
): OpenClawConfig {
|
||||
return {
|
||||
...baseCfg,
|
||||
...overrides,
|
||||
bindings,
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function createDiscordBinding(params: {
|
||||
agentId: string;
|
||||
conversationId: string;
|
||||
accountId?: string;
|
||||
acp?: Record<string, unknown>;
|
||||
}): ConfiguredBinding {
|
||||
return {
|
||||
type: "acp",
|
||||
agentId: params.agentId,
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: params.accountId ?? defaultDiscordAccountId,
|
||||
peer: { kind: "channel", id: params.conversationId },
|
||||
},
|
||||
...(params.acp ? { acp: params.acp } : {}),
|
||||
} as ConfiguredBinding;
|
||||
}
|
||||
|
||||
function createTelegramGroupBinding(params: {
|
||||
agentId: string;
|
||||
conversationId: string;
|
||||
acp?: Record<string, unknown>;
|
||||
}): ConfiguredBinding {
|
||||
return {
|
||||
type: "acp",
|
||||
agentId: params.agentId,
|
||||
match: {
|
||||
channel: "telegram",
|
||||
accountId: defaultDiscordAccountId,
|
||||
peer: { kind: "group", id: params.conversationId },
|
||||
},
|
||||
...(params.acp ? { acp: params.acp } : {}),
|
||||
} as ConfiguredBinding;
|
||||
}
|
||||
|
||||
function resolveBindingRecord(cfg: OpenClawConfig, overrides: Partial<BindingRecordInput> = {}) {
|
||||
return resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: defaultDiscordAccountId,
|
||||
conversationId: defaultDiscordConversationId,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveDiscordBindingSpecBySession(
|
||||
cfg: OpenClawConfig,
|
||||
conversationId = defaultDiscordConversationId,
|
||||
) {
|
||||
const resolved = resolveBindingRecord(cfg, { conversationId });
|
||||
return resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
cfg,
|
||||
sessionKey: resolved?.record.targetSessionKey ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
function createDiscordPersistentSpec(overrides: Partial<BindingSpec> = {}): BindingSpec {
|
||||
return {
|
||||
channel: "discord",
|
||||
accountId: defaultDiscordAccountId,
|
||||
conversationId: defaultDiscordConversationId,
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
...overrides,
|
||||
} as BindingSpec;
|
||||
}
|
||||
|
||||
function mockReadySession(params: { spec: BindingSpec; cwd: string }) {
|
||||
const sessionKey = buildConfiguredAcpSessionKey(params.spec);
|
||||
managerMocks.resolveSession.mockReturnValue({
|
||||
kind: "ready",
|
||||
sessionKey,
|
||||
meta: {
|
||||
backend: "acpx",
|
||||
agent: params.spec.acpAgentId ?? params.spec.agentId,
|
||||
runtimeSessionName: "existing",
|
||||
mode: params.spec.mode,
|
||||
runtimeOptions: { cwd: params.cwd },
|
||||
state: "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
});
|
||||
return sessionKey;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
managerMocks.resolveSession.mockReset();
|
||||
managerMocks.closeSession.mockReset().mockResolvedValue({
|
||||
@ -50,58 +153,30 @@ beforeEach(() => {
|
||||
|
||||
describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
it("resolves discord channel ACP binding from top-level typed bindings", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "1478836151241412759" },
|
||||
},
|
||||
acp: {
|
||||
cwd: "/repo/openclaw",
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1478836151241412759",
|
||||
});
|
||||
const cfg = createCfgWithBindings([
|
||||
createDiscordBinding({
|
||||
agentId: "codex",
|
||||
conversationId: defaultDiscordConversationId,
|
||||
acp: { cwd: "/repo/openclaw" },
|
||||
}),
|
||||
]);
|
||||
const resolved = resolveBindingRecord(cfg);
|
||||
|
||||
expect(resolved?.spec.channel).toBe("discord");
|
||||
expect(resolved?.spec.conversationId).toBe("1478836151241412759");
|
||||
expect(resolved?.spec.conversationId).toBe(defaultDiscordConversationId);
|
||||
expect(resolved?.spec.agentId).toBe("codex");
|
||||
expect(resolved?.record.targetSessionKey).toContain("agent:codex:acp:binding:discord:default:");
|
||||
expect(resolved?.record.metadata?.source).toBe("config");
|
||||
});
|
||||
|
||||
it("falls back to parent discord channel when conversation is a thread id", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "channel-parent-1" },
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
const cfg = createCfgWithBindings([
|
||||
createDiscordBinding({
|
||||
agentId: "codex",
|
||||
conversationId: "channel-parent-1",
|
||||
}),
|
||||
]);
|
||||
const resolved = resolveBindingRecord(cfg, {
|
||||
conversationId: "thread-123",
|
||||
parentConversationId: "channel-parent-1",
|
||||
});
|
||||
@ -111,34 +186,17 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
});
|
||||
|
||||
it("prefers direct discord thread binding over parent channel fallback", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "channel-parent-1" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "claude",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "thread-123" },
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
const cfg = createCfgWithBindings([
|
||||
createDiscordBinding({
|
||||
agentId: "codex",
|
||||
conversationId: "channel-parent-1",
|
||||
}),
|
||||
createDiscordBinding({
|
||||
agentId: "claude",
|
||||
conversationId: "thread-123",
|
||||
}),
|
||||
]);
|
||||
const resolved = resolveBindingRecord(cfg, {
|
||||
conversationId: "thread-123",
|
||||
parentConversationId: "channel-parent-1",
|
||||
});
|
||||
@ -148,60 +206,30 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
});
|
||||
|
||||
it("prefers exact account binding over wildcard for the same discord conversation", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "*",
|
||||
peer: { kind: "channel", id: "1478836151241412759" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "claude",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "1478836151241412759" },
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1478836151241412759",
|
||||
});
|
||||
const cfg = createCfgWithBindings([
|
||||
createDiscordBinding({
|
||||
agentId: "codex",
|
||||
conversationId: defaultDiscordConversationId,
|
||||
accountId: "*",
|
||||
}),
|
||||
createDiscordBinding({
|
||||
agentId: "claude",
|
||||
conversationId: defaultDiscordConversationId,
|
||||
}),
|
||||
]);
|
||||
const resolved = resolveBindingRecord(cfg);
|
||||
|
||||
expect(resolved?.spec.agentId).toBe("claude");
|
||||
});
|
||||
|
||||
it("returns null when no top-level ACP binding matches the conversation", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "different-channel" },
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
const cfg = createCfgWithBindings([
|
||||
createDiscordBinding({
|
||||
agentId: "codex",
|
||||
conversationId: "different-channel",
|
||||
}),
|
||||
]);
|
||||
const resolved = resolveBindingRecord(cfg, {
|
||||
conversationId: "thread-123",
|
||||
parentConversationId: "channel-parent-1",
|
||||
});
|
||||
@ -210,23 +238,13 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
});
|
||||
|
||||
it("resolves telegram forum topic bindings using canonical conversation ids", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "claude",
|
||||
match: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
peer: { kind: "group", id: "-1001234567890:topic:42" },
|
||||
},
|
||||
acp: {
|
||||
backend: "acpx",
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
const cfg = createCfgWithBindings([
|
||||
createTelegramGroupBinding({
|
||||
agentId: "claude",
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
acp: { backend: "acpx" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const canonical = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
@ -250,20 +268,12 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
});
|
||||
|
||||
it("skips telegram non-group topic configs", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "claude",
|
||||
match: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
peer: { kind: "group", id: "123456789:topic:42" },
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
const cfg = createCfgWithBindings([
|
||||
createTelegramGroupBinding({
|
||||
agentId: "claude",
|
||||
conversationId: "123456789:topic:42",
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
@ -275,44 +285,34 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
});
|
||||
|
||||
it("applies agent runtime ACP defaults for bound conversations", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main" },
|
||||
{
|
||||
id: "coding",
|
||||
runtime: {
|
||||
type: "acp",
|
||||
acp: {
|
||||
agent: "codex",
|
||||
backend: "acpx",
|
||||
mode: "oneshot",
|
||||
cwd: "/workspace/repo-a",
|
||||
const cfg = createCfgWithBindings(
|
||||
[
|
||||
createDiscordBinding({
|
||||
agentId: "coding",
|
||||
conversationId: defaultDiscordConversationId,
|
||||
}),
|
||||
],
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main" },
|
||||
{
|
||||
id: "coding",
|
||||
runtime: {
|
||||
type: "acp",
|
||||
acp: {
|
||||
agent: "codex",
|
||||
backend: "acpx",
|
||||
mode: "oneshot",
|
||||
cwd: "/workspace/repo-a",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "coding",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "1478836151241412759" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1478836151241412759",
|
||||
});
|
||||
},
|
||||
);
|
||||
const resolved = resolveBindingRecord(cfg);
|
||||
|
||||
expect(resolved?.spec.agentId).toBe("coding");
|
||||
expect(resolved?.spec.acpAgentId).toBe("codex");
|
||||
@ -324,37 +324,17 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
|
||||
describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
|
||||
it("maps a configured discord binding session key back to its spec", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "1478836151241412759" },
|
||||
},
|
||||
acp: {
|
||||
backend: "acpx",
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1478836151241412759",
|
||||
});
|
||||
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
cfg,
|
||||
sessionKey: resolved?.record.targetSessionKey ?? "",
|
||||
});
|
||||
const cfg = createCfgWithBindings([
|
||||
createDiscordBinding({
|
||||
agentId: "codex",
|
||||
conversationId: defaultDiscordConversationId,
|
||||
acp: { backend: "acpx" },
|
||||
}),
|
||||
]);
|
||||
const spec = resolveDiscordBindingSpecBySession(cfg);
|
||||
|
||||
expect(spec?.channel).toBe("discord");
|
||||
expect(spec?.conversationId).toBe("1478836151241412759");
|
||||
expect(spec?.conversationId).toBe(defaultDiscordConversationId);
|
||||
expect(spec?.agentId).toBe("codex");
|
||||
expect(spec?.backend).toBe("acpx");
|
||||
});
|
||||
@ -368,46 +348,20 @@ describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
|
||||
});
|
||||
|
||||
it("prefers exact account ACP settings over wildcard when session keys collide", () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "*",
|
||||
peer: { kind: "channel", id: "1478836151241412759" },
|
||||
},
|
||||
acp: {
|
||||
backend: "wild",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "1478836151241412759" },
|
||||
},
|
||||
acp: {
|
||||
backend: "exact",
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1478836151241412759",
|
||||
});
|
||||
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
cfg,
|
||||
sessionKey: resolved?.record.targetSessionKey ?? "",
|
||||
});
|
||||
const cfg = createCfgWithBindings([
|
||||
createDiscordBinding({
|
||||
agentId: "codex",
|
||||
conversationId: defaultDiscordConversationId,
|
||||
accountId: "*",
|
||||
acp: { backend: "wild" },
|
||||
}),
|
||||
createDiscordBinding({
|
||||
agentId: "codex",
|
||||
conversationId: defaultDiscordConversationId,
|
||||
acp: { backend: "exact" },
|
||||
}),
|
||||
]);
|
||||
const spec = resolveDiscordBindingSpecBySession(cfg);
|
||||
|
||||
expect(spec?.backend).toBe("exact");
|
||||
});
|
||||
@ -435,26 +389,10 @@ describe("buildConfiguredAcpSessionKey", () => {
|
||||
|
||||
describe("ensureConfiguredAcpBindingSession", () => {
|
||||
it("keeps an existing ready session when configured binding omits cwd", async () => {
|
||||
const spec = {
|
||||
channel: "discord" as const,
|
||||
accountId: "default",
|
||||
conversationId: "1478836151241412759",
|
||||
agentId: "codex",
|
||||
mode: "persistent" as const,
|
||||
};
|
||||
const sessionKey = buildConfiguredAcpSessionKey(spec);
|
||||
managerMocks.resolveSession.mockReturnValue({
|
||||
kind: "ready",
|
||||
sessionKey,
|
||||
meta: {
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "existing",
|
||||
mode: "persistent",
|
||||
runtimeOptions: { cwd: "/workspace/openclaw" },
|
||||
state: "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
const spec = createDiscordPersistentSpec();
|
||||
const sessionKey = mockReadySession({
|
||||
spec,
|
||||
cwd: "/workspace/openclaw",
|
||||
});
|
||||
|
||||
const ensured = await ensureConfiguredAcpBindingSession({
|
||||
@ -468,27 +406,12 @@ describe("ensureConfiguredAcpBindingSession", () => {
|
||||
});
|
||||
|
||||
it("reinitializes a ready session when binding config explicitly sets mismatched cwd", async () => {
|
||||
const spec = {
|
||||
channel: "discord" as const,
|
||||
accountId: "default",
|
||||
conversationId: "1478836151241412759",
|
||||
agentId: "codex",
|
||||
mode: "persistent" as const,
|
||||
const spec = createDiscordPersistentSpec({
|
||||
cwd: "/workspace/repo-a",
|
||||
};
|
||||
const sessionKey = buildConfiguredAcpSessionKey(spec);
|
||||
managerMocks.resolveSession.mockReturnValue({
|
||||
kind: "ready",
|
||||
sessionKey,
|
||||
meta: {
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "existing",
|
||||
mode: "persistent",
|
||||
runtimeOptions: { cwd: "/workspace/other-repo" },
|
||||
state: "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
});
|
||||
const sessionKey = mockReadySession({
|
||||
spec,
|
||||
cwd: "/workspace/other-repo",
|
||||
});
|
||||
|
||||
const ensured = await ensureConfiguredAcpBindingSession({
|
||||
@ -508,14 +431,10 @@ describe("ensureConfiguredAcpBindingSession", () => {
|
||||
});
|
||||
|
||||
it("initializes ACP session with runtime agent override when provided", async () => {
|
||||
const spec = {
|
||||
channel: "discord" as const,
|
||||
accountId: "default",
|
||||
conversationId: "1478836151241412759",
|
||||
const spec = createDiscordPersistentSpec({
|
||||
agentId: "coding",
|
||||
acpAgentId: "codex",
|
||||
mode: "persistent" as const,
|
||||
};
|
||||
});
|
||||
managerMocks.resolveSession.mockReturnValue({ kind: "none" });
|
||||
|
||||
const ensured = await ensureConfiguredAcpBindingSession({
|
||||
@ -534,24 +453,16 @@ describe("ensureConfiguredAcpBindingSession", () => {
|
||||
|
||||
describe("resetAcpSessionInPlace", () => {
|
||||
it("reinitializes from configured binding when ACP metadata is missing", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: "claude",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "1478844424791396446" },
|
||||
},
|
||||
acp: {
|
||||
mode: "persistent",
|
||||
backend: "acpx",
|
||||
},
|
||||
const cfg = createCfgWithBindings([
|
||||
createDiscordBinding({
|
||||
agentId: "claude",
|
||||
conversationId: "1478844424791396446",
|
||||
acp: {
|
||||
mode: "persistent",
|
||||
backend: "acpx",
|
||||
},
|
||||
],
|
||||
} satisfies OpenClawConfig;
|
||||
}),
|
||||
]);
|
||||
const sessionKey = buildConfiguredAcpSessionKey({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
|
||||
@ -129,6 +129,22 @@ describe("serveAcpGateway startup", () => {
|
||||
return { signalHandlers, onceSpy };
|
||||
}
|
||||
|
||||
async function emitHelloAndWaitForAgentSideConnection() {
|
||||
const gateway = getMockGateway();
|
||||
gateway.emitHello();
|
||||
await vi.waitFor(() => {
|
||||
expect(mockState.agentSideConnectionCtor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
}
|
||||
|
||||
async function stopServeWithSigint(
|
||||
signalHandlers: Map<NodeJS.Signals, () => void>,
|
||||
servePromise: Promise<void>,
|
||||
) {
|
||||
signalHandlers.get("SIGINT")?.();
|
||||
await servePromise;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ serveAcpGateway } = await import("./server.js"));
|
||||
});
|
||||
@ -153,14 +169,8 @@ describe("serveAcpGateway startup", () => {
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mockState.agentSideConnectionCtor).not.toHaveBeenCalled();
|
||||
const gateway = getMockGateway();
|
||||
gateway.emitHello();
|
||||
await vi.waitFor(() => {
|
||||
expect(mockState.agentSideConnectionCtor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
signalHandlers.get("SIGINT")?.();
|
||||
await servePromise;
|
||||
await emitHelloAndWaitForAgentSideConnection();
|
||||
await stopServeWithSigint(signalHandlers, servePromise);
|
||||
} finally {
|
||||
onceSpy.mockRestore();
|
||||
}
|
||||
@ -207,13 +217,8 @@ describe("serveAcpGateway startup", () => {
|
||||
password: "resolved-secret-password", // pragma: allowlist secret
|
||||
});
|
||||
|
||||
const gateway = getMockGateway();
|
||||
gateway.emitHello();
|
||||
await vi.waitFor(() => {
|
||||
expect(mockState.agentSideConnectionCtor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
signalHandlers.get("SIGINT")?.();
|
||||
await servePromise;
|
||||
await emitHelloAndWaitForAgentSideConnection();
|
||||
await stopServeWithSigint(signalHandlers, servePromise);
|
||||
} finally {
|
||||
onceSpy.mockRestore();
|
||||
}
|
||||
@ -236,13 +241,8 @@ describe("serveAcpGateway startup", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const gateway = getMockGateway();
|
||||
gateway.emitHello();
|
||||
await vi.waitFor(() => {
|
||||
expect(mockState.agentSideConnectionCtor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
signalHandlers.get("SIGINT")?.();
|
||||
await servePromise;
|
||||
await emitHelloAndWaitForAgentSideConnection();
|
||||
await stopServeWithSigint(signalHandlers, servePromise);
|
||||
} finally {
|
||||
onceSpy.mockRestore();
|
||||
}
|
||||
|
||||
@ -91,19 +91,45 @@ async function startPendingPrompt(
|
||||
};
|
||||
}
|
||||
|
||||
async function cancelAndExpectAbortForPendingRun(
|
||||
harness: Harness,
|
||||
sessionId: string,
|
||||
sessionKey: string,
|
||||
pending: { promptPromise: Promise<PromptResponse>; runId: string },
|
||||
) {
|
||||
await harness.agent.cancel({ sessionId } as CancelNotification);
|
||||
|
||||
expect(harness.requestSpy).toHaveBeenCalledWith("chat.abort", {
|
||||
sessionKey,
|
||||
runId: pending.runId,
|
||||
});
|
||||
await expect(pending.promptPromise).resolves.toEqual({ stopReason: "cancelled" });
|
||||
}
|
||||
|
||||
async function deliverFinalChatEventAndExpectEndTurn(
|
||||
harness: Harness,
|
||||
sessionKey: string,
|
||||
pending: { promptPromise: Promise<PromptResponse>; runId: string },
|
||||
seq: number,
|
||||
) {
|
||||
await harness.agent.handleGatewayEvent(
|
||||
createChatEvent({
|
||||
runId: pending.runId,
|
||||
sessionKey,
|
||||
seq,
|
||||
state: "final",
|
||||
}),
|
||||
);
|
||||
await expect(pending.promptPromise).resolves.toEqual({ stopReason: "end_turn" });
|
||||
}
|
||||
|
||||
describe("acp translator cancel and run scoping", () => {
|
||||
it("cancel passes active runId to chat.abort", async () => {
|
||||
const sessionKey = "agent:main:shared";
|
||||
const harness = createHarness([{ sessionId: "session-1", sessionKey }]);
|
||||
const pending = await startPendingPrompt(harness, "session-1");
|
||||
|
||||
await harness.agent.cancel({ sessionId: "session-1" } as CancelNotification);
|
||||
|
||||
expect(harness.requestSpy).toHaveBeenCalledWith("chat.abort", {
|
||||
sessionKey,
|
||||
runId: pending.runId,
|
||||
});
|
||||
await expect(pending.promptPromise).resolves.toEqual({ stopReason: "cancelled" });
|
||||
await cancelAndExpectAbortForPendingRun(harness, "session-1", sessionKey, pending);
|
||||
});
|
||||
|
||||
it("cancel uses pending runId when there is no active run", async () => {
|
||||
@ -112,13 +138,7 @@ describe("acp translator cancel and run scoping", () => {
|
||||
const pending = await startPendingPrompt(harness, "session-1");
|
||||
harness.sessionStore.clearActiveRun("session-1");
|
||||
|
||||
await harness.agent.cancel({ sessionId: "session-1" } as CancelNotification);
|
||||
|
||||
expect(harness.requestSpy).toHaveBeenCalledWith("chat.abort", {
|
||||
sessionKey,
|
||||
runId: pending.runId,
|
||||
});
|
||||
await expect(pending.promptPromise).resolves.toEqual({ stopReason: "cancelled" });
|
||||
await cancelAndExpectAbortForPendingRun(harness, "session-1", sessionKey, pending);
|
||||
});
|
||||
|
||||
it("cancel skips chat.abort when there is no active run and no pending prompt", async () => {
|
||||
@ -145,15 +165,7 @@ describe("acp translator cancel and run scoping", () => {
|
||||
expect(abortCalls).toHaveLength(0);
|
||||
expect(harness.sessionStore.getSession("session-2")?.activeRunId).toBe(pending2.runId);
|
||||
|
||||
await harness.agent.handleGatewayEvent(
|
||||
createChatEvent({
|
||||
runId: pending2.runId,
|
||||
sessionKey,
|
||||
seq: 1,
|
||||
state: "final",
|
||||
}),
|
||||
);
|
||||
await expect(pending2.promptPromise).resolves.toEqual({ stopReason: "end_turn" });
|
||||
await deliverFinalChatEventAndExpectEndTurn(harness, sessionKey, pending2, 1);
|
||||
});
|
||||
|
||||
it("drops chat events when runId does not match the active prompt", async () => {
|
||||
@ -250,15 +262,7 @@ describe("acp translator cancel and run scoping", () => {
|
||||
);
|
||||
expect(harness.sessionUpdateSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
await harness.agent.handleGatewayEvent(
|
||||
createChatEvent({
|
||||
runId: pending2.runId,
|
||||
sessionKey,
|
||||
seq: 1,
|
||||
state: "final",
|
||||
}),
|
||||
);
|
||||
await expect(pending2.promptPromise).resolves.toEqual({ stopReason: "end_turn" });
|
||||
await deliverFinalChatEventAndExpectEndTurn(harness, sessionKey, pending2, 1);
|
||||
expect(harness.sessionStore.getSession("session-1")?.activeRunId).toBe(pending1.runId);
|
||||
|
||||
await harness.agent.handleGatewayEvent(
|
||||
|
||||
@ -87,15 +87,16 @@ export function createOpenClawTools(
|
||||
options?.spawnWorkspaceDir ?? options?.workspaceDir,
|
||||
);
|
||||
const runtimeWebTools = getActiveRuntimeWebToolsMetadata();
|
||||
const sandbox =
|
||||
options?.sandboxRoot && options?.sandboxFsBridge
|
||||
? { root: options.sandboxRoot, bridge: options.sandboxFsBridge }
|
||||
: undefined;
|
||||
const imageTool = options?.agentDir?.trim()
|
||||
? createImageTool({
|
||||
config: options?.config,
|
||||
agentDir: options.agentDir,
|
||||
workspaceDir,
|
||||
sandbox:
|
||||
options?.sandboxRoot && options?.sandboxFsBridge
|
||||
? { root: options.sandboxRoot, bridge: options.sandboxFsBridge }
|
||||
: undefined,
|
||||
sandbox,
|
||||
fsPolicy: options?.fsPolicy,
|
||||
modelHasVision: options?.modelHasVision,
|
||||
})
|
||||
@ -105,10 +106,7 @@ export function createOpenClawTools(
|
||||
config: options?.config,
|
||||
agentDir: options.agentDir,
|
||||
workspaceDir,
|
||||
sandbox:
|
||||
options?.sandboxRoot && options?.sandboxFsBridge
|
||||
? { root: options.sandboxRoot, bridge: options.sandboxFsBridge }
|
||||
: undefined,
|
||||
sandbox,
|
||||
fsPolicy: options?.fsPolicy,
|
||||
})
|
||||
: null;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { detectMime } from "../../media/mime.js";
|
||||
import { readSnakeCaseParamRaw } from "../../param-key.js";
|
||||
import type { ImageSanitizationLimits } from "../image-sanitization.js";
|
||||
import { sanitizeToolResultImages } from "../tool-images.js";
|
||||
|
||||
@ -53,22 +54,8 @@ export function createActionGate<T extends Record<string, boolean | undefined>>(
|
||||
};
|
||||
}
|
||||
|
||||
function toSnakeCaseKey(key: string): string {
|
||||
return key
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function readParamRaw(params: Record<string, unknown>, key: string): unknown {
|
||||
if (Object.hasOwn(params, key)) {
|
||||
return params[key];
|
||||
}
|
||||
const snakeKey = toSnakeCaseKey(key);
|
||||
if (snakeKey !== key && Object.hasOwn(params, snakeKey)) {
|
||||
return params[snakeKey];
|
||||
}
|
||||
return undefined;
|
||||
return readSnakeCaseParamRaw(params, key);
|
||||
}
|
||||
|
||||
export function readStringParam(
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import {
|
||||
buildTelegramTopicConversationId,
|
||||
normalizeConversationText,
|
||||
parseTelegramChatIdFromTarget,
|
||||
} from "../../../acp/conversation-id.js";
|
||||
import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js";
|
||||
@ -8,33 +9,25 @@ import { parseAgentSessionKey } from "../../../routing/session-key.js";
|
||||
import type { HandleCommandsParams } from "../commands-types.js";
|
||||
import { resolveTelegramConversationId } from "../telegram-context.js";
|
||||
|
||||
function normalizeString(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
|
||||
return `${value}`.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function resolveAcpCommandChannel(params: HandleCommandsParams): string {
|
||||
const raw =
|
||||
params.ctx.OriginatingChannel ??
|
||||
params.command.channel ??
|
||||
params.ctx.Surface ??
|
||||
params.ctx.Provider;
|
||||
return normalizeString(raw).toLowerCase();
|
||||
return normalizeConversationText(raw).toLowerCase();
|
||||
}
|
||||
|
||||
export function resolveAcpCommandAccountId(params: HandleCommandsParams): string {
|
||||
const accountId = normalizeString(params.ctx.AccountId);
|
||||
const accountId = normalizeConversationText(params.ctx.AccountId);
|
||||
return accountId || "default";
|
||||
}
|
||||
|
||||
export function resolveAcpCommandThreadId(params: HandleCommandsParams): string | undefined {
|
||||
const threadId =
|
||||
params.ctx.MessageThreadId != null ? normalizeString(String(params.ctx.MessageThreadId)) : "";
|
||||
params.ctx.MessageThreadId != null
|
||||
? normalizeConversationText(String(params.ctx.MessageThreadId))
|
||||
: "";
|
||||
return threadId || undefined;
|
||||
}
|
||||
|
||||
@ -72,7 +65,7 @@ export function resolveAcpCommandConversationId(params: HandleCommandsParams): s
|
||||
}
|
||||
|
||||
function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefined {
|
||||
const sessionKey = normalizeString(raw);
|
||||
const sessionKey = normalizeConversationText(raw);
|
||||
if (!sessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
@ -85,7 +78,7 @@ function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefin
|
||||
}
|
||||
|
||||
function parseDiscordParentChannelFromContext(raw: unknown): string | undefined {
|
||||
const parentId = normalizeString(raw);
|
||||
const parentId = normalizeConversationText(raw);
|
||||
if (!parentId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildTelegramTopicConversationId,
|
||||
normalizeConversationText,
|
||||
parseTelegramChatIdFromTarget,
|
||||
} from "../../acp/conversation-id.js";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
@ -69,18 +70,8 @@ export type SessionInitResult = {
|
||||
triggerBodyNormalized: string;
|
||||
};
|
||||
|
||||
function normalizeSessionText(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
|
||||
return `${value}`.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefined {
|
||||
const sessionKey = normalizeSessionText(raw);
|
||||
const sessionKey = normalizeConversationText(raw);
|
||||
if (!sessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
@ -98,15 +89,15 @@ function resolveAcpResetBindingContext(ctx: MsgContext): {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
} | null {
|
||||
const channelRaw = normalizeSessionText(
|
||||
const channelRaw = normalizeConversationText(
|
||||
ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider ?? "",
|
||||
).toLowerCase();
|
||||
if (!channelRaw) {
|
||||
return null;
|
||||
}
|
||||
const accountId = normalizeSessionText(ctx.AccountId) || "default";
|
||||
const accountId = normalizeConversationText(ctx.AccountId) || "default";
|
||||
const normalizedThreadId =
|
||||
ctx.MessageThreadId != null ? normalizeSessionText(String(ctx.MessageThreadId)) : "";
|
||||
ctx.MessageThreadId != null ? normalizeConversationText(String(ctx.MessageThreadId)) : "";
|
||||
|
||||
if (channelRaw === "telegram") {
|
||||
const parentConversationId =
|
||||
@ -143,7 +134,7 @@ function resolveAcpResetBindingContext(ctx: MsgContext): {
|
||||
}
|
||||
let parentConversationId: string | undefined;
|
||||
if (channelRaw === "discord" && normalizedThreadId) {
|
||||
const fromContext = normalizeSessionText(ctx.ThreadParentId);
|
||||
const fromContext = normalizeConversationText(ctx.ThreadParentId);
|
||||
if (fromContext && fromContext !== conversationId) {
|
||||
parentConversationId = fromContext;
|
||||
} else {
|
||||
@ -172,7 +163,7 @@ function resolveBoundAcpSessionForReset(params: {
|
||||
cfg: OpenClawConfig;
|
||||
ctx: MsgContext;
|
||||
}): string | undefined {
|
||||
const activeSessionKey = normalizeSessionText(params.ctx.SessionKey);
|
||||
const activeSessionKey = normalizeConversationText(params.ctx.SessionKey);
|
||||
const bindingContext = resolveAcpResetBindingContext(params.ctx);
|
||||
return resolveEffectiveResetTargetSessionKey({
|
||||
cfg: params.cfg,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { expandHomePrefix, resolveRequiredHomeDir } from "../infra/home-dir.js";
|
||||
import { resolveHomeRelativePath, resolveRequiredHomeDir } from "../infra/home-dir.js";
|
||||
import type { OpenClawConfig } from "./types.js";
|
||||
|
||||
/**
|
||||
@ -93,19 +93,7 @@ function resolveUserPath(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homedir: () => string = envHomedir(env),
|
||||
): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.startsWith("~")) {
|
||||
const expanded = expandHomePrefix(trimmed, {
|
||||
home: resolveRequiredHomeDir(env, homedir),
|
||||
env,
|
||||
homedir,
|
||||
});
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
return resolveHomeRelativePath(input, { env, homedir });
|
||||
}
|
||||
|
||||
export const STATE_DIR = resolveStateDir();
|
||||
|
||||
@ -283,18 +283,25 @@ describe("session store lock (Promise chain mutex)", () => {
|
||||
|
||||
describe("appendAssistantMessageToSessionTranscript", () => {
|
||||
const fixture = useTempSessionsFixture("transcript-test-");
|
||||
const sessionId = "test-session-id";
|
||||
const sessionKey = "test-session";
|
||||
|
||||
function writeTranscriptStore() {
|
||||
fs.writeFileSync(
|
||||
fixture.storePath(),
|
||||
JSON.stringify({
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
chatType: "direct",
|
||||
channel: "discord",
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
it("creates transcript file and appends message for valid session", async () => {
|
||||
const sessionId = "test-session-id";
|
||||
const sessionKey = "test-session";
|
||||
const store = {
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
chatType: "direct",
|
||||
channel: "discord",
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8");
|
||||
writeTranscriptStore();
|
||||
|
||||
const result = await appendAssistantMessageToSessionTranscript({
|
||||
sessionKey,
|
||||
@ -326,16 +333,7 @@ describe("appendAssistantMessageToSessionTranscript", () => {
|
||||
});
|
||||
|
||||
it("does not append a duplicate delivery mirror for the same idempotency key", async () => {
|
||||
const sessionId = "test-session-id";
|
||||
const sessionKey = "test-session";
|
||||
const store = {
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
chatType: "direct",
|
||||
channel: "discord",
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8");
|
||||
writeTranscriptStore();
|
||||
|
||||
await appendAssistantMessageToSessionTranscript({
|
||||
sessionKey,
|
||||
@ -360,16 +358,7 @@ describe("appendAssistantMessageToSessionTranscript", () => {
|
||||
});
|
||||
|
||||
it("ignores malformed transcript lines when checking mirror idempotency", async () => {
|
||||
const sessionId = "test-session-id";
|
||||
const sessionKey = "test-session";
|
||||
const store = {
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
chatType: "direct",
|
||||
channel: "discord",
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8");
|
||||
writeTranscriptStore();
|
||||
|
||||
const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir());
|
||||
fs.writeFileSync(
|
||||
|
||||
@ -24,6 +24,8 @@ function lastEmbeddedCall(): { provider?: string; model?: string } {
|
||||
}
|
||||
|
||||
const DEFAULT_MESSAGE = "do it";
|
||||
const DEFAULT_PROVIDER = "anthropic";
|
||||
const DEFAULT_MODEL = "claude-opus-4-5";
|
||||
|
||||
type TurnOptions = {
|
||||
cfgOverrides?: Parameters<typeof makeCfg>[2];
|
||||
@ -73,6 +75,50 @@ async function runTurn(home: string, options: TurnOptions = {}) {
|
||||
return { res, call: lastEmbeddedCall() };
|
||||
}
|
||||
|
||||
function expectSelectedModel(
|
||||
call: { provider?: string; model?: string },
|
||||
params: { provider: string; model: string },
|
||||
) {
|
||||
expect(call.provider).toBe(params.provider);
|
||||
expect(call.model).toBe(params.model);
|
||||
}
|
||||
|
||||
function expectDefaultSelectedModel(call: { provider?: string; model?: string }) {
|
||||
expectSelectedModel(call, { provider: DEFAULT_PROVIDER, model: DEFAULT_MODEL });
|
||||
}
|
||||
|
||||
function createCronSessionOverrideStore(
|
||||
overrides: Record<string, unknown>,
|
||||
sessionId = "existing-session",
|
||||
) {
|
||||
return {
|
||||
"agent:main:cron:job-1": {
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function expectTurnModel(
|
||||
home: string,
|
||||
options: TurnOptions,
|
||||
expected: { provider: string; model: string },
|
||||
) {
|
||||
const { res, call } = await runTurn(home, options);
|
||||
expect(res.status).toBe("ok");
|
||||
expectSelectedModel(call, expected);
|
||||
}
|
||||
|
||||
async function expectInvalidModel(home: string, model: string) {
|
||||
const { res } = await runErrorTurn(home, {
|
||||
jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model },
|
||||
});
|
||||
expect(res.status).toBe("error");
|
||||
expect(res.error).toMatch(/invalid model/i);
|
||||
expect(vi.mocked(runEmbeddedPiAgent)).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -99,16 +145,17 @@ describe("cron model formatting and precedence edge cases", () => {
|
||||
|
||||
it("handles leading/trailing whitespace in model string", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { res, call } = await runTurn(home, {
|
||||
jobPayload: {
|
||||
kind: "agentTurn",
|
||||
message: DEFAULT_MESSAGE,
|
||||
model: " openai/gpt-4.1-mini ",
|
||||
await expectTurnModel(
|
||||
home,
|
||||
{
|
||||
jobPayload: {
|
||||
kind: "agentTurn",
|
||||
message: DEFAULT_MESSAGE,
|
||||
model: " openai/gpt-4.1-mini ",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe("ok");
|
||||
expect(call.provider).toBe("openai");
|
||||
expect(call.model).toBe("gpt-4.1-mini");
|
||||
{ provider: "openai", model: "gpt-4.1-mini" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -129,38 +176,29 @@ describe("cron model formatting and precedence edge cases", () => {
|
||||
|
||||
it("rejects model with trailing slash (empty model name)", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { res } = await runErrorTurn(home, {
|
||||
jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: "openai/" },
|
||||
});
|
||||
expect(res.status).toBe("error");
|
||||
expect(res.error).toMatch(/invalid model/i);
|
||||
expect(vi.mocked(runEmbeddedPiAgent)).not.toHaveBeenCalled();
|
||||
await expectInvalidModel(home, "openai/");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects model with leading slash (empty provider)", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { res } = await runErrorTurn(home, {
|
||||
jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: "/gpt-4.1-mini" },
|
||||
});
|
||||
expect(res.status).toBe("error");
|
||||
expect(res.error).toMatch(/invalid model/i);
|
||||
expect(vi.mocked(runEmbeddedPiAgent)).not.toHaveBeenCalled();
|
||||
await expectInvalidModel(home, "/gpt-4.1-mini");
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes provider casing", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { res, call } = await runTurn(home, {
|
||||
jobPayload: {
|
||||
kind: "agentTurn",
|
||||
message: DEFAULT_MESSAGE,
|
||||
model: "OpenAI/gpt-4.1-mini",
|
||||
await expectTurnModel(
|
||||
home,
|
||||
{
|
||||
jobPayload: {
|
||||
kind: "agentTurn",
|
||||
message: DEFAULT_MESSAGE,
|
||||
model: "OpenAI/gpt-4.1-mini",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe("ok");
|
||||
expect(call.provider).toBe("openai");
|
||||
expect(call.model).toBe("gpt-4.1-mini");
|
||||
{ provider: "openai", model: "gpt-4.1-mini" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -217,43 +255,39 @@ describe("cron model formatting and precedence edge cases", () => {
|
||||
// No model in job payload. Session store has openai override.
|
||||
// Provider must be openai, not the default anthropic.
|
||||
await withTempHome(async (home) => {
|
||||
const { call } = await runTurn(home, {
|
||||
jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false },
|
||||
storeEntries: {
|
||||
"agent:main:cron:job-1": {
|
||||
sessionId: "existing-session",
|
||||
updatedAt: Date.now(),
|
||||
await expectTurnModel(
|
||||
home,
|
||||
{
|
||||
jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false },
|
||||
storeEntries: createCronSessionOverrideStore({
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-4.1-mini",
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(call.provider).toBe("openai");
|
||||
expect(call.model).toBe("gpt-4.1-mini");
|
||||
{ provider: "openai", model: "gpt-4.1-mini" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("job payload model wins over conflicting session override", async () => {
|
||||
// Job payload says anthropic. Session says openai. Job must win.
|
||||
await withTempHome(async (home) => {
|
||||
const { call } = await runTurn(home, {
|
||||
jobPayload: {
|
||||
kind: "agentTurn",
|
||||
message: DEFAULT_MESSAGE,
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
deliver: false,
|
||||
},
|
||||
storeEntries: {
|
||||
"agent:main:cron:job-1": {
|
||||
sessionId: "existing-session",
|
||||
updatedAt: Date.now(),
|
||||
await expectTurnModel(
|
||||
home,
|
||||
{
|
||||
jobPayload: {
|
||||
kind: "agentTurn",
|
||||
message: DEFAULT_MESSAGE,
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
deliver: false,
|
||||
},
|
||||
storeEntries: createCronSessionOverrideStore({
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-4.1-mini",
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(call.provider).toBe("anthropic");
|
||||
expect(call.model).toBe("claude-sonnet-4-5");
|
||||
{ provider: "anthropic", model: "claude-sonnet-4-5" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -262,9 +296,7 @@ describe("cron model formatting and precedence edge cases", () => {
|
||||
const { call } = await runTurn(home, {
|
||||
jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false },
|
||||
});
|
||||
// makeCfg default is anthropic/claude-opus-4-5
|
||||
expect(call.provider).toBe("anthropic");
|
||||
expect(call.model).toBe("claude-opus-4-5");
|
||||
expectDefaultSelectedModel(call);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -293,17 +325,12 @@ describe("cron model formatting and precedence edge cases", () => {
|
||||
mockAgentPayloads([{ text: "ok" }]);
|
||||
const step2 = await runTurn(home, {
|
||||
jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false },
|
||||
storeEntries: {
|
||||
"agent:main:cron:job-1": {
|
||||
sessionId: "existing-session",
|
||||
updatedAt: Date.now(),
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-4.1-mini",
|
||||
},
|
||||
},
|
||||
storeEntries: createCronSessionOverrideStore({
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-4.1-mini",
|
||||
}),
|
||||
});
|
||||
expect(step2.call.provider).toBe("openai");
|
||||
expect(step2.call.model).toBe("gpt-4.1-mini");
|
||||
expectSelectedModel(step2.call, { provider: "openai", model: "gpt-4.1-mini" });
|
||||
|
||||
// Step 3: Job payload says anthropic, session store still says openai
|
||||
vi.mocked(runEmbeddedPiAgent).mockClear();
|
||||
@ -315,17 +342,12 @@ describe("cron model formatting and precedence edge cases", () => {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
deliver: false,
|
||||
},
|
||||
storeEntries: {
|
||||
"agent:main:cron:job-1": {
|
||||
sessionId: "existing-session",
|
||||
updatedAt: Date.now(),
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-4.1-mini",
|
||||
},
|
||||
},
|
||||
storeEntries: createCronSessionOverrideStore({
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-4.1-mini",
|
||||
}),
|
||||
});
|
||||
expect(step3.call.provider).toBe("anthropic");
|
||||
expect(step3.call.model).toBe("claude-opus-4-5");
|
||||
expectSelectedModel(step3.call, { provider: "anthropic", model: "claude-opus-4-5" });
|
||||
});
|
||||
});
|
||||
|
||||
@ -349,8 +371,7 @@ describe("cron model formatting and precedence edge cases", () => {
|
||||
const r2 = await runTurn(home, {
|
||||
jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false },
|
||||
});
|
||||
expect(r2.call.provider).toBe("anthropic");
|
||||
expect(r2.call.model).toBe("claude-opus-4-5");
|
||||
expectDefaultSelectedModel(r2.call);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -363,19 +384,20 @@ describe("cron model formatting and precedence edge cases", () => {
|
||||
// The stored modelOverride/providerOverride must still be read and applied
|
||||
// (resolveCronSession spreads ...entry before overriding core fields).
|
||||
await withTempHome(async (home) => {
|
||||
const { call } = await runTurn(home, {
|
||||
jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false },
|
||||
storeEntries: {
|
||||
"agent:main:cron:job-1": {
|
||||
sessionId: "old-session-id",
|
||||
updatedAt: Date.now(),
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-4.1-mini",
|
||||
},
|
||||
await expectTurnModel(
|
||||
home,
|
||||
{
|
||||
jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false },
|
||||
storeEntries: createCronSessionOverrideStore(
|
||||
{
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-4.1-mini",
|
||||
},
|
||||
"old-session-id",
|
||||
),
|
||||
},
|
||||
});
|
||||
expect(call.provider).toBe("openai");
|
||||
expect(call.model).toBe("gpt-4.1-mini");
|
||||
{ provider: "openai", model: "gpt-4.1-mini" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -383,16 +405,9 @@ describe("cron model formatting and precedence edge cases", () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { call } = await runTurn(home, {
|
||||
jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false },
|
||||
storeEntries: {
|
||||
"agent:main:cron:job-1": {
|
||||
sessionId: "old-session-id",
|
||||
updatedAt: Date.now(),
|
||||
// No providerOverride or modelOverride
|
||||
},
|
||||
},
|
||||
storeEntries: createCronSessionOverrideStore({}, "old-session-id"),
|
||||
});
|
||||
expect(call.provider).toBe("anthropic");
|
||||
expect(call.model).toBe("claude-opus-4-5");
|
||||
expectDefaultSelectedModel(call);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -405,8 +420,7 @@ describe("cron model formatting and precedence edge cases", () => {
|
||||
const { call } = await runTurn(home, {
|
||||
jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: " " },
|
||||
});
|
||||
expect(call.provider).toBe("anthropic");
|
||||
expect(call.model).toBe("claude-opus-4-5");
|
||||
expectDefaultSelectedModel(call);
|
||||
});
|
||||
});
|
||||
|
||||
@ -415,8 +429,7 @@ describe("cron model formatting and precedence edge cases", () => {
|
||||
const { call } = await runTurn(home, {
|
||||
jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: "" },
|
||||
});
|
||||
expect(call.provider).toBe("anthropic");
|
||||
expect(call.model).toBe("claude-opus-4-5");
|
||||
expectDefaultSelectedModel(call);
|
||||
});
|
||||
});
|
||||
|
||||
@ -424,18 +437,13 @@ describe("cron model formatting and precedence edge cases", () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { call } = await runTurn(home, {
|
||||
jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false },
|
||||
storeEntries: {
|
||||
"agent:main:cron:job-1": {
|
||||
sessionId: "old",
|
||||
updatedAt: Date.now(),
|
||||
providerOverride: "openai",
|
||||
modelOverride: " ",
|
||||
},
|
||||
},
|
||||
storeEntries: createCronSessionOverrideStore(
|
||||
{ providerOverride: "openai", modelOverride: " " },
|
||||
"old",
|
||||
),
|
||||
});
|
||||
// Whitespace modelOverride should be ignored → default
|
||||
expect(call.provider).toBe("anthropic");
|
||||
expect(call.model).toBe("claude-opus-4-5");
|
||||
expectDefaultSelectedModel(call);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -445,35 +453,39 @@ describe("cron model formatting and precedence edge cases", () => {
|
||||
describe("config model format variations", () => {
|
||||
it("default model as string 'provider/model'", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { call } = await runTurn(home, {
|
||||
cfgOverrides: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "openai/gpt-4.1",
|
||||
await expectTurnModel(
|
||||
home,
|
||||
{
|
||||
cfgOverrides: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "openai/gpt-4.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false },
|
||||
},
|
||||
jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false },
|
||||
});
|
||||
expect(call.provider).toBe("openai");
|
||||
expect(call.model).toBe("gpt-4.1");
|
||||
{ provider: "openai", model: "gpt-4.1" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("default model as object with primary field", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { call } = await runTurn(home, {
|
||||
cfgOverrides: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-4.1" },
|
||||
await expectTurnModel(
|
||||
home,
|
||||
{
|
||||
cfgOverrides: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-4.1" },
|
||||
},
|
||||
},
|
||||
},
|
||||
jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false },
|
||||
},
|
||||
jobPayload: { kind: "agentTurn", message: DEFAULT_MESSAGE, deliver: false },
|
||||
});
|
||||
expect(call.provider).toBe("openai");
|
||||
expect(call.model).toBe("gpt-4.1");
|
||||
{ provider: "openai", model: "gpt-4.1" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -64,6 +64,23 @@ function setMainSessionEntry(entry?: SessionStore[string]) {
|
||||
vi.mocked(loadSessionStore).mockReturnValue(store);
|
||||
}
|
||||
|
||||
function setLastSessionEntry(params: {
|
||||
sessionId: string;
|
||||
lastChannel: string;
|
||||
lastTo: string;
|
||||
lastThreadId?: string;
|
||||
lastAccountId?: string;
|
||||
}) {
|
||||
setMainSessionEntry({
|
||||
sessionId: params.sessionId,
|
||||
updatedAt: 1000,
|
||||
lastChannel: params.lastChannel,
|
||||
lastTo: params.lastTo,
|
||||
...(params.lastThreadId ? { lastThreadId: params.lastThreadId } : {}),
|
||||
...(params.lastAccountId ? { lastAccountId: params.lastAccountId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function setWhatsAppAllowFrom(allowFrom: string[]) {
|
||||
vi.mocked(resolveWhatsAppAccount).mockReturnValue({
|
||||
allowFrom,
|
||||
@ -86,11 +103,17 @@ async function resolveForAgent(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveLastTarget(cfg: OpenClawConfig) {
|
||||
return resolveForAgent({
|
||||
cfg,
|
||||
target: { channel: "last", to: undefined },
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveDeliveryTarget", () => {
|
||||
it("reroutes implicit whatsapp delivery to authorized allowFrom recipient", async () => {
|
||||
setMainSessionEntry({
|
||||
setLastSessionEntry({
|
||||
sessionId: "sess-w1",
|
||||
updatedAt: 1000,
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+15550000099",
|
||||
});
|
||||
@ -98,16 +121,15 @@ describe("resolveDeliveryTarget", () => {
|
||||
setStoredWhatsAppAllowFrom(["+15550000001"]);
|
||||
|
||||
const cfg = makeCfg({ bindings: [] });
|
||||
const result = await resolveDeliveryTarget(cfg, AGENT_ID, { channel: "last", to: undefined });
|
||||
const result = await resolveLastTarget(cfg);
|
||||
|
||||
expect(result.channel).toBe("whatsapp");
|
||||
expect(result.to).toBe("+15550000001");
|
||||
});
|
||||
|
||||
it("keeps explicit whatsapp target unchanged", async () => {
|
||||
setMainSessionEntry({
|
||||
setLastSessionEntry({
|
||||
sessionId: "sess-w2",
|
||||
updatedAt: 1000,
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+15550000099",
|
||||
});
|
||||
@ -220,9 +242,8 @@ describe("resolveDeliveryTarget", () => {
|
||||
});
|
||||
|
||||
it("drops session threadId when destination does not match the previous recipient", async () => {
|
||||
setMainSessionEntry({
|
||||
setLastSessionEntry({
|
||||
sessionId: "sess-2",
|
||||
updatedAt: 1000,
|
||||
lastChannel: "telegram",
|
||||
lastTo: "999999",
|
||||
lastThreadId: "thread-1",
|
||||
@ -233,9 +254,8 @@ describe("resolveDeliveryTarget", () => {
|
||||
});
|
||||
|
||||
it("keeps session threadId when destination matches the previous recipient", async () => {
|
||||
setMainSessionEntry({
|
||||
setLastSessionEntry({
|
||||
sessionId: "sess-3",
|
||||
updatedAt: 1000,
|
||||
lastChannel: "telegram",
|
||||
lastTo: "123456",
|
||||
lastThreadId: "thread-2",
|
||||
@ -248,10 +268,7 @@ describe("resolveDeliveryTarget", () => {
|
||||
it("uses single configured channel when neither explicit nor session channel exists", async () => {
|
||||
setMainSessionEntry(undefined);
|
||||
|
||||
const result = await resolveForAgent({
|
||||
cfg: makeCfg({ bindings: [] }),
|
||||
target: { channel: "last", to: undefined },
|
||||
});
|
||||
const result = await resolveLastTarget(makeCfg({ bindings: [] }));
|
||||
expect(result.channel).toBe("telegram");
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
@ -268,10 +285,7 @@ describe("resolveDeliveryTarget", () => {
|
||||
new Error("Channel is required when multiple channels are configured: telegram, slack"),
|
||||
);
|
||||
|
||||
const result = await resolveForAgent({
|
||||
cfg: makeCfg({ bindings: [] }),
|
||||
target: { channel: "last", to: undefined },
|
||||
});
|
||||
const result = await resolveLastTarget(makeCfg({ bindings: [] }));
|
||||
expect(result.channel).toBeUndefined();
|
||||
expect(result.to).toBeUndefined();
|
||||
expect(result.ok).toBe(false);
|
||||
@ -308,17 +322,13 @@ describe("resolveDeliveryTarget", () => {
|
||||
});
|
||||
|
||||
it("uses main session channel when channel=last and session route exists", async () => {
|
||||
setMainSessionEntry({
|
||||
setLastSessionEntry({
|
||||
sessionId: "sess-4",
|
||||
updatedAt: 1000,
|
||||
lastChannel: "telegram",
|
||||
lastTo: "987654",
|
||||
});
|
||||
|
||||
const result = await resolveForAgent({
|
||||
cfg: makeCfg({ bindings: [] }),
|
||||
target: { channel: "last", to: undefined },
|
||||
});
|
||||
const result = await resolveLastTarget(makeCfg({ bindings: [] }));
|
||||
|
||||
expect(result.channel).toBe("telegram");
|
||||
expect(result.to).toBe("987654");
|
||||
@ -326,9 +336,8 @@ describe("resolveDeliveryTarget", () => {
|
||||
});
|
||||
|
||||
it("explicit delivery.accountId overrides session-derived accountId", async () => {
|
||||
setMainSessionEntry({
|
||||
setLastSessionEntry({
|
||||
sessionId: "sess-5",
|
||||
updatedAt: 1000,
|
||||
lastChannel: "telegram",
|
||||
lastTo: "chat-999",
|
||||
lastAccountId: "default",
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
countActiveDescendantRunsMock,
|
||||
listDescendantRunsForRequesterMock,
|
||||
loadRunCronIsolatedAgentTurn,
|
||||
mockRunCronFallbackPassthrough,
|
||||
pickLastNonEmptyTextFromPayloadsMock,
|
||||
runEmbeddedPiAgentMock,
|
||||
runWithModelFallbackMock,
|
||||
@ -17,13 +18,6 @@ const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
|
||||
describe("runCronIsolatedAgentTurn — interim ack retry", () => {
|
||||
setupRunCronIsolatedAgentTurnSuite();
|
||||
|
||||
const mockFallbackPassthrough = () => {
|
||||
runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => {
|
||||
const result = await run(provider, model);
|
||||
return { result, provider, model, attempts: [] };
|
||||
});
|
||||
};
|
||||
|
||||
const runTurnAndExpectOk = async (expectedFallbackCalls: number, expectedAgentCalls: number) => {
|
||||
const result = await runCronIsolatedAgentTurn(makeIsolatedAgentTurnParams());
|
||||
expect(result.status).toBe("ok");
|
||||
@ -62,7 +56,7 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => {
|
||||
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
|
||||
});
|
||||
|
||||
mockFallbackPassthrough();
|
||||
mockRunCronFallbackPassthrough();
|
||||
await runTurnAndExpectOk(2, 2);
|
||||
expect(runEmbeddedPiAgentMock.mock.calls[1]?.[0]?.prompt).toContain(
|
||||
"previous response was only an acknowledgement",
|
||||
@ -76,7 +70,7 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => {
|
||||
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
|
||||
});
|
||||
|
||||
mockFallbackPassthrough();
|
||||
mockRunCronFallbackPassthrough();
|
||||
await runTurnAndExpectOk(1, 1);
|
||||
});
|
||||
|
||||
@ -93,7 +87,7 @@ describe("runCronIsolatedAgentTurn — interim ack retry", () => {
|
||||
]);
|
||||
countActiveDescendantRunsMock.mockReturnValue(0);
|
||||
|
||||
mockFallbackPassthrough();
|
||||
mockRunCronFallbackPassthrough();
|
||||
await runTurnAndExpectOk(1, 1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,12 +2,12 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearFastTestEnv,
|
||||
loadRunCronIsolatedAgentTurn,
|
||||
mockRunCronFallbackPassthrough,
|
||||
resetRunCronIsolatedAgentTurnHarness,
|
||||
resolveCronDeliveryPlanMock,
|
||||
resolveDeliveryTargetMock,
|
||||
restoreFastTestEnv,
|
||||
runEmbeddedPiAgentMock,
|
||||
runWithModelFallbackMock,
|
||||
} from "./run.test-harness.js";
|
||||
|
||||
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
|
||||
@ -32,12 +32,18 @@ function makeParams() {
|
||||
describe("runCronIsolatedAgentTurn message tool policy", () => {
|
||||
let previousFastTestEnv: string | undefined;
|
||||
|
||||
const mockFallbackPassthrough = () => {
|
||||
runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => {
|
||||
const result = await run(provider, model);
|
||||
return { result, provider, model, attempts: [] };
|
||||
});
|
||||
};
|
||||
async function expectMessageToolDisabledForPlan(plan: {
|
||||
requested: boolean;
|
||||
mode: "none" | "announce";
|
||||
channel?: string;
|
||||
to?: string;
|
||||
}) {
|
||||
mockRunCronFallbackPassthrough();
|
||||
resolveCronDeliveryPlanMock.mockReturnValue(plan);
|
||||
await runCronIsolatedAgentTurn(makeParams());
|
||||
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
|
||||
expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.disableMessageTool).toBe(true);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
previousFastTestEnv = clearFastTestEnv();
|
||||
@ -56,35 +62,23 @@ describe("runCronIsolatedAgentTurn message tool policy", () => {
|
||||
});
|
||||
|
||||
it('disables the message tool when delivery.mode is "none"', async () => {
|
||||
mockFallbackPassthrough();
|
||||
resolveCronDeliveryPlanMock.mockReturnValue({
|
||||
await expectMessageToolDisabledForPlan({
|
||||
requested: false,
|
||||
mode: "none",
|
||||
});
|
||||
|
||||
await runCronIsolatedAgentTurn(makeParams());
|
||||
|
||||
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
|
||||
expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.disableMessageTool).toBe(true);
|
||||
});
|
||||
|
||||
it("disables the message tool when cron delivery is active", async () => {
|
||||
mockFallbackPassthrough();
|
||||
resolveCronDeliveryPlanMock.mockReturnValue({
|
||||
await expectMessageToolDisabledForPlan({
|
||||
requested: true,
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
});
|
||||
|
||||
await runCronIsolatedAgentTurn(makeParams());
|
||||
|
||||
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
|
||||
expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.disableMessageTool).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the message tool enabled for shared callers when delivery is not requested", async () => {
|
||||
mockFallbackPassthrough();
|
||||
mockRunCronFallbackPassthrough();
|
||||
resolveCronDeliveryPlanMock.mockReturnValue({
|
||||
requested: false,
|
||||
mode: "none",
|
||||
|
||||
@ -54,6 +54,31 @@ function makeParams(overrides?: Record<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
function expectDefaultSandboxPreserved(
|
||||
runCfg:
|
||||
| {
|
||||
agents?: { defaults?: { sandbox?: unknown } };
|
||||
}
|
||||
| undefined,
|
||||
) {
|
||||
expect(runCfg?.agents?.defaults?.sandbox).toEqual({
|
||||
mode: "all",
|
||||
workspaceAccess: "rw",
|
||||
docker: {
|
||||
network: "none",
|
||||
dangerouslyAllowContainerNamespaceJoin: true,
|
||||
dangerouslyAllowExternalBindSources: true,
|
||||
},
|
||||
browser: {
|
||||
enabled: true,
|
||||
autoStart: false,
|
||||
},
|
||||
prune: {
|
||||
maxAgeDays: 7,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("runCronIsolatedAgentTurn sandbox config preserved", () => {
|
||||
let previousFastTestEnv: string | undefined;
|
||||
|
||||
@ -79,22 +104,7 @@ describe("runCronIsolatedAgentTurn sandbox config preserved", () => {
|
||||
|
||||
expect(runWithModelFallbackMock).toHaveBeenCalledTimes(1);
|
||||
const runCfg = runWithModelFallbackMock.mock.calls[0]?.[0]?.cfg;
|
||||
expect(runCfg?.agents?.defaults?.sandbox).toEqual({
|
||||
mode: "all",
|
||||
workspaceAccess: "rw",
|
||||
docker: {
|
||||
network: "none",
|
||||
dangerouslyAllowContainerNamespaceJoin: true,
|
||||
dangerouslyAllowExternalBindSources: true,
|
||||
},
|
||||
browser: {
|
||||
enabled: true,
|
||||
autoStart: false,
|
||||
},
|
||||
prune: {
|
||||
maxAgeDays: 7,
|
||||
},
|
||||
});
|
||||
expectDefaultSandboxPreserved(runCfg);
|
||||
});
|
||||
|
||||
it("keeps global sandbox defaults when agent override is partial", async () => {
|
||||
@ -118,22 +128,7 @@ describe("runCronIsolatedAgentTurn sandbox config preserved", () => {
|
||||
const runCfg = runWithModelFallbackMock.mock.calls[0]?.[0]?.cfg;
|
||||
const resolvedSandbox = resolveSandboxConfigForAgent(runCfg, "specialist");
|
||||
|
||||
expect(runCfg?.agents?.defaults?.sandbox).toEqual({
|
||||
mode: "all",
|
||||
workspaceAccess: "rw",
|
||||
docker: {
|
||||
network: "none",
|
||||
dangerouslyAllowContainerNamespaceJoin: true,
|
||||
dangerouslyAllowExternalBindSources: true,
|
||||
},
|
||||
browser: {
|
||||
enabled: true,
|
||||
autoStart: false,
|
||||
},
|
||||
prune: {
|
||||
maxAgeDays: 7,
|
||||
},
|
||||
});
|
||||
expectDefaultSandboxPreserved(runCfg);
|
||||
expect(resolvedSandbox.mode).toBe("all");
|
||||
expect(resolvedSandbox.workspaceAccess).toBe("rw");
|
||||
expect(resolvedSandbox.docker).toMatchObject({
|
||||
|
||||
@ -341,6 +341,13 @@ function makeDefaultEmbeddedResult() {
|
||||
};
|
||||
}
|
||||
|
||||
export function mockRunCronFallbackPassthrough(): void {
|
||||
runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => {
|
||||
const result = await run(provider, model);
|
||||
return { result, provider, model, attempts: [] };
|
||||
});
|
||||
}
|
||||
|
||||
export function resetRunCronIsolatedAgentTurnHarness(): void {
|
||||
vi.clearAllMocks();
|
||||
|
||||
|
||||
@ -33,6 +33,29 @@ async function resolveAfterAdvancingTimers<T>(promise: Promise<T>, advanceMs = 1
|
||||
return promise;
|
||||
}
|
||||
|
||||
function createDescendantRun(params?: {
|
||||
runId?: string;
|
||||
childSessionKey?: string;
|
||||
task?: string;
|
||||
cleanup?: "keep" | "delete";
|
||||
endedAt?: number;
|
||||
frozenResultText?: string | null;
|
||||
}) {
|
||||
return {
|
||||
runId: params?.runId ?? "run-1",
|
||||
childSessionKey: params?.childSessionKey ?? "child-1",
|
||||
requesterSessionKey: "test-session",
|
||||
requesterDisplayKey: "test-session",
|
||||
task: params?.task ?? "task-1",
|
||||
cleanup: params?.cleanup ?? "keep",
|
||||
createdAt: 1000,
|
||||
endedAt: params?.endedAt ?? 2000,
|
||||
...(params?.frozenResultText === undefined
|
||||
? {}
|
||||
: { frozenResultText: params.frozenResultText }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("isLikelyInterimCronMessage", () => {
|
||||
it("detects 'on it' as interim", () => {
|
||||
expect(isLikelyInterimCronMessage("on it")).toBe(true);
|
||||
@ -85,18 +108,7 @@ describe("readDescendantSubagentFallbackReply", () => {
|
||||
});
|
||||
|
||||
it("reads reply from child session transcript", async () => {
|
||||
vi.mocked(listDescendantRunsForRequester).mockReturnValue([
|
||||
{
|
||||
runId: "run-1",
|
||||
childSessionKey: "child-1",
|
||||
requesterSessionKey: "test-session",
|
||||
requesterDisplayKey: "test-session",
|
||||
task: "task-1",
|
||||
cleanup: "keep",
|
||||
createdAt: 1000,
|
||||
endedAt: 2000,
|
||||
},
|
||||
]);
|
||||
vi.mocked(listDescendantRunsForRequester).mockReturnValue([createDescendantRun()]);
|
||||
vi.mocked(readLatestAssistantReply).mockResolvedValue("child output text");
|
||||
const result = await readDescendantSubagentFallbackReply({
|
||||
sessionKey: "test-session",
|
||||
@ -107,17 +119,10 @@ describe("readDescendantSubagentFallbackReply", () => {
|
||||
|
||||
it("falls back to frozenResultText when session transcript unavailable", async () => {
|
||||
vi.mocked(listDescendantRunsForRequester).mockReturnValue([
|
||||
{
|
||||
runId: "run-1",
|
||||
childSessionKey: "child-1",
|
||||
requesterSessionKey: "test-session",
|
||||
requesterDisplayKey: "test-session",
|
||||
task: "task-1",
|
||||
createDescendantRun({
|
||||
cleanup: "delete",
|
||||
createdAt: 1000,
|
||||
endedAt: 2000,
|
||||
frozenResultText: "frozen child output",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
vi.mocked(readLatestAssistantReply).mockResolvedValue(undefined);
|
||||
const result = await readDescendantSubagentFallbackReply({
|
||||
@ -129,17 +134,7 @@ describe("readDescendantSubagentFallbackReply", () => {
|
||||
|
||||
it("prefers session transcript over frozenResultText", async () => {
|
||||
vi.mocked(listDescendantRunsForRequester).mockReturnValue([
|
||||
{
|
||||
runId: "run-1",
|
||||
childSessionKey: "child-1",
|
||||
requesterSessionKey: "test-session",
|
||||
requesterDisplayKey: "test-session",
|
||||
task: "task-1",
|
||||
cleanup: "keep",
|
||||
createdAt: 1000,
|
||||
endedAt: 2000,
|
||||
frozenResultText: "frozen text",
|
||||
},
|
||||
createDescendantRun({ frozenResultText: "frozen text" }),
|
||||
]);
|
||||
vi.mocked(readLatestAssistantReply).mockResolvedValue("live transcript text");
|
||||
const result = await readDescendantSubagentFallbackReply({
|
||||
@ -151,28 +146,14 @@ describe("readDescendantSubagentFallbackReply", () => {
|
||||
|
||||
it("joins replies from multiple descendants", async () => {
|
||||
vi.mocked(listDescendantRunsForRequester).mockReturnValue([
|
||||
{
|
||||
runId: "run-1",
|
||||
childSessionKey: "child-1",
|
||||
requesterSessionKey: "test-session",
|
||||
requesterDisplayKey: "test-session",
|
||||
task: "task-1",
|
||||
cleanup: "keep",
|
||||
createdAt: 1000,
|
||||
endedAt: 2000,
|
||||
frozenResultText: "first child output",
|
||||
},
|
||||
{
|
||||
createDescendantRun({ frozenResultText: "first child output" }),
|
||||
createDescendantRun({
|
||||
runId: "run-2",
|
||||
childSessionKey: "child-2",
|
||||
requesterSessionKey: "test-session",
|
||||
requesterDisplayKey: "test-session",
|
||||
task: "task-2",
|
||||
cleanup: "keep",
|
||||
createdAt: 1000,
|
||||
endedAt: 3000,
|
||||
frozenResultText: "second child output",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
vi.mocked(readLatestAssistantReply).mockResolvedValue(undefined);
|
||||
const result = await readDescendantSubagentFallbackReply({
|
||||
@ -184,27 +165,14 @@ describe("readDescendantSubagentFallbackReply", () => {
|
||||
|
||||
it("skips SILENT_REPLY_TOKEN descendants", async () => {
|
||||
vi.mocked(listDescendantRunsForRequester).mockReturnValue([
|
||||
{
|
||||
runId: "run-1",
|
||||
childSessionKey: "child-1",
|
||||
requesterSessionKey: "test-session",
|
||||
requesterDisplayKey: "test-session",
|
||||
task: "task-1",
|
||||
cleanup: "keep",
|
||||
createdAt: 1000,
|
||||
endedAt: 2000,
|
||||
},
|
||||
{
|
||||
createDescendantRun(),
|
||||
createDescendantRun({
|
||||
runId: "run-2",
|
||||
childSessionKey: "child-2",
|
||||
requesterSessionKey: "test-session",
|
||||
requesterDisplayKey: "test-session",
|
||||
task: "task-2",
|
||||
cleanup: "keep",
|
||||
createdAt: 1000,
|
||||
endedAt: 3000,
|
||||
frozenResultText: "useful output",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
vi.mocked(readLatestAssistantReply).mockImplementation(async (params) => {
|
||||
if (params.sessionKey === "child-1") {
|
||||
@ -221,17 +189,10 @@ describe("readDescendantSubagentFallbackReply", () => {
|
||||
|
||||
it("returns undefined when frozenResultText is null", async () => {
|
||||
vi.mocked(listDescendantRunsForRequester).mockReturnValue([
|
||||
{
|
||||
runId: "run-1",
|
||||
childSessionKey: "child-1",
|
||||
requesterSessionKey: "test-session",
|
||||
requesterDisplayKey: "test-session",
|
||||
task: "task-1",
|
||||
createDescendantRun({
|
||||
cleanup: "delete",
|
||||
createdAt: 1000,
|
||||
endedAt: 2000,
|
||||
frozenResultText: null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
vi.mocked(readLatestAssistantReply).mockResolvedValue(undefined);
|
||||
const result = await readDescendantSubagentFallbackReply({
|
||||
|
||||
@ -169,7 +169,7 @@ export async function waitForDescendantSubagentSummary(params: {
|
||||
// CRON_SUBAGENT_FINAL_REPLY_GRACE_MS) to capture that synthesis.
|
||||
const gracePeriodDeadline = Math.min(Date.now() + CRON_SUBAGENT_FINAL_REPLY_GRACE_MS, deadline);
|
||||
|
||||
while (Date.now() < gracePeriodDeadline) {
|
||||
const resolveUsableLatestReply = async () => {
|
||||
const latest = (await readLatestAssistantReply({ sessionKey: params.sessionKey }))?.trim();
|
||||
if (
|
||||
latest &&
|
||||
@ -178,16 +178,20 @@ export async function waitForDescendantSubagentSummary(params: {
|
||||
) {
|
||||
return latest;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
while (Date.now() < gracePeriodDeadline) {
|
||||
const latest = await resolveUsableLatestReply();
|
||||
if (latest) {
|
||||
return latest;
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, CRON_SUBAGENT_GRACE_POLL_MS));
|
||||
}
|
||||
|
||||
// Final read after grace period expires.
|
||||
const latest = (await readLatestAssistantReply({ sessionKey: params.sessionKey }))?.trim();
|
||||
if (
|
||||
latest &&
|
||||
latest.toUpperCase() !== SILENT_REPLY_TOKEN.toUpperCase() &&
|
||||
(latest !== initialReply || !isLikelyInterimCronMessage(latest))
|
||||
) {
|
||||
const latest = await resolveUsableLatestReply();
|
||||
if (latest) {
|
||||
return latest;
|
||||
}
|
||||
|
||||
|
||||
@ -57,6 +57,30 @@ async function writeGatewayScript(env: Record<string, string>, port = 18789) {
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
async function writeStartupFallbackEntry(env: Record<string, string>) {
|
||||
const startupEntryPath = resolveStartupEntryPath(env);
|
||||
await fs.mkdir(path.dirname(startupEntryPath), { recursive: true });
|
||||
await fs.writeFile(startupEntryPath, "@echo off\r\n", "utf8");
|
||||
return startupEntryPath;
|
||||
}
|
||||
|
||||
function expectStartupFallbackSpawn(env: Record<string, string>) {
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
"cmd.exe",
|
||||
["/d", "/s", "/c", quoteCmdScriptArg(resolveTaskScriptPath(env))],
|
||||
expect.objectContaining({ detached: true, stdio: "ignore", windowsHide: true }),
|
||||
);
|
||||
}
|
||||
|
||||
function addStartupFallbackMissingResponses(
|
||||
extraResponses: Array<{ code: number; stdout: string; stderr: string }> = [],
|
||||
) {
|
||||
schtasksResponses.push(
|
||||
{ code: 0, stdout: "", stderr: "" },
|
||||
{ code: 1, stdout: "", stderr: "not found" },
|
||||
...extraResponses,
|
||||
);
|
||||
}
|
||||
beforeEach(() => {
|
||||
resetSchtasksBaseMocks();
|
||||
spawn.mockClear();
|
||||
@ -119,22 +143,14 @@ describe("Windows startup fallback", () => {
|
||||
});
|
||||
|
||||
await expect(fs.access(resolveStartupEntryPath(env))).resolves.toBeUndefined();
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
"cmd.exe",
|
||||
["/d", "/s", "/c", quoteCmdScriptArg(resolveTaskScriptPath(env))],
|
||||
expect.objectContaining({ detached: true, stdio: "ignore", windowsHide: true }),
|
||||
);
|
||||
expectStartupFallbackSpawn(env);
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an installed Startup-folder launcher as loaded", async () => {
|
||||
await withWindowsEnv("openclaw-win-startup-", async ({ env }) => {
|
||||
schtasksResponses.push(
|
||||
{ code: 0, stdout: "", stderr: "" },
|
||||
{ code: 1, stdout: "", stderr: "not found" },
|
||||
);
|
||||
await fs.mkdir(path.dirname(resolveStartupEntryPath(env)), { recursive: true });
|
||||
await fs.writeFile(resolveStartupEntryPath(env), "@echo off\r\n", "utf8");
|
||||
addStartupFallbackMissingResponses();
|
||||
await writeStartupFallbackEntry(env);
|
||||
|
||||
await expect(isScheduledTaskInstalled({ env })).resolves.toBe(true);
|
||||
});
|
||||
@ -142,12 +158,8 @@ describe("Windows startup fallback", () => {
|
||||
|
||||
it("reports runtime from the gateway listener when using the Startup fallback", async () => {
|
||||
await withWindowsEnv("openclaw-win-startup-", async ({ env }) => {
|
||||
schtasksResponses.push(
|
||||
{ code: 0, stdout: "", stderr: "" },
|
||||
{ code: 1, stdout: "", stderr: "not found" },
|
||||
);
|
||||
await fs.mkdir(path.dirname(resolveStartupEntryPath(env)), { recursive: true });
|
||||
await fs.writeFile(resolveStartupEntryPath(env), "@echo off\r\n", "utf8");
|
||||
addStartupFallbackMissingResponses();
|
||||
await writeStartupFallbackEntry(env);
|
||||
inspectPortUsage.mockResolvedValue({
|
||||
port: 18789,
|
||||
status: "busy",
|
||||
@ -164,14 +176,11 @@ describe("Windows startup fallback", () => {
|
||||
|
||||
it("restarts the Startup fallback by killing the current pid and relaunching the entry", async () => {
|
||||
await withWindowsEnv("openclaw-win-startup-", async ({ env }) => {
|
||||
schtasksResponses.push(
|
||||
addStartupFallbackMissingResponses([
|
||||
{ code: 0, stdout: "", stderr: "" },
|
||||
{ code: 1, stdout: "", stderr: "not found" },
|
||||
{ code: 0, stdout: "", stderr: "" },
|
||||
{ code: 1, stdout: "", stderr: "not found" },
|
||||
);
|
||||
await fs.mkdir(path.dirname(resolveStartupEntryPath(env)), { recursive: true });
|
||||
await fs.writeFile(resolveStartupEntryPath(env), "@echo off\r\n", "utf8");
|
||||
]);
|
||||
await writeStartupFallbackEntry(env);
|
||||
inspectPortUsage.mockResolvedValue({
|
||||
port: 18789,
|
||||
status: "busy",
|
||||
@ -184,11 +193,7 @@ describe("Windows startup fallback", () => {
|
||||
outcome: "completed",
|
||||
});
|
||||
expect(killProcessTree).toHaveBeenCalledWith(5151, { graceMs: 300 });
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
"cmd.exe",
|
||||
["/d", "/s", "/c", quoteCmdScriptArg(resolveTaskScriptPath(env))],
|
||||
expect.objectContaining({ detached: true, stdio: "ignore", windowsHide: true }),
|
||||
);
|
||||
expectStartupFallbackSpawn(env);
|
||||
});
|
||||
});
|
||||
|
||||
@ -196,8 +201,7 @@ describe("Windows startup fallback", () => {
|
||||
await withWindowsEnv("openclaw-win-startup-", async ({ env }) => {
|
||||
schtasksResponses.push({ code: 0, stdout: "", stderr: "" });
|
||||
await writeGatewayScript(env);
|
||||
await fs.mkdir(path.dirname(resolveStartupEntryPath(env)), { recursive: true });
|
||||
await fs.writeFile(resolveStartupEntryPath(env), "@echo off\r\n", "utf8");
|
||||
await writeStartupFallbackEntry(env);
|
||||
inspectPortUsage
|
||||
.mockResolvedValueOnce({
|
||||
port: 18789,
|
||||
|
||||
@ -29,45 +29,33 @@ function createStore(params?: {
|
||||
};
|
||||
}
|
||||
|
||||
function expectExhaustedDecision(params: { failureCounts: Record<string, number> }) {
|
||||
const now = Date.now();
|
||||
const decision = resolveDiscordAutoPresenceDecision({
|
||||
discordConfig: {
|
||||
autoPresence: {
|
||||
enabled: true,
|
||||
exhaustedText: "token exhausted",
|
||||
},
|
||||
},
|
||||
authStore: createStore({ cooldownUntil: now + 60_000, failureCounts: params.failureCounts }),
|
||||
gatewayConnected: true,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(decision).toBeTruthy();
|
||||
expect(decision?.state).toBe("exhausted");
|
||||
expect(decision?.presence.status).toBe("dnd");
|
||||
expect(decision?.presence.activities[0]?.state).toBe("token exhausted");
|
||||
}
|
||||
|
||||
describe("discord auto presence", () => {
|
||||
it("maps exhausted runtime signal to dnd", () => {
|
||||
const now = Date.now();
|
||||
const decision = resolveDiscordAutoPresenceDecision({
|
||||
discordConfig: {
|
||||
autoPresence: {
|
||||
enabled: true,
|
||||
exhaustedText: "token exhausted",
|
||||
},
|
||||
},
|
||||
authStore: createStore({ cooldownUntil: now + 60_000, failureCounts: { rate_limit: 2 } }),
|
||||
gatewayConnected: true,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(decision).toBeTruthy();
|
||||
expect(decision?.state).toBe("exhausted");
|
||||
expect(decision?.presence.status).toBe("dnd");
|
||||
expect(decision?.presence.activities[0]?.state).toBe("token exhausted");
|
||||
expectExhaustedDecision({ failureCounts: { rate_limit: 2 } });
|
||||
});
|
||||
|
||||
it("treats overloaded cooldown as exhausted", () => {
|
||||
const now = Date.now();
|
||||
const decision = resolveDiscordAutoPresenceDecision({
|
||||
discordConfig: {
|
||||
autoPresence: {
|
||||
enabled: true,
|
||||
exhaustedText: "token exhausted",
|
||||
},
|
||||
},
|
||||
authStore: createStore({ cooldownUntil: now + 60_000, failureCounts: { overloaded: 2 } }),
|
||||
gatewayConnected: true,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(decision).toBeTruthy();
|
||||
expect(decision?.state).toBe("exhausted");
|
||||
expect(decision?.presence.status).toBe("dnd");
|
||||
expect(decision?.presence.activities[0]?.state).toBe("token exhausted");
|
||||
expectExhaustedDecision({ failureCounts: { overloaded: 2 } });
|
||||
});
|
||||
|
||||
it("recovers from exhausted to online once a profile becomes usable", () => {
|
||||
|
||||
@ -113,6 +113,26 @@ function runDockerSetup(
|
||||
});
|
||||
}
|
||||
|
||||
async function runDockerSetupWithUnsetGatewayToken(
|
||||
sandbox: DockerSetupSandbox,
|
||||
suffix: string,
|
||||
prepare?: (configDir: string) => Promise<void>,
|
||||
) {
|
||||
const configDir = join(sandbox.rootDir, `config-${suffix}`);
|
||||
const workspaceDir = join(sandbox.rootDir, `workspace-${suffix}`);
|
||||
await mkdir(configDir, { recursive: true });
|
||||
await prepare?.(configDir);
|
||||
|
||||
const result = runDockerSetup(sandbox, {
|
||||
OPENCLAW_GATEWAY_TOKEN: undefined,
|
||||
OPENCLAW_CONFIG_DIR: configDir,
|
||||
OPENCLAW_WORKSPACE_DIR: workspaceDir,
|
||||
});
|
||||
const envFile = await readFile(join(sandbox.rootDir, ".env"), "utf8");
|
||||
|
||||
return { result, envFile };
|
||||
}
|
||||
|
||||
async function withUnixSocket<T>(socketPath: string, run: () => Promise<T>): Promise<T> {
|
||||
const server = createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@ -243,52 +263,39 @@ describe("docker-setup.sh", () => {
|
||||
|
||||
it("reuses existing config token when OPENCLAW_GATEWAY_TOKEN is unset", async () => {
|
||||
const activeSandbox = requireSandbox(sandbox);
|
||||
const configDir = join(activeSandbox.rootDir, "config-token-reuse");
|
||||
const workspaceDir = join(activeSandbox.rootDir, "workspace-token-reuse");
|
||||
await mkdir(configDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(configDir, "openclaw.json"),
|
||||
JSON.stringify({ gateway: { auth: { mode: "token", token: "config-token-123" } } }),
|
||||
const { result, envFile } = await runDockerSetupWithUnsetGatewayToken(
|
||||
activeSandbox,
|
||||
"token-reuse",
|
||||
async (configDir) => {
|
||||
await writeFile(
|
||||
join(configDir, "openclaw.json"),
|
||||
JSON.stringify({ gateway: { auth: { mode: "token", token: "config-token-123" } } }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const result = runDockerSetup(activeSandbox, {
|
||||
OPENCLAW_GATEWAY_TOKEN: undefined,
|
||||
OPENCLAW_CONFIG_DIR: configDir,
|
||||
OPENCLAW_WORKSPACE_DIR: workspaceDir,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8");
|
||||
expect(envFile).toContain("OPENCLAW_GATEWAY_TOKEN=config-token-123"); // pragma: allowlist secret
|
||||
});
|
||||
|
||||
it("reuses existing .env token when OPENCLAW_GATEWAY_TOKEN and config token are unset", async () => {
|
||||
const activeSandbox = requireSandbox(sandbox);
|
||||
const configDir = join(activeSandbox.rootDir, "config-dotenv-token-reuse");
|
||||
const workspaceDir = join(activeSandbox.rootDir, "workspace-dotenv-token-reuse");
|
||||
await mkdir(configDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(activeSandbox.rootDir, ".env"),
|
||||
"OPENCLAW_GATEWAY_TOKEN=dotenv-token-123\nOPENCLAW_GATEWAY_PORT=18789\n", // pragma: allowlist secret
|
||||
);
|
||||
|
||||
const result = runDockerSetup(activeSandbox, {
|
||||
OPENCLAW_GATEWAY_TOKEN: undefined,
|
||||
OPENCLAW_CONFIG_DIR: configDir,
|
||||
OPENCLAW_WORKSPACE_DIR: workspaceDir,
|
||||
});
|
||||
const { result, envFile } = await runDockerSetupWithUnsetGatewayToken(
|
||||
activeSandbox,
|
||||
"dotenv-token-reuse",
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8");
|
||||
expect(envFile).toContain("OPENCLAW_GATEWAY_TOKEN=dotenv-token-123"); // pragma: allowlist secret
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
|
||||
it("reuses the last non-empty .env token and strips CRLF without truncating '='", async () => {
|
||||
const activeSandbox = requireSandbox(sandbox);
|
||||
const configDir = join(activeSandbox.rootDir, "config-dotenv-last-wins");
|
||||
const workspaceDir = join(activeSandbox.rootDir, "workspace-dotenv-last-wins");
|
||||
await mkdir(configDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(activeSandbox.rootDir, ".env"),
|
||||
[
|
||||
@ -297,15 +304,12 @@ describe("docker-setup.sh", () => {
|
||||
"OPENCLAW_GATEWAY_TOKEN=last=token=value\r", // pragma: allowlist secret
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const result = runDockerSetup(activeSandbox, {
|
||||
OPENCLAW_GATEWAY_TOKEN: undefined,
|
||||
OPENCLAW_CONFIG_DIR: configDir,
|
||||
OPENCLAW_WORKSPACE_DIR: workspaceDir,
|
||||
});
|
||||
const { result, envFile } = await runDockerSetupWithUnsetGatewayToken(
|
||||
activeSandbox,
|
||||
"dotenv-last-wins",
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8");
|
||||
expect(envFile).toContain("OPENCLAW_GATEWAY_TOKEN=last=token=value"); // pragma: allowlist secret
|
||||
expect(envFile).not.toContain("OPENCLAW_GATEWAY_TOKEN=first-token");
|
||||
expect(envFile).not.toContain("\r");
|
||||
|
||||
@ -10,12 +10,20 @@ const requestHeartbeatNowMock = vi.fn();
|
||||
const loadConfigMock = vi.fn();
|
||||
const fetchWithSsrFGuardMock = vi.fn();
|
||||
|
||||
function enqueueSystemEvent(...args: unknown[]) {
|
||||
return enqueueSystemEventMock(...args);
|
||||
}
|
||||
|
||||
function requestHeartbeatNow(...args: unknown[]) {
|
||||
return requestHeartbeatNowMock(...args);
|
||||
}
|
||||
|
||||
vi.mock("../infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
|
||||
enqueueSystemEvent,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/heartbeat-wake.js", () => ({
|
||||
requestHeartbeatNow: (...args: unknown[]) => requestHeartbeatNowMock(...args),
|
||||
requestHeartbeatNow,
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async () => {
|
||||
@ -32,6 +40,18 @@ vi.mock("../infra/net/fetch-guard.js", () => ({
|
||||
|
||||
import { buildGatewayCronService } from "./server-cron.js";
|
||||
|
||||
function createCronConfig(name: string): OpenClawConfig {
|
||||
const tmpDir = path.join(os.tmpdir(), `${name}-${Date.now()}`);
|
||||
return {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
},
|
||||
cron: {
|
||||
store: path.join(tmpDir, "cron.json"),
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
describe("buildGatewayCronService", () => {
|
||||
beforeEach(() => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
@ -41,15 +61,7 @@ describe("buildGatewayCronService", () => {
|
||||
});
|
||||
|
||||
it("routes main-target jobs to the scoped session for enqueue + wake", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `server-cron-${Date.now()}`);
|
||||
const cfg = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
},
|
||||
cron: {
|
||||
store: path.join(tmpDir, "cron.json"),
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const cfg = createCronConfig("server-cron");
|
||||
loadConfigMock.mockReturnValue(cfg);
|
||||
|
||||
const state = buildGatewayCronService({
|
||||
@ -87,16 +99,7 @@ describe("buildGatewayCronService", () => {
|
||||
});
|
||||
|
||||
it("blocks private webhook URLs via SSRF-guarded fetch", async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `server-cron-ssrf-${Date.now()}`);
|
||||
const cfg = {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
},
|
||||
cron: {
|
||||
store: path.join(tmpDir, "cron.json"),
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const cfg = createCronConfig("server-cron-ssrf");
|
||||
loadConfigMock.mockReturnValue(cfg);
|
||||
fetchWithSsrFGuardMock.mockRejectedValue(
|
||||
new SsrFBlockedError("Blocked: resolves to private/internal/special-use IP address"),
|
||||
|
||||
@ -23,6 +23,17 @@ function asFiniteNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function removeWaiter(runId: string, waiter: () => void): void {
|
||||
const waiters = AGENT_WAITERS_BY_RUN_ID.get(runId);
|
||||
if (!waiters) {
|
||||
return;
|
||||
}
|
||||
waiters.delete(waiter);
|
||||
if (waiters.size === 0) {
|
||||
AGENT_WAITERS_BY_RUN_ID.delete(runId);
|
||||
}
|
||||
}
|
||||
|
||||
function addWaiter(runId: string, waiter: () => void): () => void {
|
||||
const normalizedRunId = runId.trim();
|
||||
if (!normalizedRunId) {
|
||||
@ -31,28 +42,10 @@ function addWaiter(runId: string, waiter: () => void): () => void {
|
||||
const existing = AGENT_WAITERS_BY_RUN_ID.get(normalizedRunId);
|
||||
if (existing) {
|
||||
existing.add(waiter);
|
||||
return () => {
|
||||
const waiters = AGENT_WAITERS_BY_RUN_ID.get(normalizedRunId);
|
||||
if (!waiters) {
|
||||
return;
|
||||
}
|
||||
waiters.delete(waiter);
|
||||
if (waiters.size === 0) {
|
||||
AGENT_WAITERS_BY_RUN_ID.delete(normalizedRunId);
|
||||
}
|
||||
};
|
||||
return () => removeWaiter(normalizedRunId, waiter);
|
||||
}
|
||||
AGENT_WAITERS_BY_RUN_ID.set(normalizedRunId, new Set([waiter]));
|
||||
return () => {
|
||||
const waiters = AGENT_WAITERS_BY_RUN_ID.get(normalizedRunId);
|
||||
if (!waiters) {
|
||||
return;
|
||||
}
|
||||
waiters.delete(waiter);
|
||||
if (waiters.size === 0) {
|
||||
AGENT_WAITERS_BY_RUN_ID.delete(normalizedRunId);
|
||||
}
|
||||
};
|
||||
return () => removeWaiter(normalizedRunId, waiter);
|
||||
}
|
||||
|
||||
function notifyWaiters(runId: string): void {
|
||||
|
||||
@ -10,24 +10,21 @@ import {
|
||||
shouldSkipBackendSelfPairing,
|
||||
} from "./handshake-auth-helpers.js";
|
||||
|
||||
function createRateLimiter(): AuthRateLimiter {
|
||||
return {
|
||||
check: () => ({ allowed: true, remaining: 1, retryAfterMs: 0 }),
|
||||
reset: () => {},
|
||||
recordFailure: () => {},
|
||||
size: () => 0,
|
||||
prune: () => {},
|
||||
dispose: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("handshake auth helpers", () => {
|
||||
it("pins browser-origin loopback clients to the synthetic rate-limit ip", () => {
|
||||
const rateLimiter: AuthRateLimiter = {
|
||||
check: () => ({ allowed: true, remaining: 1, retryAfterMs: 0 }),
|
||||
reset: () => {},
|
||||
recordFailure: () => {},
|
||||
size: () => 0,
|
||||
prune: () => {},
|
||||
dispose: () => {},
|
||||
};
|
||||
const browserRateLimiter: AuthRateLimiter = {
|
||||
check: () => ({ allowed: true, remaining: 1, retryAfterMs: 0 }),
|
||||
reset: () => {},
|
||||
recordFailure: () => {},
|
||||
size: () => 0,
|
||||
prune: () => {},
|
||||
dispose: () => {},
|
||||
};
|
||||
const rateLimiter = createRateLimiter();
|
||||
const browserRateLimiter = createRateLimiter();
|
||||
const resolved = resolveHandshakeBrowserSecurityContext({
|
||||
requestOrigin: "https://app.example",
|
||||
clientIp: "127.0.0.1",
|
||||
|
||||
@ -91,6 +91,23 @@ function resolveSignatureToken(connectParams: ConnectParams): string | null {
|
||||
);
|
||||
}
|
||||
|
||||
function buildUnauthorizedHandshakeContext(params: {
|
||||
authProvided: AuthProvidedKind;
|
||||
canRetryWithDeviceToken: boolean;
|
||||
recommendedNextStep:
|
||||
| "retry_with_device_token"
|
||||
| "update_auth_configuration"
|
||||
| "update_auth_credentials"
|
||||
| "wait_then_retry"
|
||||
| "review_auth_configuration";
|
||||
}) {
|
||||
return {
|
||||
authProvided: params.authProvided,
|
||||
canRetryWithDeviceToken: params.canRetryWithDeviceToken,
|
||||
recommendedNextStep: params.recommendedNextStep,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDeviceSignaturePayloadVersion(params: {
|
||||
device: {
|
||||
id: string;
|
||||
@ -104,7 +121,7 @@ export function resolveDeviceSignaturePayloadVersion(params: {
|
||||
nonce: string;
|
||||
}): "v3" | "v2" | null {
|
||||
const signatureToken = resolveSignatureToken(params.connectParams);
|
||||
const payloadV3 = buildDeviceAuthPayloadV3({
|
||||
const basePayload = {
|
||||
deviceId: params.device.id,
|
||||
clientId: params.connectParams.client.id,
|
||||
clientMode: params.connectParams.client.mode,
|
||||
@ -113,6 +130,9 @@ export function resolveDeviceSignaturePayloadVersion(params: {
|
||||
signedAtMs: params.signedAtMs,
|
||||
token: signatureToken,
|
||||
nonce: params.nonce,
|
||||
};
|
||||
const payloadV3 = buildDeviceAuthPayloadV3({
|
||||
...basePayload,
|
||||
platform: params.connectParams.client.platform,
|
||||
deviceFamily: params.connectParams.client.deviceFamily,
|
||||
});
|
||||
@ -120,16 +140,7 @@ export function resolveDeviceSignaturePayloadVersion(params: {
|
||||
return "v3";
|
||||
}
|
||||
|
||||
const payloadV2 = buildDeviceAuthPayload({
|
||||
deviceId: params.device.id,
|
||||
clientId: params.connectParams.client.id,
|
||||
clientMode: params.connectParams.client.mode,
|
||||
role: params.role,
|
||||
scopes: params.scopes,
|
||||
signedAtMs: params.signedAtMs,
|
||||
token: signatureToken,
|
||||
nonce: params.nonce,
|
||||
});
|
||||
const payloadV2 = buildDeviceAuthPayload(basePayload);
|
||||
if (verifyDeviceSignature(params.device.publicKey, payloadV2, params.device.signature)) {
|
||||
return "v2";
|
||||
}
|
||||
@ -171,41 +182,41 @@ export function resolveUnauthorizedHandshakeContext(params: {
|
||||
authProvided === "token" &&
|
||||
!params.connectAuth?.deviceToken;
|
||||
if (canRetryWithDeviceToken) {
|
||||
return {
|
||||
return buildUnauthorizedHandshakeContext({
|
||||
authProvided,
|
||||
canRetryWithDeviceToken,
|
||||
recommendedNextStep: "retry_with_device_token",
|
||||
};
|
||||
});
|
||||
}
|
||||
switch (params.failedAuth.reason) {
|
||||
case "token_missing":
|
||||
case "token_missing_config":
|
||||
case "password_missing":
|
||||
case "password_missing_config":
|
||||
return {
|
||||
return buildUnauthorizedHandshakeContext({
|
||||
authProvided,
|
||||
canRetryWithDeviceToken,
|
||||
recommendedNextStep: "update_auth_configuration",
|
||||
};
|
||||
});
|
||||
case "token_mismatch":
|
||||
case "password_mismatch":
|
||||
case "device_token_mismatch":
|
||||
return {
|
||||
return buildUnauthorizedHandshakeContext({
|
||||
authProvided,
|
||||
canRetryWithDeviceToken,
|
||||
recommendedNextStep: "update_auth_credentials",
|
||||
};
|
||||
});
|
||||
case "rate_limited":
|
||||
return {
|
||||
return buildUnauthorizedHandshakeContext({
|
||||
authProvided,
|
||||
canRetryWithDeviceToken,
|
||||
recommendedNextStep: "wait_then_retry",
|
||||
};
|
||||
});
|
||||
default:
|
||||
return {
|
||||
return buildUnauthorizedHandshakeContext({
|
||||
authProvided,
|
||||
canRetryWithDeviceToken,
|
||||
recommendedNextStep: "review_auth_configuration",
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,15 @@ import {
|
||||
} from "./heartbeat-wake.js";
|
||||
|
||||
describe("heartbeat-wake", () => {
|
||||
function setRetryOnceHeartbeatHandler() {
|
||||
const handler = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ status: "skipped", reason: "requests-in-flight" })
|
||||
.mockResolvedValueOnce({ status: "ran", durationMs: 1 });
|
||||
setHeartbeatWakeHandler(handler);
|
||||
return handler;
|
||||
}
|
||||
|
||||
async function expectRetryAfterDefaultDelay(params: {
|
||||
handler: ReturnType<typeof vi.fn>;
|
||||
initialReason: string;
|
||||
@ -74,11 +83,7 @@ describe("heartbeat-wake", () => {
|
||||
|
||||
it("keeps retry cooldown even when a sooner request arrives", async () => {
|
||||
vi.useFakeTimers();
|
||||
const handler = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ status: "skipped", reason: "requests-in-flight" })
|
||||
.mockResolvedValueOnce({ status: "ran", durationMs: 1 });
|
||||
setHeartbeatWakeHandler(handler);
|
||||
const handler = setRetryOnceHeartbeatHandler();
|
||||
|
||||
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
@ -252,11 +257,7 @@ describe("heartbeat-wake", () => {
|
||||
|
||||
it("forwards wake target fields and preserves them across retries", async () => {
|
||||
vi.useFakeTimers();
|
||||
const handler = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ status: "skipped", reason: "requests-in-flight" })
|
||||
.mockResolvedValueOnce({ status: "ran", durationMs: 1 });
|
||||
setHeartbeatWakeHandler(handler);
|
||||
const handler = setRetryOnceHeartbeatHandler();
|
||||
|
||||
requestHeartbeatNow({
|
||||
reason: "cron:job-1",
|
||||
|
||||
@ -75,3 +75,25 @@ export function expandHomePrefix(
|
||||
}
|
||||
return input.replace(/^~(?=$|[\\/])/, home);
|
||||
}
|
||||
|
||||
export function resolveHomeRelativePath(
|
||||
input: string,
|
||||
opts?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homedir?: () => string;
|
||||
},
|
||||
): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.startsWith("~")) {
|
||||
const expanded = expandHomePrefix(trimmed, {
|
||||
home: resolveRequiredHomeDir(opts?.env ?? process.env, opts?.homedir ?? os.homedir),
|
||||
env: opts?.env,
|
||||
homedir: opts?.homedir,
|
||||
});
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
@ -80,6 +80,23 @@ export function isDangerousHostEnvOverrideVarName(rawKey: string): boolean {
|
||||
return HOST_DANGEROUS_OVERRIDE_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix));
|
||||
}
|
||||
|
||||
function listNormalizedPortableEnvEntries(
|
||||
source: Record<string, string | undefined>,
|
||||
): Array<[string, string]> {
|
||||
const entries: Array<[string, string]> = [];
|
||||
for (const [rawKey, value] of Object.entries(source)) {
|
||||
if (typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
const key = normalizeEnvVarKey(rawKey, { portable: true });
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
entries.push([key, value]);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function sanitizeHostExecEnv(params?: {
|
||||
baseEnv?: Record<string, string | undefined>;
|
||||
overrides?: Record<string, string> | null;
|
||||
@ -90,12 +107,8 @@ export function sanitizeHostExecEnv(params?: {
|
||||
const blockPathOverrides = params?.blockPathOverrides ?? true;
|
||||
|
||||
const merged: Record<string, string> = {};
|
||||
for (const [rawKey, value] of Object.entries(baseEnv)) {
|
||||
if (typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
const key = normalizeEnvVarKey(rawKey, { portable: true });
|
||||
if (!key || isDangerousHostEnvVarName(key)) {
|
||||
for (const [key, value] of listNormalizedPortableEnvEntries(baseEnv)) {
|
||||
if (isDangerousHostEnvVarName(key)) {
|
||||
continue;
|
||||
}
|
||||
merged[key] = value;
|
||||
@ -105,14 +118,7 @@ export function sanitizeHostExecEnv(params?: {
|
||||
return markOpenClawExecEnv(merged);
|
||||
}
|
||||
|
||||
for (const [rawKey, value] of Object.entries(overrides)) {
|
||||
if (typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
const key = normalizeEnvVarKey(rawKey, { portable: true });
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of listNormalizedPortableEnvEntries(overrides)) {
|
||||
const upper = key.toUpperCase();
|
||||
// PATH is part of the security boundary (command resolution + safe-bin checks). Never allow
|
||||
// request-scoped PATH overrides from agents/gateways.
|
||||
@ -140,14 +146,7 @@ export function sanitizeSystemRunEnvOverrides(params?: {
|
||||
return overrides;
|
||||
}
|
||||
const filtered: Record<string, string> = {};
|
||||
for (const [rawKey, value] of Object.entries(overrides)) {
|
||||
if (typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
const key = normalizeEnvVarKey(rawKey, { portable: true });
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of listNormalizedPortableEnvEntries(overrides)) {
|
||||
if (!HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEYS.has(key.toUpperCase())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -84,6 +84,12 @@ type RequestBodyLimitValues = {
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
type RequestBodyChunkProgress = {
|
||||
buffer: Buffer;
|
||||
totalBytes: number;
|
||||
exceeded: boolean;
|
||||
};
|
||||
|
||||
function resolveRequestBodyLimitValues(options: {
|
||||
maxBytes: number;
|
||||
timeoutMs?: number;
|
||||
@ -98,6 +104,20 @@ function resolveRequestBodyLimitValues(options: {
|
||||
return { maxBytes, timeoutMs };
|
||||
}
|
||||
|
||||
function advanceRequestBodyChunk(
|
||||
chunk: Buffer | string,
|
||||
totalBytes: number,
|
||||
maxBytes: number,
|
||||
): RequestBodyChunkProgress {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
const nextTotalBytes = totalBytes + buffer.length;
|
||||
return {
|
||||
buffer,
|
||||
totalBytes: nextTotalBytes,
|
||||
exceeded: nextTotalBytes > maxBytes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readRequestBodyWithLimit(
|
||||
req: IncomingMessage,
|
||||
options: ReadRequestBodyOptions,
|
||||
@ -155,9 +175,9 @@ export async function readRequestBodyWithLimit(
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
totalBytes += buffer.length;
|
||||
if (totalBytes > maxBytes) {
|
||||
const progress = advanceRequestBodyChunk(chunk, totalBytes, maxBytes);
|
||||
totalBytes = progress.totalBytes;
|
||||
if (progress.exceeded) {
|
||||
const error = new RequestBodyLimitError({ code: "PAYLOAD_TOO_LARGE" });
|
||||
if (!req.destroyed) {
|
||||
req.destroy();
|
||||
@ -165,7 +185,7 @@ export async function readRequestBodyWithLimit(
|
||||
fail(error);
|
||||
return;
|
||||
}
|
||||
chunks.push(buffer);
|
||||
chunks.push(progress.buffer);
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
@ -313,9 +333,9 @@ export function installRequestBodyLimitGuard(
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
totalBytes += buffer.length;
|
||||
if (totalBytes > maxBytes) {
|
||||
const progress = advanceRequestBodyChunk(chunk, totalBytes, maxBytes);
|
||||
totalBytes = progress.totalBytes;
|
||||
if (progress.exceeded) {
|
||||
trip(new RequestBodyLimitError({ code: "PAYLOAD_TOO_LARGE" }));
|
||||
}
|
||||
};
|
||||
|
||||
@ -6,6 +6,19 @@ import * as archive from "./archive.js";
|
||||
import { resolveExistingInstallPath, withExtractedArchiveRoot } from "./install-flow.js";
|
||||
import * as installSource from "./install-source-utils.js";
|
||||
|
||||
async function runExtractedArchiveFailureCase(configureArchive: () => void) {
|
||||
vi.spyOn(installSource, "withTempDir").mockImplementation(
|
||||
async (_prefix, fn) => await fn("/tmp/openclaw-install-flow"),
|
||||
);
|
||||
configureArchive();
|
||||
return await withExtractedArchiveRoot({
|
||||
archivePath: "/tmp/plugin.tgz",
|
||||
tempDirPrefix: "openclaw-plugin-",
|
||||
timeoutMs: 1000,
|
||||
onExtracted: async () => ({ ok: true as const }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveExistingInstallPath", () => {
|
||||
let fixtureRoot = "";
|
||||
|
||||
@ -84,16 +97,8 @@ describe("withExtractedArchiveRoot", () => {
|
||||
});
|
||||
|
||||
it("returns extract failure when extraction throws", async () => {
|
||||
vi.spyOn(installSource, "withTempDir").mockImplementation(
|
||||
async (_prefix, fn) => await fn("/tmp/openclaw-install-flow"),
|
||||
);
|
||||
vi.spyOn(archive, "extractArchive").mockRejectedValue(new Error("boom"));
|
||||
|
||||
const result = await withExtractedArchiveRoot({
|
||||
archivePath: "/tmp/plugin.tgz",
|
||||
tempDirPrefix: "openclaw-plugin-",
|
||||
timeoutMs: 1000,
|
||||
onExtracted: async () => ({ ok: true as const }),
|
||||
const result = await runExtractedArchiveFailureCase(() => {
|
||||
vi.spyOn(archive, "extractArchive").mockRejectedValue(new Error("boom"));
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@ -103,17 +108,9 @@ describe("withExtractedArchiveRoot", () => {
|
||||
});
|
||||
|
||||
it("returns root-resolution failure when archive layout is invalid", async () => {
|
||||
vi.spyOn(installSource, "withTempDir").mockImplementation(
|
||||
async (_prefix, fn) => await fn("/tmp/openclaw-install-flow"),
|
||||
);
|
||||
vi.spyOn(archive, "extractArchive").mockResolvedValue(undefined);
|
||||
vi.spyOn(archive, "resolvePackedRootDir").mockRejectedValue(new Error("invalid layout"));
|
||||
|
||||
const result = await withExtractedArchiveRoot({
|
||||
archivePath: "/tmp/plugin.tgz",
|
||||
tempDirPrefix: "openclaw-plugin-",
|
||||
timeoutMs: 1000,
|
||||
onExtracted: async () => ({ ok: true as const }),
|
||||
const result = await runExtractedArchiveFailureCase(() => {
|
||||
vi.spyOn(archive, "extractArchive").mockResolvedValue(undefined);
|
||||
vi.spyOn(archive, "resolvePackedRootDir").mockRejectedValue(new Error("invalid layout"));
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
|
||||
@ -5,11 +5,14 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const CORE_PACKAGE_NAMES = new Set(["openclaw"]);
|
||||
|
||||
function parsePackageName(raw: string): string | null {
|
||||
const parsed = JSON.parse(raw) as { name?: unknown };
|
||||
return typeof parsed.name === "string" ? parsed.name : null;
|
||||
}
|
||||
|
||||
async function readPackageName(dir: string): Promise<string | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(dir, "package.json"), "utf-8");
|
||||
const parsed = JSON.parse(raw) as { name?: unknown };
|
||||
return typeof parsed.name === "string" ? parsed.name : null;
|
||||
return parsePackageName(await fs.readFile(path.join(dir, "package.json"), "utf-8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -17,9 +20,7 @@ async function readPackageName(dir: string): Promise<string | null> {
|
||||
|
||||
function readPackageNameSync(dir: string): string | null {
|
||||
try {
|
||||
const raw = fsSync.readFileSync(path.join(dir, "package.json"), "utf-8");
|
||||
const parsed = JSON.parse(raw) as { name?: unknown };
|
||||
return typeof parsed.name === "string" ? parsed.name : null;
|
||||
return parsePackageName(fsSync.readFileSync(path.join(dir, "package.json"), "utf-8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -7,6 +7,25 @@ import {
|
||||
formatPluginInstallPathIssue,
|
||||
} from "./plugin-install-path-warnings.js";
|
||||
|
||||
async function detectMatrixCustomPathIssue(sourcePath: string | ((pluginPath: string) => string)) {
|
||||
return withTempHome(async (home) => {
|
||||
const pluginPath = path.join(home, "matrix-plugin");
|
||||
await fs.mkdir(pluginPath, { recursive: true });
|
||||
const resolvedSourcePath =
|
||||
typeof sourcePath === "function" ? sourcePath(pluginPath) : sourcePath;
|
||||
const issue = await detectPluginInstallPathIssue({
|
||||
pluginId: "matrix",
|
||||
install: {
|
||||
source: "path",
|
||||
sourcePath: resolvedSourcePath,
|
||||
installPath: pluginPath,
|
||||
},
|
||||
});
|
||||
|
||||
return { issue, pluginPath };
|
||||
});
|
||||
}
|
||||
|
||||
describe("plugin install path warnings", () => {
|
||||
it("ignores non-path installs and blank path candidates", async () => {
|
||||
expect(
|
||||
@ -57,46 +76,22 @@ describe("plugin install path warnings", () => {
|
||||
});
|
||||
|
||||
it("uses the second candidate path when the first one is stale", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const pluginPath = path.join(home, "matrix-plugin");
|
||||
await fs.mkdir(pluginPath, { recursive: true });
|
||||
|
||||
const issue = await detectPluginInstallPathIssue({
|
||||
pluginId: "matrix",
|
||||
install: {
|
||||
source: "path",
|
||||
sourcePath: "/tmp/openclaw-matrix-missing",
|
||||
installPath: pluginPath,
|
||||
},
|
||||
});
|
||||
|
||||
expect(issue).toEqual({
|
||||
kind: "custom-path",
|
||||
pluginId: "matrix",
|
||||
path: pluginPath,
|
||||
});
|
||||
const { issue, pluginPath } = await detectMatrixCustomPathIssue("/tmp/openclaw-matrix-missing");
|
||||
expect(issue).toEqual({
|
||||
kind: "custom-path",
|
||||
pluginId: "matrix",
|
||||
path: pluginPath,
|
||||
});
|
||||
});
|
||||
|
||||
it("detects active custom plugin install paths", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const pluginPath = path.join(home, "matrix-plugin");
|
||||
await fs.mkdir(pluginPath, { recursive: true });
|
||||
|
||||
const issue = await detectPluginInstallPathIssue({
|
||||
pluginId: "matrix",
|
||||
install: {
|
||||
source: "path",
|
||||
sourcePath: pluginPath,
|
||||
installPath: pluginPath,
|
||||
},
|
||||
});
|
||||
|
||||
expect(issue).toEqual({
|
||||
kind: "custom-path",
|
||||
pluginId: "matrix",
|
||||
path: pluginPath,
|
||||
});
|
||||
const { issue, pluginPath } = await detectMatrixCustomPathIssue(
|
||||
(resolvedPluginPath) => resolvedPluginPath,
|
||||
);
|
||||
expect(issue).toEqual({
|
||||
kind: "custom-path",
|
||||
pluginId: "matrix",
|
||||
path: pluginPath,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -53,7 +53,10 @@ function clearSupervisorHints() {
|
||||
}
|
||||
}
|
||||
|
||||
function expectLaunchdSupervisedWithoutKickstart(params?: { launchJobLabel?: string }) {
|
||||
function expectLaunchdSupervisedWithoutKickstart(params?: {
|
||||
launchJobLabel?: string;
|
||||
detailContains?: string;
|
||||
}) {
|
||||
setPlatform("darwin");
|
||||
if (params?.launchJobLabel) {
|
||||
process.env.LAUNCH_JOB_LABEL = params.launchJobLabel;
|
||||
@ -61,6 +64,9 @@ function expectLaunchdSupervisedWithoutKickstart(params?: { launchJobLabel?: str
|
||||
process.env.OPENCLAW_LAUNCHD_LABEL = "ai.openclaw.gateway";
|
||||
const result = restartGatewayProcessWithFreshPid();
|
||||
expect(result.mode).toBe("supervised");
|
||||
if (params?.detailContains) {
|
||||
expect(result.detail).toContain(params.detailContains);
|
||||
}
|
||||
expect(scheduleDetachedLaunchdRestartHandoffMock).toHaveBeenCalledWith({
|
||||
env: process.env,
|
||||
mode: "start-after-exit",
|
||||
@ -80,18 +86,10 @@ describe("restartGatewayProcessWithFreshPid", () => {
|
||||
|
||||
it("returns supervised when launchd hints are present on macOS (no kickstart)", () => {
|
||||
clearSupervisorHints();
|
||||
setPlatform("darwin");
|
||||
process.env.LAUNCH_JOB_LABEL = "ai.openclaw.gateway";
|
||||
const result = restartGatewayProcessWithFreshPid();
|
||||
expect(result.mode).toBe("supervised");
|
||||
expect(result.detail).toContain("launchd restart handoff");
|
||||
expect(scheduleDetachedLaunchdRestartHandoffMock).toHaveBeenCalledWith({
|
||||
env: process.env,
|
||||
mode: "start-after-exit",
|
||||
waitForPid: process.pid,
|
||||
expectLaunchdSupervisedWithoutKickstart({
|
||||
launchJobLabel: "ai.openclaw.gateway",
|
||||
detailContains: "launchd restart handoff",
|
||||
});
|
||||
expect(triggerOpenClawRestartMock).not.toHaveBeenCalled();
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns supervised on macOS when launchd label is set (no kickstart)", () => {
|
||||
|
||||
@ -49,6 +49,29 @@ async function makeTempDir(): Promise<string> {
|
||||
return dir;
|
||||
}
|
||||
|
||||
function createDirectApnsSendFixture(params: {
|
||||
nodeId: string;
|
||||
environment: "sandbox" | "production";
|
||||
sendResult: { status: number; apnsId: string; body: string };
|
||||
}) {
|
||||
return {
|
||||
send: vi.fn().mockResolvedValue(params.sendResult),
|
||||
registration: {
|
||||
nodeId: params.nodeId,
|
||||
transport: "direct" as const,
|
||||
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: params.environment,
|
||||
updatedAtMs: 1,
|
||||
},
|
||||
auth: {
|
||||
teamId: "TEAM123",
|
||||
keyId: "KEY123",
|
||||
privateKey: testAuthPrivateKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
while (tempDirs.length > 0) {
|
||||
@ -447,29 +470,22 @@ describe("push APNs env config", () => {
|
||||
|
||||
describe("push APNs send semantics", () => {
|
||||
it("sends alert pushes with alert headers and payload", async () => {
|
||||
const send = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
apnsId: "apns-alert-id",
|
||||
body: "",
|
||||
const { send, registration, auth } = createDirectApnsSendFixture({
|
||||
nodeId: "ios-node-alert",
|
||||
environment: "sandbox",
|
||||
sendResult: {
|
||||
status: 200,
|
||||
apnsId: "apns-alert-id",
|
||||
body: "",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await sendApnsAlert({
|
||||
registration: {
|
||||
nodeId: "ios-node-alert",
|
||||
transport: "direct",
|
||||
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
updatedAtMs: 1,
|
||||
},
|
||||
registration,
|
||||
nodeId: "ios-node-alert",
|
||||
title: "Wake",
|
||||
body: "Ping",
|
||||
auth: {
|
||||
teamId: "TEAM123",
|
||||
keyId: "KEY123",
|
||||
privateKey: testAuthPrivateKey,
|
||||
},
|
||||
auth,
|
||||
requestSender: send,
|
||||
});
|
||||
|
||||
@ -493,28 +509,21 @@ describe("push APNs send semantics", () => {
|
||||
});
|
||||
|
||||
it("sends background wake pushes with silent payload semantics", async () => {
|
||||
const send = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
apnsId: "apns-wake-id",
|
||||
body: "",
|
||||
const { send, registration, auth } = createDirectApnsSendFixture({
|
||||
nodeId: "ios-node-wake",
|
||||
environment: "production",
|
||||
sendResult: {
|
||||
status: 200,
|
||||
apnsId: "apns-wake-id",
|
||||
body: "",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await sendApnsBackgroundWake({
|
||||
registration: {
|
||||
nodeId: "ios-node-wake",
|
||||
transport: "direct",
|
||||
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
updatedAtMs: 1,
|
||||
},
|
||||
registration,
|
||||
nodeId: "ios-node-wake",
|
||||
wakeReason: "node.invoke",
|
||||
auth: {
|
||||
teamId: "TEAM123",
|
||||
keyId: "KEY123",
|
||||
privateKey: testAuthPrivateKey,
|
||||
},
|
||||
auth,
|
||||
requestSender: send,
|
||||
});
|
||||
|
||||
@ -542,27 +551,20 @@ describe("push APNs send semantics", () => {
|
||||
});
|
||||
|
||||
it("defaults background wake reason when not provided", async () => {
|
||||
const send = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
apnsId: "apns-wake-default-reason-id",
|
||||
body: "",
|
||||
const { send, registration, auth } = createDirectApnsSendFixture({
|
||||
nodeId: "ios-node-wake-default-reason",
|
||||
environment: "sandbox",
|
||||
sendResult: {
|
||||
status: 200,
|
||||
apnsId: "apns-wake-default-reason-id",
|
||||
body: "",
|
||||
},
|
||||
});
|
||||
|
||||
await sendApnsBackgroundWake({
|
||||
registration: {
|
||||
nodeId: "ios-node-wake-default-reason",
|
||||
transport: "direct",
|
||||
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
updatedAtMs: 1,
|
||||
},
|
||||
registration,
|
||||
nodeId: "ios-node-wake-default-reason",
|
||||
auth: {
|
||||
teamId: "TEAM123",
|
||||
keyId: "KEY123",
|
||||
privateKey: testAuthPrivateKey,
|
||||
},
|
||||
auth,
|
||||
requestSender: send,
|
||||
});
|
||||
|
||||
|
||||
@ -187,6 +187,21 @@ describe("runGatewayUpdate", () => {
|
||||
);
|
||||
}
|
||||
|
||||
async function writeGlobalPackageVersion(pkgRoot: string, version = "2.0.0") {
|
||||
await fs.writeFile(
|
||||
path.join(pkgRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version }),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
async function createGlobalPackageFixture(rootDir: string) {
|
||||
const nodeModules = path.join(rootDir, "node_modules");
|
||||
const pkgRoot = path.join(nodeModules, "openclaw");
|
||||
await seedGlobalPackageRoot(pkgRoot);
|
||||
return { nodeModules, pkgRoot };
|
||||
}
|
||||
|
||||
function createGlobalNpmUpdateRunner(params: {
|
||||
pkgRoot: string;
|
||||
nodeModules: string;
|
||||
@ -366,13 +381,17 @@ describe("runGatewayUpdate", () => {
|
||||
pkgRoot: string;
|
||||
npmRootOutput?: string;
|
||||
installCommand: string;
|
||||
onInstall?: () => Promise<void>;
|
||||
gitRootMode?: "not-git" | "missing";
|
||||
onInstall?: (options?: { env?: NodeJS.ProcessEnv }) => Promise<void>;
|
||||
}) => {
|
||||
const calls: string[] = [];
|
||||
const runCommand = async (argv: string[]) => {
|
||||
const runCommand = async (argv: string[], options?: { env?: NodeJS.ProcessEnv }) => {
|
||||
const key = argv.join(" ");
|
||||
calls.push(key);
|
||||
if (key === `git -C ${params.pkgRoot} rev-parse --show-toplevel`) {
|
||||
if (params.gitRootMode === "missing") {
|
||||
throw Object.assign(new Error("spawn git ENOENT"), { code: "ENOENT" });
|
||||
}
|
||||
return { stdout: "", stderr: "not a git repository", code: 128 };
|
||||
}
|
||||
if (key === "npm root -g") {
|
||||
@ -385,7 +404,7 @@ describe("runGatewayUpdate", () => {
|
||||
return { stdout: "", stderr: "", code: 1 };
|
||||
}
|
||||
if (key === params.installCommand) {
|
||||
await params.onInstall?.();
|
||||
await params.onInstall?.(options);
|
||||
return { stdout: "ok", stderr: "", code: 0 };
|
||||
}
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
@ -423,32 +442,14 @@ describe("runGatewayUpdate", () => {
|
||||
});
|
||||
|
||||
it("falls back to global npm update when git is missing from PATH", async () => {
|
||||
const nodeModules = path.join(tempDir, "node_modules");
|
||||
const pkgRoot = path.join(nodeModules, "openclaw");
|
||||
await seedGlobalPackageRoot(pkgRoot);
|
||||
|
||||
const calls: string[] = [];
|
||||
const runCommand = async (argv: string[]): Promise<CommandResult> => {
|
||||
const key = argv.join(" ");
|
||||
calls.push(key);
|
||||
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) {
|
||||
throw Object.assign(new Error("spawn git ENOENT"), { code: "ENOENT" });
|
||||
}
|
||||
if (key === "npm root -g") {
|
||||
return { stdout: nodeModules, stderr: "", code: 0 };
|
||||
}
|
||||
if (key === "pnpm root -g") {
|
||||
return { stdout: "", stderr: "", code: 1 };
|
||||
}
|
||||
if (key === "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error") {
|
||||
await fs.writeFile(
|
||||
path.join(pkgRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version: "2.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
return { stdout: "ok", stderr: "", code: 0 };
|
||||
};
|
||||
const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir);
|
||||
const { calls, runCommand } = createGlobalInstallHarness({
|
||||
pkgRoot,
|
||||
npmRootOutput: nodeModules,
|
||||
installCommand: "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error",
|
||||
gitRootMode: "missing",
|
||||
onInstall: async () => writeGlobalPackageVersion(pkgRoot),
|
||||
});
|
||||
|
||||
const result = await runWithCommand(runCommand, { cwd: pkgRoot });
|
||||
|
||||
@ -537,35 +538,17 @@ describe("runGatewayUpdate", () => {
|
||||
await fs.mkdir(portableGitMingw, { recursive: true });
|
||||
await fs.mkdir(portableGitUsr, { recursive: true });
|
||||
|
||||
const nodeModules = path.join(tempDir, "node_modules");
|
||||
const pkgRoot = path.join(nodeModules, "openclaw");
|
||||
await seedGlobalPackageRoot(pkgRoot);
|
||||
|
||||
let installEnv: NodeJS.ProcessEnv | undefined;
|
||||
const runCommand = async (
|
||||
argv: string[],
|
||||
options?: { env?: NodeJS.ProcessEnv },
|
||||
): Promise<CommandResult> => {
|
||||
const key = argv.join(" ");
|
||||
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) {
|
||||
return { stdout: "", stderr: "not a git repository", code: 128 };
|
||||
}
|
||||
if (key === "npm root -g") {
|
||||
return { stdout: nodeModules, stderr: "", code: 0 };
|
||||
}
|
||||
if (key === "pnpm root -g") {
|
||||
return { stdout: "", stderr: "", code: 1 };
|
||||
}
|
||||
if (key === "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error") {
|
||||
const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir);
|
||||
const { runCommand } = createGlobalInstallHarness({
|
||||
pkgRoot,
|
||||
npmRootOutput: nodeModules,
|
||||
installCommand: "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error",
|
||||
onInstall: async (options) => {
|
||||
installEnv = options?.env;
|
||||
await fs.writeFile(
|
||||
path.join(pkgRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version: "2.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
return { stdout: "ok", stderr: "", code: 0 };
|
||||
};
|
||||
await writeGlobalPackageVersion(pkgRoot);
|
||||
},
|
||||
});
|
||||
|
||||
await withEnvAsync({ LOCALAPPDATA: localAppData }, async () => {
|
||||
const result = await runWithCommand(runCommand, { cwd: pkgRoot });
|
||||
@ -584,35 +567,15 @@ describe("runGatewayUpdate", () => {
|
||||
});
|
||||
|
||||
it("uses OPENCLAW_UPDATE_PACKAGE_SPEC for global package updates", async () => {
|
||||
const nodeModules = path.join(tempDir, "node_modules");
|
||||
const pkgRoot = path.join(nodeModules, "openclaw");
|
||||
await seedGlobalPackageRoot(pkgRoot);
|
||||
|
||||
const calls: string[] = [];
|
||||
const runCommand = async (argv: string[]): Promise<CommandResult> => {
|
||||
const key = argv.join(" ");
|
||||
calls.push(key);
|
||||
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) {
|
||||
return { stdout: "", stderr: "not a git repository", code: 128 };
|
||||
}
|
||||
if (key === "npm root -g") {
|
||||
return { stdout: nodeModules, stderr: "", code: 0 };
|
||||
}
|
||||
if (key === "pnpm root -g") {
|
||||
return { stdout: "", stderr: "", code: 1 };
|
||||
}
|
||||
if (
|
||||
key ===
|
||||
"npm i -g http://10.211.55.2:8138/openclaw-next.tgz --no-fund --no-audit --loglevel=error"
|
||||
) {
|
||||
await fs.writeFile(
|
||||
path.join(pkgRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version: "2.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
return { stdout: "ok", stderr: "", code: 0 };
|
||||
};
|
||||
const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir);
|
||||
const expectedInstallCommand =
|
||||
"npm i -g http://10.211.55.2:8138/openclaw-next.tgz --no-fund --no-audit --loglevel=error";
|
||||
const { calls, runCommand } = createGlobalInstallHarness({
|
||||
pkgRoot,
|
||||
npmRootOutput: nodeModules,
|
||||
installCommand: expectedInstallCommand,
|
||||
onInstall: async () => writeGlobalPackageVersion(pkgRoot),
|
||||
});
|
||||
|
||||
await withEnvAsync(
|
||||
{ OPENCLAW_UPDATE_PACKAGE_SPEC: "http://10.211.55.2:8138/openclaw-next.tgz" },
|
||||
@ -622,27 +585,21 @@ describe("runGatewayUpdate", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(calls).toContain(
|
||||
"npm i -g http://10.211.55.2:8138/openclaw-next.tgz --no-fund --no-audit --loglevel=error",
|
||||
);
|
||||
expect(calls).toContain(expectedInstallCommand);
|
||||
});
|
||||
|
||||
it("updates global bun installs when detected", async () => {
|
||||
const bunInstall = path.join(tempDir, "bun-install");
|
||||
await withEnvAsync({ BUN_INSTALL: bunInstall }, async () => {
|
||||
const bunGlobalRoot = path.join(bunInstall, "install", "global", "node_modules");
|
||||
const pkgRoot = path.join(bunGlobalRoot, "openclaw");
|
||||
await seedGlobalPackageRoot(pkgRoot);
|
||||
const { pkgRoot } = await createGlobalPackageFixture(
|
||||
path.join(bunInstall, "install", "global"),
|
||||
);
|
||||
|
||||
const { calls, runCommand } = createGlobalInstallHarness({
|
||||
pkgRoot,
|
||||
installCommand: "bun add -g openclaw@latest",
|
||||
onInstall: async () => {
|
||||
await fs.writeFile(
|
||||
path.join(pkgRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version: "2.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
await writeGlobalPackageVersion(pkgRoot);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -29,48 +29,67 @@ function createManagerStatus(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function createManagerMock(params: {
|
||||
backend: "qmd" | "builtin";
|
||||
provider: string;
|
||||
model: string;
|
||||
requestedProvider: string;
|
||||
searchResults?: Array<{
|
||||
path: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
score: number;
|
||||
snippet: string;
|
||||
source: "memory";
|
||||
}>;
|
||||
withMemorySourceCounts?: boolean;
|
||||
}) {
|
||||
return {
|
||||
search: vi.fn(async () => params.searchResults ?? []),
|
||||
readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })),
|
||||
status: vi.fn(() =>
|
||||
createManagerStatus({
|
||||
backend: params.backend,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
requestedProvider: params.requestedProvider,
|
||||
withMemorySourceCounts: params.withMemorySourceCounts,
|
||||
}),
|
||||
),
|
||||
sync: vi.fn(async () => {}),
|
||||
probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })),
|
||||
probeVectorAvailability: vi.fn(async () => true),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
const mockPrimary = vi.hoisted(() => ({
|
||||
search: vi.fn(async () => []),
|
||||
readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })),
|
||||
status: vi.fn(() =>
|
||||
createManagerStatus({
|
||||
backend: "qmd",
|
||||
provider: "qmd",
|
||||
model: "qmd",
|
||||
requestedProvider: "qmd",
|
||||
withMemorySourceCounts: true,
|
||||
}),
|
||||
),
|
||||
sync: vi.fn(async () => {}),
|
||||
probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })),
|
||||
probeVectorAvailability: vi.fn(async () => true),
|
||||
close: vi.fn(async () => {}),
|
||||
...createManagerMock({
|
||||
backend: "qmd",
|
||||
provider: "qmd",
|
||||
model: "qmd",
|
||||
requestedProvider: "qmd",
|
||||
withMemorySourceCounts: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
const fallbackManager = vi.hoisted(() => ({
|
||||
search: vi.fn(async () => [
|
||||
{
|
||||
path: "MEMORY.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 1,
|
||||
snippet: "fallback",
|
||||
source: "memory" as const,
|
||||
},
|
||||
]),
|
||||
readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })),
|
||||
status: vi.fn(() =>
|
||||
createManagerStatus({
|
||||
backend: "builtin",
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
requestedProvider: "openai",
|
||||
}),
|
||||
),
|
||||
sync: vi.fn(async () => {}),
|
||||
probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })),
|
||||
probeVectorAvailability: vi.fn(async () => true),
|
||||
close: vi.fn(async () => {}),
|
||||
...createManagerMock({
|
||||
backend: "builtin",
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
requestedProvider: "openai",
|
||||
searchResults: [
|
||||
{
|
||||
path: "MEMORY.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 1,
|
||||
snippet: "fallback",
|
||||
source: "memory",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
const fallbackSearch = fallbackManager.search;
|
||||
|
||||
@ -20,6 +20,37 @@ async function makeTempDir(): Promise<string> {
|
||||
return dir;
|
||||
}
|
||||
|
||||
function createVectorMemoryEntry(params: {
|
||||
id: string;
|
||||
path: string;
|
||||
snippet: string;
|
||||
vectorScore: number;
|
||||
}) {
|
||||
return {
|
||||
id: params.id,
|
||||
path: params.path,
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
source: "memory" as const,
|
||||
snippet: params.snippet,
|
||||
vectorScore: params.vectorScore,
|
||||
};
|
||||
}
|
||||
|
||||
async function mergeVectorResultsWithTemporalDecay(
|
||||
vector: Parameters<typeof mergeHybridResults>[0]["vector"],
|
||||
) {
|
||||
return mergeHybridResults({
|
||||
vectorWeight: 1,
|
||||
textWeight: 0,
|
||||
temporalDecay: { enabled: true, halfLifeDays: 30 },
|
||||
mmr: { enabled: false },
|
||||
nowMs: NOW_MS,
|
||||
vector,
|
||||
keyword: [],
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.splice(0).map(async (dir) => {
|
||||
@ -75,77 +106,46 @@ describe("temporal decay", () => {
|
||||
});
|
||||
|
||||
it("applies decay in hybrid merging before ranking", async () => {
|
||||
const merged = await mergeHybridResults({
|
||||
vectorWeight: 1,
|
||||
textWeight: 0,
|
||||
temporalDecay: { enabled: true, halfLifeDays: 30 },
|
||||
mmr: { enabled: false },
|
||||
nowMs: NOW_MS,
|
||||
vector: [
|
||||
{
|
||||
id: "old",
|
||||
path: "memory/2025-01-01.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
source: "memory",
|
||||
snippet: "old but high",
|
||||
vectorScore: 0.95,
|
||||
},
|
||||
{
|
||||
id: "new",
|
||||
path: "memory/2026-02-10.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
source: "memory",
|
||||
snippet: "new and relevant",
|
||||
vectorScore: 0.8,
|
||||
},
|
||||
],
|
||||
keyword: [],
|
||||
});
|
||||
const merged = await mergeVectorResultsWithTemporalDecay([
|
||||
createVectorMemoryEntry({
|
||||
id: "old",
|
||||
path: "memory/2025-01-01.md",
|
||||
snippet: "old but high",
|
||||
vectorScore: 0.95,
|
||||
}),
|
||||
createVectorMemoryEntry({
|
||||
id: "new",
|
||||
path: "memory/2026-02-10.md",
|
||||
snippet: "new and relevant",
|
||||
vectorScore: 0.8,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(merged[0]?.path).toBe("memory/2026-02-10.md");
|
||||
expect(merged[0]?.score ?? 0).toBeGreaterThan(merged[1]?.score ?? 0);
|
||||
});
|
||||
|
||||
it("handles future dates, zero age, and very old memories", async () => {
|
||||
const merged = await mergeHybridResults({
|
||||
vectorWeight: 1,
|
||||
textWeight: 0,
|
||||
temporalDecay: { enabled: true, halfLifeDays: 30 },
|
||||
mmr: { enabled: false },
|
||||
nowMs: NOW_MS,
|
||||
vector: [
|
||||
{
|
||||
id: "future",
|
||||
path: "memory/2099-01-01.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
source: "memory",
|
||||
snippet: "future",
|
||||
vectorScore: 0.9,
|
||||
},
|
||||
{
|
||||
id: "today",
|
||||
path: "memory/2026-02-10.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
source: "memory",
|
||||
snippet: "today",
|
||||
vectorScore: 0.8,
|
||||
},
|
||||
{
|
||||
id: "very-old",
|
||||
path: "memory/2000-01-01.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
source: "memory",
|
||||
snippet: "ancient",
|
||||
vectorScore: 1,
|
||||
},
|
||||
],
|
||||
keyword: [],
|
||||
});
|
||||
const merged = await mergeVectorResultsWithTemporalDecay([
|
||||
createVectorMemoryEntry({
|
||||
id: "future",
|
||||
path: "memory/2099-01-01.md",
|
||||
snippet: "future",
|
||||
vectorScore: 0.9,
|
||||
}),
|
||||
createVectorMemoryEntry({
|
||||
id: "today",
|
||||
path: "memory/2026-02-10.md",
|
||||
snippet: "today",
|
||||
vectorScore: 0.8,
|
||||
}),
|
||||
createVectorMemoryEntry({
|
||||
id: "very-old",
|
||||
path: "memory/2000-01-01.md",
|
||||
snippet: "ancient",
|
||||
vectorScore: 1,
|
||||
}),
|
||||
]);
|
||||
|
||||
const byPath = new Map(merged.map((entry) => [entry.path, entry]));
|
||||
expect(byPath.get("memory/2099-01-01.md")?.score).toBeCloseTo(0.9);
|
||||
|
||||
@ -182,6 +182,25 @@ async function sendSystemRunDenied(
|
||||
});
|
||||
}
|
||||
|
||||
async function sendSystemRunCompleted(
|
||||
opts: Pick<HandleSystemRunInvokeOptions, "sendExecFinishedEvent" | "sendInvokeResult">,
|
||||
execution: SystemRunExecutionContext,
|
||||
result: ExecFinishedResult,
|
||||
payloadJSON: string,
|
||||
) {
|
||||
await opts.sendExecFinishedEvent({
|
||||
sessionKey: execution.sessionKey,
|
||||
runId: execution.runId,
|
||||
commandText: execution.commandText,
|
||||
result,
|
||||
suppressNotifyOnExit: execution.suppressNotifyOnExit,
|
||||
});
|
||||
await opts.sendInvokeResult({
|
||||
ok: true,
|
||||
payloadJSON,
|
||||
});
|
||||
}
|
||||
|
||||
export { formatSystemRunAllowlistMissMessage } from "./exec-policy.js";
|
||||
export { buildSystemRunApprovalPlan } from "./invoke-system-run-plan.js";
|
||||
|
||||
@ -462,17 +481,7 @@ async function executeSystemRunPhase(
|
||||
return;
|
||||
} else {
|
||||
const result: ExecHostRunResult = response.payload;
|
||||
await opts.sendExecFinishedEvent({
|
||||
sessionKey: phase.sessionKey,
|
||||
runId: phase.runId,
|
||||
commandText: phase.commandText,
|
||||
result,
|
||||
suppressNotifyOnExit: phase.suppressNotifyOnExit,
|
||||
});
|
||||
await opts.sendInvokeResult({
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify(result),
|
||||
});
|
||||
await sendSystemRunCompleted(opts, phase.execution, result, JSON.stringify(result));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -530,17 +539,11 @@ async function executeSystemRunPhase(
|
||||
|
||||
const result = await opts.runCommand(execArgv, phase.cwd, phase.env, phase.timeoutMs);
|
||||
applyOutputTruncation(result);
|
||||
await opts.sendExecFinishedEvent({
|
||||
sessionKey: phase.sessionKey,
|
||||
runId: phase.runId,
|
||||
commandText: phase.commandText,
|
||||
await sendSystemRunCompleted(
|
||||
opts,
|
||||
phase.execution,
|
||||
result,
|
||||
suppressNotifyOnExit: phase.suppressNotifyOnExit,
|
||||
});
|
||||
|
||||
await opts.sendInvokeResult({
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({
|
||||
JSON.stringify({
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
success: result.success,
|
||||
@ -548,7 +551,7 @@ async function executeSystemRunPhase(
|
||||
stderr: result.stderr,
|
||||
error: result.error ?? null,
|
||||
}),
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleSystemRunInvoke(opts: HandleSystemRunInvokeOptions): Promise<void> {
|
||||
|
||||
@ -19,6 +19,17 @@ function createRemoteGatewayTokenRefConfig(tokenId: string): OpenClawConfig {
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
async function expectNoGatewayCredentials(
|
||||
config: OpenClawConfig,
|
||||
env: Record<string, string | undefined>,
|
||||
) {
|
||||
await withEnvAsync(env, async () => {
|
||||
const credentials = await resolveNodeHostGatewayCredentials({ config });
|
||||
expect(credentials.token).toBeUndefined();
|
||||
expect(credentials.password).toBeUndefined();
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveNodeHostGatewayCredentials", () => {
|
||||
it("does not inherit gateway.remote token in local mode", async () => {
|
||||
const config = {
|
||||
@ -28,17 +39,10 @@ describe("resolveNodeHostGatewayCredentials", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
await withEnvAsync(
|
||||
{
|
||||
OPENCLAW_GATEWAY_TOKEN: undefined,
|
||||
OPENCLAW_GATEWAY_PASSWORD: undefined,
|
||||
},
|
||||
async () => {
|
||||
const credentials = await resolveNodeHostGatewayCredentials({ config });
|
||||
expect(credentials.token).toBeUndefined();
|
||||
expect(credentials.password).toBeUndefined();
|
||||
},
|
||||
);
|
||||
await expectNoGatewayCredentials(config, {
|
||||
OPENCLAW_GATEWAY_TOKEN: undefined,
|
||||
OPENCLAW_GATEWAY_PASSWORD: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores unresolved gateway.remote token refs in local mode", async () => {
|
||||
@ -56,18 +60,11 @@ describe("resolveNodeHostGatewayCredentials", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
await withEnvAsync(
|
||||
{
|
||||
OPENCLAW_GATEWAY_TOKEN: undefined,
|
||||
OPENCLAW_GATEWAY_PASSWORD: undefined,
|
||||
MISSING_REMOTE_GATEWAY_TOKEN: undefined,
|
||||
},
|
||||
async () => {
|
||||
const credentials = await resolveNodeHostGatewayCredentials({ config });
|
||||
expect(credentials.token).toBeUndefined();
|
||||
expect(credentials.password).toBeUndefined();
|
||||
},
|
||||
);
|
||||
await expectNoGatewayCredentials(config, {
|
||||
OPENCLAW_GATEWAY_TOKEN: undefined,
|
||||
OPENCLAW_GATEWAY_PASSWORD: undefined,
|
||||
MISSING_REMOTE_GATEWAY_TOKEN: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves remote token SecretRef values", async () => {
|
||||
|
||||
17
src/param-key.ts
Normal file
17
src/param-key.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export function toSnakeCaseKey(key: string): string {
|
||||
return key
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function readSnakeCaseParamRaw(params: Record<string, unknown>, key: string): unknown {
|
||||
if (Object.hasOwn(params, key)) {
|
||||
return params[key];
|
||||
}
|
||||
const snakeKey = toSnakeCaseKey(key);
|
||||
if (snakeKey !== key && Object.hasOwn(params, snakeKey)) {
|
||||
return params[snakeKey];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
import { readSnakeCaseParamRaw } from "./param-key.js";
|
||||
|
||||
export type PollCreationParamKind = "string" | "stringArray" | "number" | "boolean";
|
||||
|
||||
export type PollCreationParamDef = {
|
||||
@ -19,22 +21,8 @@ export type PollCreationParamName = keyof typeof POLL_CREATION_PARAM_DEFS;
|
||||
|
||||
export const POLL_CREATION_PARAM_NAMES = Object.keys(POLL_CREATION_PARAM_DEFS);
|
||||
|
||||
function toSnakeCaseKey(key: string): string {
|
||||
return key
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function readPollParamRaw(params: Record<string, unknown>, key: string): unknown {
|
||||
if (Object.hasOwn(params, key)) {
|
||||
return params[key];
|
||||
}
|
||||
const snakeKey = toSnakeCaseKey(key);
|
||||
if (snakeKey !== key && Object.hasOwn(params, snakeKey)) {
|
||||
return params[snakeKey];
|
||||
}
|
||||
return undefined;
|
||||
return readSnakeCaseParamRaw(params, key);
|
||||
}
|
||||
|
||||
export function resolveTelegramPollVisibility(params: {
|
||||
|
||||
@ -5,6 +5,7 @@ describe("shared/assistant-identity-values", () => {
|
||||
it("returns undefined for missing or blank values", () => {
|
||||
expect(coerceIdentityValue(undefined, 10)).toBeUndefined();
|
||||
expect(coerceIdentityValue(" ", 10)).toBeUndefined();
|
||||
expect(coerceIdentityValue(42 as unknown as string, 10)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("trims values and preserves strings within the limit", () => {
|
||||
@ -14,4 +15,8 @@ describe("shared/assistant-identity-values", () => {
|
||||
it("truncates overlong trimmed values at the exact limit", () => {
|
||||
expect(coerceIdentityValue(" OpenClaw Assistant ", 8)).toBe("OpenClaw");
|
||||
});
|
||||
|
||||
it("returns an empty string when truncating to a zero-length limit", () => {
|
||||
expect(coerceIdentityValue(" OpenClaw ", 0)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,6 +12,7 @@ describe("shared/chat-content", () => {
|
||||
{ type: "text", text: " hello " },
|
||||
{ type: "image_url", image_url: "https://example.com" },
|
||||
{ type: "text", text: "world" },
|
||||
{ text: "ignored without type" },
|
||||
null,
|
||||
]),
|
||||
).toBe("hello world");
|
||||
@ -37,6 +38,18 @@ describe("shared/chat-content", () => {
|
||||
},
|
||||
),
|
||||
).toBe("hello\nworld");
|
||||
|
||||
expect(
|
||||
extractTextFromChatContent(
|
||||
[
|
||||
{ type: "text", text: "keep" },
|
||||
{ type: "text", text: "drop" },
|
||||
],
|
||||
{
|
||||
sanitizeText: (text) => (text === "drop" ? " " : text),
|
||||
},
|
||||
),
|
||||
).toBe("keep");
|
||||
});
|
||||
|
||||
it("returns null for unsupported or empty content", () => {
|
||||
|
||||
@ -4,18 +4,23 @@ import { stripEnvelope, stripMessageIdHints } from "./chat-envelope.js";
|
||||
describe("shared/chat-envelope", () => {
|
||||
it("strips recognized channel and timestamp envelope prefixes only", () => {
|
||||
expect(stripEnvelope("[WhatsApp 2026-01-24 13:36] hello")).toBe("hello");
|
||||
expect(stripEnvelope("[Google Chat room] hello")).toBe("hello");
|
||||
expect(stripEnvelope("[2026-01-24T13:36Z] hello")).toBe("hello");
|
||||
expect(stripEnvelope("[2026-01-24 13:36] hello")).toBe("hello");
|
||||
expect(stripEnvelope("[Custom Sender] hello")).toBe("[Custom Sender] hello");
|
||||
});
|
||||
|
||||
it("keeps non-envelope headers and preserves unmatched text", () => {
|
||||
expect(stripEnvelope("hello")).toBe("hello");
|
||||
expect(stripEnvelope("[note] hello")).toBe("[note] hello");
|
||||
expect(stripEnvelope("[2026/01/24 13:36] hello")).toBe("[2026/01/24 13:36] hello");
|
||||
});
|
||||
|
||||
it("removes standalone message id hint lines but keeps inline mentions", () => {
|
||||
expect(stripMessageIdHints("hello\n[message_id: abc123]")).toBe("hello");
|
||||
expect(stripMessageIdHints("hello\n [message_id: abc123] \nworld")).toBe("hello\nworld");
|
||||
expect(stripMessageIdHints("[message_id: abc123]\nhello")).toBe("hello");
|
||||
expect(stripMessageIdHints("[message_id: abc123]")).toBe("");
|
||||
expect(stripMessageIdHints("I typed [message_id: abc123] inline")).toBe(
|
||||
"I typed [message_id: abc123] inline",
|
||||
);
|
||||
|
||||
@ -10,10 +10,19 @@ describe("shared/chat-message-content", () => {
|
||||
).toBe("hello");
|
||||
});
|
||||
|
||||
it("preserves empty-string text in the first block", () => {
|
||||
expect(
|
||||
extractFirstTextBlock({
|
||||
content: [{ text: "" }, { text: "later" }],
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("returns undefined for missing, empty, or non-text content", () => {
|
||||
expect(extractFirstTextBlock(null)).toBeUndefined();
|
||||
expect(extractFirstTextBlock({ content: [] })).toBeUndefined();
|
||||
expect(extractFirstTextBlock({ content: [{ type: "image" }] })).toBeUndefined();
|
||||
expect(extractFirstTextBlock({ content: ["hello"] })).toBeUndefined();
|
||||
expect(extractFirstTextBlock({ content: [{ text: 1 }, { text: "later" }] })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,6 +5,7 @@ describe("shared/device-auth", () => {
|
||||
it("trims device auth roles without further rewriting", () => {
|
||||
expect(normalizeDeviceAuthRole(" operator ")).toBe("operator");
|
||||
expect(normalizeDeviceAuthRole("")).toBe("");
|
||||
expect(normalizeDeviceAuthRole(" NODE.Admin ")).toBe("NODE.Admin");
|
||||
});
|
||||
|
||||
it("dedupes, trims, sorts, and filters auth scopes", () => {
|
||||
@ -12,5 +13,11 @@ describe("shared/device-auth", () => {
|
||||
normalizeDeviceAuthScopes([" node.invoke ", "operator.read", "", "node.invoke", "a.scope"]),
|
||||
).toEqual(["a.scope", "node.invoke", "operator.read"]);
|
||||
expect(normalizeDeviceAuthScopes(undefined)).toEqual([]);
|
||||
expect(normalizeDeviceAuthScopes([" ", "\t", "\n"])).toEqual([]);
|
||||
expect(normalizeDeviceAuthScopes(["z.scope", "A.scope", "m.scope"])).toEqual([
|
||||
"A.scope",
|
||||
"m.scope",
|
||||
"z.scope",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -14,6 +14,15 @@ describe("shared/entry-metadata", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps metadata precedence even when metadata values are blank", () => {
|
||||
expect(
|
||||
resolveEmojiAndHomepage({
|
||||
metadata: { emoji: "", homepage: " " },
|
||||
frontmatter: { emoji: "🙂", homepage: "https://example.com" },
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("falls back through frontmatter homepage aliases and drops blanks", () => {
|
||||
expect(
|
||||
resolveEmojiAndHomepage({
|
||||
@ -29,5 +38,12 @@ describe("shared/entry-metadata", () => {
|
||||
frontmatter: { url: " " },
|
||||
}),
|
||||
).toEqual({});
|
||||
expect(
|
||||
resolveEmojiAndHomepage({
|
||||
frontmatter: { url: " https://openclaw.ai/install " },
|
||||
}),
|
||||
).toEqual({
|
||||
homepage: "https://openclaw.ai/install",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -52,4 +52,14 @@ describe("resolveGlobalMap", () => {
|
||||
|
||||
expect(resolveGlobalMap<string, number>(TEST_MAP_KEY).get("a")).toBe(1);
|
||||
});
|
||||
|
||||
it("reuses a prepopulated global map without creating a new one", () => {
|
||||
const existing = new Map<string, number>([["a", 1]]);
|
||||
(globalThis as Record<PropertyKey, unknown>)[TEST_MAP_KEY] = existing;
|
||||
|
||||
const resolved = resolveGlobalMap<string, number>(TEST_MAP_KEY);
|
||||
|
||||
expect(resolved).toBe(existing);
|
||||
expect(resolved.get("a")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,10 +5,13 @@ describe("shared/model-param-b", () => {
|
||||
it("extracts the largest valid b-sized parameter token", () => {
|
||||
expect(inferParamBFromIdOrName("llama-8b mixtral-22b")).toBe(22);
|
||||
expect(inferParamBFromIdOrName("Qwen 0.5B Instruct")).toBe(0.5);
|
||||
expect(inferParamBFromIdOrName("prefix M7B and q4_32b")).toBe(32);
|
||||
});
|
||||
|
||||
it("ignores malformed, zero, and non-delimited matches", () => {
|
||||
expect(inferParamBFromIdOrName("abc70beta 0b x70b2")).toBeNull();
|
||||
expect(inferParamBFromIdOrName("model 0b")).toBeNull();
|
||||
expect(inferParamBFromIdOrName("model b5")).toBeNull();
|
||||
expect(inferParamBFromIdOrName("foo70bbar")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -7,13 +7,18 @@ describe("shared/net/ipv4", () => {
|
||||
"IP address is required for custom bind mode",
|
||||
);
|
||||
expect(validateDottedDecimalIPv4Input("")).toBe("IP address is required for custom bind mode");
|
||||
expect(validateDottedDecimalIPv4Input(" ")).toBe(
|
||||
"Invalid IPv4 address (e.g., 192.168.1.100)",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts canonical dotted-decimal ipv4 only", () => {
|
||||
expect(validateDottedDecimalIPv4Input("192.168.1.100")).toBeUndefined();
|
||||
expect(validateDottedDecimalIPv4Input(" 192.168.1.100 ")).toBeUndefined();
|
||||
expect(validateDottedDecimalIPv4Input("0177.0.0.1")).toBe(
|
||||
"Invalid IPv4 address (e.g., 192.168.1.100)",
|
||||
);
|
||||
expect(validateDottedDecimalIPv4Input("[192.168.1.100]")).toBeUndefined();
|
||||
expect(validateDottedDecimalIPv4Input("example.com")).toBe(
|
||||
"Invalid IPv4 address (e.g., 192.168.1.100)",
|
||||
);
|
||||
|
||||
@ -20,6 +20,18 @@ describe("shared/node-resolve", () => {
|
||||
).toBe("mac-123");
|
||||
});
|
||||
|
||||
it("passes the original node list to the default picker", () => {
|
||||
expect(
|
||||
resolveNodeIdFromNodeList(nodes, "", {
|
||||
allowDefault: true,
|
||||
pickDefaultNode: (entries) => {
|
||||
expect(entries).toBe(nodes);
|
||||
return entries[1] ?? null;
|
||||
},
|
||||
}),
|
||||
).toBe("pi-456");
|
||||
});
|
||||
|
||||
it("still throws when default selection is disabled or returns null", () => {
|
||||
expect(() => resolveNodeIdFromNodeList(nodes, " ")).toThrow(/node required/);
|
||||
expect(() =>
|
||||
|
||||
@ -26,4 +26,14 @@ describe("shared/process-scoped-map", () => {
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it("reuses a prepopulated process map without replacing it", () => {
|
||||
const existing = new Map<string, number>([["a", 1]]);
|
||||
(process as unknown as Record<symbol, unknown>)[MAP_KEY] = existing;
|
||||
|
||||
const resolved = resolveProcessScopedMap<number>(MAP_KEY);
|
||||
|
||||
expect(resolved).toBe(existing);
|
||||
expect(resolved.get("a")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -42,4 +42,14 @@ describe("summarizeStringEntries", () => {
|
||||
}),
|
||||
).toBe("a, b, c, d, e, f (+1)");
|
||||
});
|
||||
|
||||
it("does not add a suffix when the limit exactly matches the entry count", () => {
|
||||
expect(
|
||||
summarizeStringEntries({
|
||||
entries: ["a", "b", "c"],
|
||||
limit: 3,
|
||||
emptyText: "ignored",
|
||||
}),
|
||||
).toBe("a, b, c");
|
||||
});
|
||||
});
|
||||
|
||||
@ -32,6 +32,28 @@ describe("shared/tailscale-status", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the first tailscale ip when DNSName is blank", async () => {
|
||||
const run = vi.fn().mockResolvedValue({
|
||||
code: 0,
|
||||
stdout: '{"Self":{"DNSName":"","TailscaleIPs":["100.64.0.10","fd7a::2"]}}',
|
||||
});
|
||||
|
||||
await expect(resolveTailnetHostWithRunner(run)).resolves.toBe("100.64.0.10");
|
||||
});
|
||||
|
||||
it("continues to later command candidates when earlier output has no usable host", async () => {
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ code: 0, stdout: '{"Self":{}}' })
|
||||
.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
stdout: '{"Self":{"DNSName":"backup.tail.ts.net."}}',
|
||||
});
|
||||
|
||||
await expect(resolveTailnetHostWithRunner(run)).resolves.toBe("backup.tail.ts.net");
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns null for non-zero exits, blank output, or invalid json", async () => {
|
||||
const run = vi
|
||||
.fn()
|
||||
|
||||
@ -5,12 +5,14 @@ describe("shared/text-chunking", () => {
|
||||
it("returns empty for blank input and the full text when under limit", () => {
|
||||
expect(chunkTextByBreakResolver("", 10, () => 5)).toEqual([]);
|
||||
expect(chunkTextByBreakResolver("hello", 10, () => 2)).toEqual(["hello"]);
|
||||
expect(chunkTextByBreakResolver("hello", 0, () => 2)).toEqual(["hello"]);
|
||||
});
|
||||
|
||||
it("splits at resolver-provided breakpoints and trims separator boundaries", () => {
|
||||
expect(
|
||||
chunkTextByBreakResolver("alpha beta gamma", 10, (window) => window.lastIndexOf(" ")),
|
||||
).toEqual(["alpha", "beta gamma"]);
|
||||
expect(chunkTextByBreakResolver("abcd efgh", 4, () => 4)).toEqual(["abcd", "efgh"]);
|
||||
});
|
||||
|
||||
it("falls back to hard limits for invalid break indexes", () => {
|
||||
@ -28,4 +30,11 @@ describe("shared/text-chunking", () => {
|
||||
chunkTextByBreakResolver("word next", 5, (window) => window.lastIndexOf(" ")),
|
||||
).toEqual(["word", "next"]);
|
||||
});
|
||||
|
||||
it("trims trailing whitespace from emitted chunks before continuing", () => {
|
||||
expect(chunkTextByBreakResolver("abc def", 6, (window) => window.lastIndexOf(" "))).toEqual([
|
||||
"abc",
|
||||
"def",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -42,8 +42,40 @@ describe("stripAssistantInternalScaffolding", () => {
|
||||
expect(stripAssistantInternalScaffolding(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("keeps relevant-memories tags inside inline code", () => {
|
||||
const input = "Use `<relevant-memories>example</relevant-memories>` literally.";
|
||||
expect(stripAssistantInternalScaffolding(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("hides unfinished relevant-memories blocks", () => {
|
||||
const input = ["Hello", "<relevant-memories>", "internal-only"].join("\n");
|
||||
expect(stripAssistantInternalScaffolding(input)).toBe("Hello\n");
|
||||
});
|
||||
|
||||
it("trims leading whitespace after stripping scaffolding", () => {
|
||||
const input = [
|
||||
"<thinking>",
|
||||
"secret",
|
||||
"</thinking>",
|
||||
" ",
|
||||
"<relevant-memories>",
|
||||
"internal note",
|
||||
"</relevant-memories>",
|
||||
" Visible",
|
||||
].join("\n");
|
||||
expect(stripAssistantInternalScaffolding(input)).toBe("Visible");
|
||||
});
|
||||
|
||||
it("preserves unfinished reasoning text while still stripping memory blocks", () => {
|
||||
const input = [
|
||||
"Before",
|
||||
"<thinking>",
|
||||
"secret",
|
||||
"<relevant-memories>",
|
||||
"internal note",
|
||||
"</relevant-memories>",
|
||||
"After",
|
||||
].join("\n");
|
||||
expect(stripAssistantInternalScaffolding(input)).toBe("Before\n\nsecret\n\nAfter");
|
||||
});
|
||||
});
|
||||
|
||||
@ -27,13 +27,25 @@ describe("shared/text/code-regions", () => {
|
||||
expect(text.slice(regions[1].start, regions[1].end)).toBe("```\nunterminated");
|
||||
});
|
||||
|
||||
it("keeps adjacent inline code outside fenced regions", () => {
|
||||
const text = ["```ts", "const a = 1;", "```", "after `inline` tail"].join("\n");
|
||||
|
||||
const regions = findCodeRegions(text);
|
||||
|
||||
expect(regions).toHaveLength(2);
|
||||
expect(text.slice(regions[0].start, regions[0].end)).toContain("```ts");
|
||||
expect(text.slice(regions[1].start, regions[1].end)).toBe("`inline`");
|
||||
});
|
||||
|
||||
it("reports whether positions are inside discovered regions", () => {
|
||||
const text = "plain `code` done";
|
||||
const regions = findCodeRegions(text);
|
||||
const codeStart = text.indexOf("code");
|
||||
const plainStart = text.indexOf("plain");
|
||||
const regionEnd = regions[0]?.end ?? -1;
|
||||
|
||||
expect(isInsideCode(codeStart, regions)).toBe(true);
|
||||
expect(isInsideCode(plainStart, regions)).toBe(false);
|
||||
expect(isInsideCode(regionEnd, regions)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -18,6 +18,7 @@ vi.mock("../../../pairing/pairing-store.js", () => ({
|
||||
|
||||
type MessageHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
|
||||
type AppMentionHandler = MessageHandler;
|
||||
type RegisteredEventName = "message" | "app_mention";
|
||||
|
||||
type MessageCase = {
|
||||
overrides?: SlackSystemEventTestOverrides;
|
||||
@ -25,7 +26,7 @@ type MessageCase = {
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
function createMessageHandlers(overrides?: SlackSystemEventTestOverrides) {
|
||||
function createHandlers(eventName: RegisteredEventName, overrides?: SlackSystemEventTestOverrides) {
|
||||
const harness = createSlackSystemEventTestHarness(overrides);
|
||||
const handleSlackMessage = vi.fn(async () => {});
|
||||
registerSlackMessageEvents({
|
||||
@ -33,22 +34,14 @@ function createMessageHandlers(overrides?: SlackSystemEventTestOverrides) {
|
||||
handleSlackMessage,
|
||||
});
|
||||
return {
|
||||
handler: harness.getHandler("message") as MessageHandler | null,
|
||||
handler: harness.getHandler(eventName) as MessageHandler | null,
|
||||
handleSlackMessage,
|
||||
};
|
||||
}
|
||||
|
||||
function createAppMentionHandlers(overrides?: SlackSystemEventTestOverrides) {
|
||||
const harness = createSlackSystemEventTestHarness(overrides);
|
||||
const handleSlackMessage = vi.fn(async () => {});
|
||||
registerSlackMessageEvents({
|
||||
ctx: harness.ctx,
|
||||
handleSlackMessage,
|
||||
});
|
||||
return {
|
||||
handler: harness.getHandler("app_mention") as AppMentionHandler | null,
|
||||
handleSlackMessage,
|
||||
};
|
||||
function resetMessageMocks(): void {
|
||||
messageQueueMock.mockClear();
|
||||
messageAllowMock.mockReset().mockResolvedValue([]);
|
||||
}
|
||||
|
||||
function makeChangedEvent(overrides?: { channel?: string; user?: string }) {
|
||||
@ -89,10 +82,40 @@ function makeThreadBroadcastEvent(overrides?: { channel?: string; user?: string
|
||||
};
|
||||
}
|
||||
|
||||
function makeAppMentionEvent(overrides?: {
|
||||
channel?: string;
|
||||
channelType?: "channel" | "group" | "im" | "mpim";
|
||||
ts?: string;
|
||||
}) {
|
||||
return {
|
||||
type: "app_mention",
|
||||
channel: overrides?.channel ?? "C123",
|
||||
channel_type: overrides?.channelType ?? "channel",
|
||||
user: "U1",
|
||||
text: "<@U_BOT> hello",
|
||||
ts: overrides?.ts ?? "123.456",
|
||||
};
|
||||
}
|
||||
|
||||
async function invokeRegisteredHandler(input: {
|
||||
eventName: RegisteredEventName;
|
||||
overrides?: SlackSystemEventTestOverrides;
|
||||
event: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
}) {
|
||||
resetMessageMocks();
|
||||
const { handler, handleSlackMessage } = createHandlers(input.eventName, input.overrides);
|
||||
expect(handler).toBeTruthy();
|
||||
await handler!({
|
||||
event: input.event,
|
||||
body: input.body ?? {},
|
||||
});
|
||||
return { handleSlackMessage };
|
||||
}
|
||||
|
||||
async function runMessageCase(input: MessageCase = {}): Promise<void> {
|
||||
messageQueueMock.mockClear();
|
||||
messageAllowMock.mockReset().mockResolvedValue([]);
|
||||
const { handler } = createMessageHandlers(input.overrides);
|
||||
resetMessageMocks();
|
||||
const { handler } = createHandlers("message", input.overrides);
|
||||
expect(handler).toBeTruthy();
|
||||
await handler!({
|
||||
event: (input.event ?? makeChangedEvent()) as Record<string, unknown>,
|
||||
@ -151,12 +174,9 @@ describe("registerSlackMessageEvents", () => {
|
||||
});
|
||||
|
||||
it("passes regular message events to the message handler", async () => {
|
||||
messageQueueMock.mockClear();
|
||||
messageAllowMock.mockReset().mockResolvedValue([]);
|
||||
const { handler, handleSlackMessage } = createMessageHandlers({ dmPolicy: "open" });
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
await handler!({
|
||||
const { handleSlackMessage } = await invokeRegisteredHandler({
|
||||
eventName: "message",
|
||||
overrides: { dmPolicy: "open" },
|
||||
event: {
|
||||
type: "message",
|
||||
channel: "D1",
|
||||
@ -164,7 +184,6 @@ describe("registerSlackMessageEvents", () => {
|
||||
text: "hello",
|
||||
ts: "123.456",
|
||||
},
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(handleSlackMessage).toHaveBeenCalledTimes(1);
|
||||
@ -172,9 +191,8 @@ describe("registerSlackMessageEvents", () => {
|
||||
});
|
||||
|
||||
it("handles channel and group messages via the unified message handler", async () => {
|
||||
messageQueueMock.mockClear();
|
||||
messageAllowMock.mockReset().mockResolvedValue([]);
|
||||
const { handler, handleSlackMessage } = createMessageHandlers({
|
||||
resetMessageMocks();
|
||||
const { handler, handleSlackMessage } = createHandlers("message", {
|
||||
dmPolicy: "open",
|
||||
channelType: "channel",
|
||||
});
|
||||
@ -206,23 +224,18 @@ describe("registerSlackMessageEvents", () => {
|
||||
});
|
||||
|
||||
it("applies subtype system-event handling for channel messages", async () => {
|
||||
messageQueueMock.mockClear();
|
||||
messageAllowMock.mockReset().mockResolvedValue([]);
|
||||
const { handler, handleSlackMessage } = createMessageHandlers({
|
||||
dmPolicy: "open",
|
||||
channelType: "channel",
|
||||
});
|
||||
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
// message_changed events from channels arrive via the generic "message"
|
||||
// handler with channel_type:"channel" — not a separate event type.
|
||||
await handler!({
|
||||
const { handleSlackMessage } = await invokeRegisteredHandler({
|
||||
eventName: "message",
|
||||
overrides: {
|
||||
dmPolicy: "open",
|
||||
channelType: "channel",
|
||||
},
|
||||
event: {
|
||||
...makeChangedEvent({ channel: "C1", user: "U1" }),
|
||||
channel_type: "channel",
|
||||
},
|
||||
body: {},
|
||||
});
|
||||
|
||||
expect(handleSlackMessage).not.toHaveBeenCalled();
|
||||
@ -230,38 +243,20 @@ describe("registerSlackMessageEvents", () => {
|
||||
});
|
||||
|
||||
it("skips app_mention events for DM channel ids even with contradictory channel_type", async () => {
|
||||
const { handler, handleSlackMessage } = createAppMentionHandlers({ dmPolicy: "open" });
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
await handler!({
|
||||
event: {
|
||||
type: "app_mention",
|
||||
channel: "D123",
|
||||
channel_type: "channel",
|
||||
user: "U1",
|
||||
text: "<@U_BOT> hello",
|
||||
ts: "123.456",
|
||||
},
|
||||
body: {},
|
||||
const { handleSlackMessage } = await invokeRegisteredHandler({
|
||||
eventName: "app_mention",
|
||||
overrides: { dmPolicy: "open" },
|
||||
event: makeAppMentionEvent({ channel: "D123", channelType: "channel" }),
|
||||
});
|
||||
|
||||
expect(handleSlackMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes app_mention events from channels to the message handler", async () => {
|
||||
const { handler, handleSlackMessage } = createAppMentionHandlers({ dmPolicy: "open" });
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
await handler!({
|
||||
event: {
|
||||
type: "app_mention",
|
||||
channel: "C123",
|
||||
channel_type: "channel",
|
||||
user: "U1",
|
||||
text: "<@U_BOT> hello",
|
||||
ts: "123.789",
|
||||
},
|
||||
body: {},
|
||||
const { handleSlackMessage } = await invokeRegisteredHandler({
|
||||
eventName: "app_mention",
|
||||
overrides: { dmPolicy: "open" },
|
||||
event: makeAppMentionEvent({ channel: "C123", channelType: "channel", ts: "123.789" }),
|
||||
});
|
||||
|
||||
expect(handleSlackMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
16
src/utils.ts
16
src/utils.ts
@ -4,8 +4,8 @@ import path from "node:path";
|
||||
import { resolveOAuthDir } from "./config/paths.js";
|
||||
import { logVerbose, shouldLogVerbose } from "./globals.js";
|
||||
import {
|
||||
expandHomePrefix,
|
||||
resolveEffectiveHomeDir,
|
||||
resolveHomeRelativePath,
|
||||
resolveRequiredHomeDir,
|
||||
} from "./infra/home-dir.js";
|
||||
import { isPlainObject } from "./infra/plain-object.js";
|
||||
@ -279,19 +279,7 @@ export function resolveUserPath(
|
||||
if (!input) {
|
||||
return "";
|
||||
}
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.startsWith("~")) {
|
||||
const expanded = expandHomePrefix(trimmed, {
|
||||
home: resolveRequiredHomeDir(env, homedir),
|
||||
env,
|
||||
homedir,
|
||||
});
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
return resolveHomeRelativePath(input, { env, homedir });
|
||||
}
|
||||
|
||||
export function resolveConfigDir(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user