Skip to content

Commit 7a697bc

Browse files
authored
Merge branch 'main' into fix/table-wide-grapheme-cell-overflow
2 parents 6fe0ee1 + a71b121 commit 7a697bc

31 files changed

Lines changed: 750 additions & 271 deletions

extensions/googlechat/src/channel.adapters.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
2+
import type {
3+
ChannelThreadingContext,
4+
ChannelThreadingToolContext,
5+
} from "openclaw/plugin-sdk/channel-contract";
26
import {
37
createMessageReceiptFromOutboundResults,
48
defineChannelMessageAdapter,
@@ -127,6 +131,29 @@ export const googlechatThreadingAdapter = {
127131
account.config.replyToMode,
128132
fallback: "off" as const,
129133
},
134+
buildToolContext: ({
135+
cfg,
136+
accountId,
137+
context,
138+
hasRepliedRef,
139+
}: {
140+
cfg: OpenClawConfig;
141+
accountId?: string | null;
142+
context: ChannelThreadingContext;
143+
hasRepliedRef?: { value: boolean };
144+
}): ChannelThreadingToolContext => {
145+
const currentChannelId = normalizeGoogleChatTarget(context.To);
146+
const replyToId =
147+
normalizeOptionalString(context.ReplyToIdFull) ?? normalizeOptionalString(context.ReplyToId);
148+
149+
return {
150+
currentChannelId,
151+
currentMessageId: replyToId,
152+
currentThreadTs: replyToId,
153+
replyToMode: resolveGoogleChatAccount({ cfg, accountId }).config.replyToMode,
154+
hasRepliedRef,
155+
};
156+
},
130157
};
131158

132159
export const googlechatPairingTextAdapter = {

extensions/googlechat/src/channel.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,62 @@ describe("googlechatPlugin threading", () => {
439439
googlechatThreadingAdapter.scopedAccountReplyToMode.resolveReplyToMode(defaultAccount),
440440
).toBe("all");
441441
});
442+
443+
it("uses the inbound thread resource as the current tool reply target", () => {
444+
const cfg = {
445+
channels: {
446+
googlechat: {
447+
replyToMode: "all",
448+
},
449+
},
450+
} as OpenClawConfig;
451+
const hasRepliedRef = { value: false };
452+
453+
const context = googlechatThreadingAdapter.buildToolContext({
454+
cfg,
455+
accountId: "default",
456+
context: {
457+
To: "googlechat:spaces/AAA",
458+
CurrentMessageId: "spaces/AAA/messages/msg-1",
459+
ReplyToId: "spaces/AAA/threads/thread-1",
460+
},
461+
hasRepliedRef,
462+
});
463+
464+
expect(context).toMatchObject({
465+
currentChannelId: "spaces/AAA",
466+
currentMessageId: "spaces/AAA/threads/thread-1",
467+
currentThreadTs: "spaces/AAA/threads/thread-1",
468+
replyToMode: "all",
469+
hasRepliedRef,
470+
});
471+
});
472+
473+
it("does not use message resources as implicit Google Chat reply targets", () => {
474+
const cfg = {
475+
channels: {
476+
googlechat: {
477+
replyToMode: "all",
478+
},
479+
},
480+
} as OpenClawConfig;
481+
482+
const context = googlechatThreadingAdapter.buildToolContext({
483+
cfg,
484+
accountId: "default",
485+
context: {
486+
To: "googlechat:spaces/AAA",
487+
CurrentMessageId: "spaces/AAA/messages/msg-1",
488+
},
489+
});
490+
491+
expect(context).toMatchObject({
492+
currentChannelId: "spaces/AAA",
493+
replyToMode: "all",
494+
});
495+
expect(context.currentMessageId).toBeUndefined();
496+
expect(context.currentThreadTs).toBeUndefined();
497+
});
442498
});
443499

444500
const resolveTarget = googlechatOutboundAdapter.base.resolveTarget;

extensions/sms/src/twilio.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it, vi } from "vitest";
1+
import { afterEach, describe, expect, it, vi } from "vitest";
22
import {
33
buildTwilioInboundMessage,
44
computeTwilioSignature,
@@ -13,6 +13,16 @@ import {
1313
} from "./twilio.js";
1414
import type { ResolvedSmsAccount } from "./types.js";
1515

16+
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
17+
18+
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
19+
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
20+
return {
21+
...actual,
22+
fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args),
23+
};
24+
});
25+
1626
function createAccount(overrides: Partial<ResolvedSmsAccount> = {}): ResolvedSmsAccount {
1727
return {
1828
accountId: "default",
@@ -43,6 +53,10 @@ function readUrlEncodedRequestBody(init: RequestInit | undefined): URLSearchPara
4353
}
4454

4555
describe("Twilio SMS helpers", () => {
56+
afterEach(() => {
57+
fetchWithSsrFGuardMock.mockReset();
58+
});
59+
4660
it("parses Twilio form bodies and inbound messages", () => {
4761
const form = parseTwilioFormBody(
4862
"From=%2B15551234567&To=%2B15557654321&Body=hello+there&MessageSid=SM123",
@@ -439,6 +453,33 @@ describe("Twilio SMS helpers", () => {
439453
).rejects.toThrow("Twilio SMS send failed (503): upstream unavailable");
440454
});
441455

456+
it("releases guarded Twilio egress on failed send responses", async () => {
457+
const release = vi.fn(async () => {});
458+
fetchWithSsrFGuardMock.mockResolvedValue({
459+
response: new Response("upstream unavailable", { status: 503 }),
460+
release,
461+
});
462+
463+
await expect(
464+
sendSmsViaTwilio({
465+
account: createAccount(),
466+
to: "+15551234567",
467+
text: "hello",
468+
}),
469+
).rejects.toThrow("Twilio SMS send failed (503): upstream unavailable");
470+
471+
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
472+
expect.objectContaining({
473+
auditContext: "sms-twilio-api",
474+
policy: { allowedHostnames: ["api.twilio.com"] },
475+
requireHttps: true,
476+
timeoutMs: 30_000,
477+
url: "https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json",
478+
}),
479+
);
480+
expect(release).toHaveBeenCalledTimes(1);
481+
});
482+
442483
it("rejects malformed JSON from successful Twilio sends", async () => {
443484
const fetchImpl = vi.fn<typeof fetch>(async () => new Response("not json", { status: 201 }));
444485

@@ -452,6 +493,24 @@ describe("Twilio SMS helpers", () => {
452493
).rejects.toThrow("Twilio SMS send returned malformed JSON.");
453494
});
454495

496+
it("releases guarded Twilio egress on malformed successful send responses", async () => {
497+
const release = vi.fn(async () => {});
498+
fetchWithSsrFGuardMock.mockResolvedValue({
499+
response: new Response("not json", { status: 201 }),
500+
release,
501+
});
502+
503+
await expect(
504+
sendSmsViaTwilio({
505+
account: createAccount(),
506+
to: "+15551234567",
507+
text: "hello",
508+
}),
509+
).rejects.toThrow("Twilio SMS send returned malformed JSON.");
510+
511+
expect(release).toHaveBeenCalledTimes(1);
512+
});
513+
455514
it("exposes a typed Twilio SMS API error", () => {
456515
const error = new TwilioSmsApiError(
457516
429,

src/agents/session-write-lock.test.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fsSync from "node:fs";
33
import fs from "node:fs/promises";
44
import os from "node:os";
55
import path from "node:path";
6+
import { fileURLToPath } from "node:url";
67
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
78
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
89
import { SessionWriteLockStaleError } from "./session-write-lock-error.js";
@@ -87,6 +88,19 @@ async function writeCurrentProcessLock(lockPath: string, extra?: Record<string,
8788
);
8889
}
8990

91+
function readFilePathToString(filePath: Parameters<typeof fs.readFile>[0]): string | undefined {
92+
if (typeof filePath === "string") {
93+
return filePath;
94+
}
95+
if (Buffer.isBuffer(filePath)) {
96+
return filePath.toString("utf8");
97+
}
98+
if (filePath instanceof URL) {
99+
return fileURLToPath(filePath);
100+
}
101+
return undefined;
102+
}
103+
90104
async function withSymlinkedSessionPaths(
91105
run: (params: {
92106
sessionReal: string;
@@ -453,6 +467,157 @@ describe("acquireSessionWriteLock", () => {
453467
});
454468
});
455469

470+
it("retries when a stale lock report disappears before diagnostics", async () => {
471+
await withTempSessionLockFile(async ({ sessionFile, lockPath }) => {
472+
const owner = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)", "openclaw"], {
473+
stdio: "ignore",
474+
});
475+
if (!owner.pid) {
476+
throw new Error("missing lock owner pid");
477+
}
478+
await fs.writeFile(
479+
lockPath,
480+
JSON.stringify({
481+
pid: owner.pid,
482+
createdAt: new Date(Date.now() - 120_000).toISOString(),
483+
}),
484+
"utf8",
485+
);
486+
487+
const originalReadFile = fs.readFile.bind(fs);
488+
let lockReads = 0;
489+
const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation((async (
490+
filePath,
491+
options,
492+
) => {
493+
const lockFilePath = readFilePathToString(filePath);
494+
if (lockFilePath && path.basename(lockFilePath) === path.basename(lockPath)) {
495+
lockReads += 1;
496+
if (lockReads === 3) {
497+
await fs.rm(lockFilePath, { force: true });
498+
await fs.rm(lockPath, { force: true });
499+
throw Object.assign(new Error("lock disappeared"), { code: "ENOENT" });
500+
}
501+
}
502+
return await originalReadFile(filePath, options as never);
503+
}) as typeof fs.readFile);
504+
505+
try {
506+
const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500, staleMs: 10 });
507+
await lock.release();
508+
expect(lockReads).toBeGreaterThanOrEqual(3);
509+
await expectPathMissing(lockPath);
510+
} finally {
511+
readFileSpy.mockRestore();
512+
owner.kill("SIGTERM");
513+
}
514+
});
515+
});
516+
517+
it("retries when a stale lock report is replaced before diagnostics", async () => {
518+
await withTempSessionLockFile(async ({ sessionFile, lockPath }) => {
519+
const owner = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)", "openclaw"], {
520+
stdio: "ignore",
521+
});
522+
if (!owner.pid) {
523+
throw new Error("missing lock owner pid");
524+
}
525+
await fs.writeFile(
526+
lockPath,
527+
JSON.stringify({
528+
pid: owner.pid,
529+
createdAt: new Date(Date.now() - 120_000).toISOString(),
530+
}),
531+
"utf8",
532+
);
533+
534+
const originalReadFile = fs.readFile.bind(fs);
535+
let lockReads = 0;
536+
const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation((async (
537+
filePath,
538+
options,
539+
) => {
540+
const lockFilePath = readFilePathToString(filePath);
541+
if (lockFilePath && path.basename(lockFilePath) === path.basename(lockPath)) {
542+
lockReads += 1;
543+
if (lockReads === 3) {
544+
await fs.rm(lockFilePath, { force: true });
545+
await fs.rm(lockPath, { force: true });
546+
await fs.writeFile(
547+
lockFilePath,
548+
JSON.stringify({ pid: owner.pid, createdAt: new Date().toISOString() }),
549+
"utf8",
550+
);
551+
setTimeout(() => {
552+
void fs.rm(lockFilePath, { force: true });
553+
}, 10);
554+
throw Object.assign(new Error("lock disappeared"), { code: "ENOENT" });
555+
}
556+
}
557+
return await originalReadFile(filePath, options as never);
558+
}) as typeof fs.readFile);
559+
560+
try {
561+
const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500, staleMs: 10 });
562+
await lock.release();
563+
expect(lockReads).toBeGreaterThanOrEqual(3);
564+
await expectPathMissing(lockPath);
565+
} finally {
566+
readFileSpy.mockRestore();
567+
owner.kill("SIGTERM");
568+
}
569+
});
570+
});
571+
572+
it("retries when a stale lock report is replaced by a fresh payload-less lock", async () => {
573+
await withTempSessionLockFile(async ({ sessionFile, lockPath }) => {
574+
const owner = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)", "openclaw"], {
575+
stdio: "ignore",
576+
});
577+
if (!owner.pid) {
578+
throw new Error("missing lock owner pid");
579+
}
580+
await fs.writeFile(
581+
lockPath,
582+
JSON.stringify({
583+
pid: owner.pid,
584+
createdAt: new Date(Date.now() - 120_000).toISOString(),
585+
}),
586+
"utf8",
587+
);
588+
589+
const originalReadFile = fs.readFile.bind(fs);
590+
let lockReads = 0;
591+
const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation((async (
592+
filePath,
593+
options,
594+
) => {
595+
const lockFilePath = readFilePathToString(filePath);
596+
if (lockFilePath && path.basename(lockFilePath) === path.basename(lockPath)) {
597+
lockReads += 1;
598+
if (lockReads === 3) {
599+
await fs.rm(lockFilePath, { force: true });
600+
await fs.writeFile(lockFilePath, "", "utf8");
601+
setTimeout(() => {
602+
void fs.rm(lockFilePath, { force: true });
603+
}, 10);
604+
}
605+
}
606+
return await originalReadFile(filePath, options as never);
607+
}) as typeof fs.readFile);
608+
609+
try {
610+
const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 800, staleMs: 10 });
611+
await lock.release();
612+
expect(lockReads).toBeGreaterThanOrEqual(3);
613+
await expectPathMissing(lockPath);
614+
} finally {
615+
readFileSpy.mockRestore();
616+
owner.kill("SIGTERM");
617+
}
618+
});
619+
});
620+
456621
it("watchdog releases stale in-process locks", async () => {
457622
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-"));
458623
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
@@ -600,6 +765,14 @@ describe("acquireSessionWriteLock", () => {
600765
});
601766
});
602767

768+
it("preserves one acquire timeout budget across retries", () => {
769+
expect(testing.resolveRemainingAcquireTimeoutMs(500, 1_000, 1_125)).toBe(375);
770+
expect(testing.resolveRemainingAcquireTimeoutMs(500, 1_000, 1_500)).toBe(0);
771+
expect(testing.resolveRemainingAcquireTimeoutMs(Number.POSITIVE_INFINITY, 1_000, 9_000)).toBe(
772+
Number.POSITIVE_INFINITY,
773+
);
774+
});
775+
603776
it("uses resolved stale policy when cleaning stale lock files", async () => {
604777
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-policy-"));
605778
const sessionsDir = path.join(root, "sessions");

0 commit comments

Comments
 (0)