Skip to content

Commit 7f3ba04

Browse files
committed
Merge remote-tracking branch 'origin/main' into codex/test-performance-more-37
2 parents ec74ec0 + 8ff4f01 commit 7f3ba04

10 files changed

Lines changed: 294 additions & 122 deletions

File tree

extensions/google-meet/index.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,57 @@ function requireSetupCheck(checks: unknown[] | undefined, id: string): Record<st
376376
return check;
377377
}
378378

379+
type TwilioSetupCredentials = {
380+
accountSid: string;
381+
authToken: string;
382+
fromNumber: string;
383+
};
384+
385+
async function getTwilioVoiceCallCredentialsCheck(params: {
386+
env: TwilioSetupCredentials;
387+
configured?: Partial<TwilioSetupCredentials>;
388+
}): Promise<Record<string, unknown>> {
389+
vi.stubEnv("TWILIO_ACCOUNT_SID", params.env.accountSid);
390+
vi.stubEnv("TWILIO_AUTH_TOKEN", params.env.authToken);
391+
vi.stubEnv("TWILIO_FROM_NUMBER", params.env.fromNumber);
392+
const { tools } = setup(
393+
{
394+
defaultTransport: "chrome-node",
395+
chromeNode: { node: "parallels-macos" },
396+
},
397+
{
398+
fullConfig: {
399+
plugins: {
400+
allow: ["google-meet", "voice-call"],
401+
entries: {
402+
"voice-call": {
403+
enabled: true,
404+
config: {
405+
provider: "twilio",
406+
publicUrl: "https://voice.example.com/voice/webhook",
407+
fromNumber: params.configured?.fromNumber,
408+
twilio: {
409+
accountSid: params.configured?.accountSid,
410+
authToken: params.configured?.authToken,
411+
},
412+
},
413+
},
414+
},
415+
},
416+
},
417+
},
418+
);
419+
const tool = tools[0] as {
420+
execute: (
421+
id: string,
422+
params: unknown,
423+
) => Promise<{ details: { checks?: unknown[] } }>;
424+
};
425+
426+
const result = await tool.execute("id", { action: "setup_status" });
427+
return requireSetupCheck(result.details.checks, "twilio-voice-call-credentials");
428+
}
429+
379430
function requireFetchGuardCall(auditContext: string): Record<string, unknown> {
380431
const call = (
381432
fetchGuardMocks.fetchWithSsrFGuard.mock.calls as Array<[Record<string, unknown>]>
@@ -2604,6 +2655,64 @@ describe("google-meet plugin", () => {
26042655
expect(requireSetupCheck(result.details.checks, "twilio-voice-call-webhook").ok).toBe(true);
26052656
});
26062657

2658+
it.each([
2659+
{
2660+
label: "environment account SID",
2661+
env: { accountSid: " ", authToken: "test-auth-token", fromNumber: "+15550001234" },
2662+
},
2663+
{
2664+
label: "environment auth token",
2665+
env: { accountSid: "AC123", authToken: " ", fromNumber: "+15550001234" },
2666+
},
2667+
{
2668+
label: "environment from number",
2669+
env: { accountSid: "AC123", authToken: "test-auth-token", fromNumber: " " },
2670+
},
2671+
{
2672+
label: "configured account SID",
2673+
env: { accountSid: "", authToken: "", fromNumber: "" },
2674+
configured: { accountSid: " ", authToken: "test-auth-token", fromNumber: "+15550001234" },
2675+
},
2676+
{
2677+
label: "configured auth token",
2678+
env: { accountSid: "", authToken: "", fromNumber: "" },
2679+
configured: { accountSid: "AC123", authToken: " ", fromNumber: "+15550001234" },
2680+
},
2681+
{
2682+
label: "configured from number",
2683+
env: { accountSid: "", authToken: "", fromNumber: "" },
2684+
configured: { accountSid: "AC123", authToken: "test-auth-token", fromNumber: " " },
2685+
},
2686+
])("reports a blank $label as missing", async ({ env, configured }) => {
2687+
const check = await getTwilioVoiceCallCredentialsCheck({ env, configured });
2688+
2689+
expect(check.ok).toBe(false);
2690+
});
2691+
2692+
it.each([
2693+
{
2694+
label: "environment",
2695+
env: {
2696+
accountSid: " AC123 ",
2697+
authToken: " test-auth-token ",
2698+
fromNumber: " +15550001234 ",
2699+
},
2700+
},
2701+
{
2702+
label: "configuration",
2703+
env: { accountSid: "", authToken: "", fromNumber: "" },
2704+
configured: {
2705+
accountSid: " AC123 ",
2706+
authToken: " test-auth-token ",
2707+
fromNumber: " +15550001234 ",
2708+
},
2709+
},
2710+
])("accepts padded Twilio credentials from $label", async ({ env, configured }) => {
2711+
const check = await getTwilioVoiceCallCredentialsCheck({ env, configured });
2712+
2713+
expect(check.ok).toBe(true);
2714+
});
2715+
26072716
it("reports missing voice-call wiring for explicit Twilio transport", async () => {
26082717
vi.stubEnv("TWILIO_ACCOUNT_SID", "");
26092718
vi.stubEnv("TWILIO_AUTH_TOKEN", "");

extensions/google-meet/src/setup.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ function isProviderUnreachableWebhookUrl(webhookUrl: string): boolean {
3636
}
3737
}
3838

39+
function resolveVoiceCallSetupValue(configured: unknown, fallback: unknown): string | undefined {
40+
return normalizeOptionalString(configured) ?? normalizeOptionalString(fallback);
41+
}
42+
3943
function getVoiceCallWebhookExposureCheck(voiceCallConfig: Record<string, unknown>): SetupCheck {
4044
const publicUrl = normalizeOptionalString(voiceCallConfig.publicUrl);
4145
const tunnel = asRecord(voiceCallConfig.tunnel);
@@ -240,14 +244,19 @@ export function getGoogleMeetSetupStatus(
240244

241245
const provider = normalizeOptionalString(voiceCallConfig.provider) ?? "twilio";
242246
if (provider === "twilio") {
243-
const accountSid = normalizeOptionalString(voiceCallTwilioConfig.accountSid);
244-
const authToken = normalizeOptionalString(voiceCallTwilioConfig.authToken);
245-
const fromNumber = normalizeOptionalString(voiceCallConfig.fromNumber);
246-
const twilioReady = Boolean(
247-
(accountSid || env.TWILIO_ACCOUNT_SID) &&
248-
(authToken || env.TWILIO_AUTH_TOKEN) &&
249-
(fromNumber || env.TWILIO_FROM_NUMBER),
247+
const accountSid = resolveVoiceCallSetupValue(
248+
voiceCallTwilioConfig.accountSid,
249+
env.TWILIO_ACCOUNT_SID,
250+
);
251+
const authToken = resolveVoiceCallSetupValue(
252+
voiceCallTwilioConfig.authToken,
253+
env.TWILIO_AUTH_TOKEN,
254+
);
255+
const fromNumber = resolveVoiceCallSetupValue(
256+
voiceCallConfig.fromNumber,
257+
env.TWILIO_FROM_NUMBER,
250258
);
259+
const twilioReady = Boolean(accountSid && authToken && fromNumber);
251260
checks.push({
252261
id: "twilio-voice-call-credentials",
253262
ok: twilioReady,

scripts/e2e/lib/text-file-utils.mjs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
// Text file tail helpers for E2E assertions.
22
import fs from "node:fs";
33

4+
function decodeUtf8Tail(buffer, truncated) {
5+
let start = 0;
6+
if (truncated) {
7+
while (start < buffer.length && (buffer[start] & 0b1100_0000) === 0b1000_0000) {
8+
start += 1;
9+
}
10+
}
11+
return buffer.subarray(start).toString("utf8");
12+
}
13+
414
export function tailText(text, maxBytes) {
515
if (Buffer.byteLength(text, "utf8") <= maxBytes) {
616
return text;
717
}
8-
return Buffer.from(text, "utf8").subarray(-maxBytes).toString("utf8");
18+
return decodeUtf8Tail(Buffer.from(text, "utf8").subarray(-maxBytes), true);
919
}
1020

1121
export function readTextFileTail(file, maxBytes) {
@@ -26,7 +36,7 @@ export function readTextFileTail(file, maxBytes) {
2636
fd = fs.openSync(file, "r");
2737
const buffer = Buffer.alloc(length);
2838
const bytesRead = fs.readSync(fd, buffer, 0, length, start);
29-
return buffer.subarray(0, bytesRead).toString("utf8");
39+
return decodeUtf8Tail(buffer.subarray(0, bytesRead), start > 0);
3040
} catch {
3141
return "";
3242
} finally {

src/cli/secrets-cli.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,15 @@ function createSecretsApplyResult(options?: {
125125
};
126126
}
127127

128-
async function withPlanFile(run: (planPath: string) => Promise<void>) {
128+
async function withPlanFile(
129+
run: (planPath: string) => Promise<void>,
130+
contents = `${JSON.stringify(createManualSecretsPlan())}\n`,
131+
) {
129132
const planPath = path.join(
130133
os.tmpdir(),
131134
`openclaw-secrets-cli-test-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
132135
);
133-
await fs.writeFile(planPath, `${JSON.stringify(createManualSecretsPlan())}\n`, "utf8");
136+
await fs.writeFile(planPath, contents, "utf8");
134137
try {
135138
await run(planPath);
136139
} finally {
@@ -385,6 +388,17 @@ describe("secrets CLI", () => {
385388
});
386389
});
387390

391+
it("shows a user-friendly error when the secrets plan file is malformed JSON", async () => {
392+
await withPlanFile(async (planPath) => {
393+
await expect(
394+
createProgram().parseAsync(["secrets", "apply", "--from", planPath], { from: "user" }),
395+
).rejects.toThrow("__exit__:1");
396+
397+
expect(runtimeErrors.at(-1)).toContain(`Malformed JSON in secrets plan file: ${planPath}`);
398+
expect(runSecretsApply).not.toHaveBeenCalled();
399+
}, "{invalid json");
400+
});
401+
388402
it("does not print skipped-exec note when apply dry-run skippedExecRefs is zero", async () => {
389403
await withPlanFile(async (planPath) => {
390404
runSecretsApply.mockResolvedValue(createSecretsApplyResult({ resolvabilityComplete: false }));

src/cli/secrets-cli.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,12 @@ async function readPlanFile(pathname: string): Promise<SecretsApplyPlan> {
5353
import("../secrets/plan.js"),
5454
]);
5555
const raw = readFileSync(pathname, "utf8");
56-
const parsed = JSON.parse(raw) as unknown;
56+
let parsed: unknown;
57+
try {
58+
parsed = JSON.parse(raw);
59+
} catch (err) {
60+
throw new Error(`Malformed JSON in secrets plan file: ${pathname}`, { cause: err });
61+
}
5762
if (!isSecretsApplyPlan(parsed)) {
5863
throw new Error(
5964
`Invalid secrets plan file: ${pathname}. Generate a fresh plan with ${formatCliCommand("openclaw secrets configure --plan-out <path>")}.`,

src/config/issue-location.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,15 @@ describe("appendReceivedValueHint", () => {
249249
).toBe('Invalid input (allowed: "minimal", "coding"), got: "none"');
250250
});
251251

252+
it("keeps truncated received values on a valid UTF-16 boundary", () => {
253+
const message = appendReceivedValueHint(
254+
"invalid input",
255+
"gateway.bind",
256+
`${"x".repeat(155)}🎉tail`,
257+
);
258+
expect(message).toBe(`invalid input, got: "${"x".repeat(155)}...`);
259+
});
260+
252261
it("skips when message already mentions received", () => {
253262
expect(appendReceivedValueHint("expected string, received number", "gateway.port", 18789)).toBe(
254263
"expected string, received number",

src/config/issue-location.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import path from "node:path";
2+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
23
import JSON5 from "json5";
34
import { isSensitiveConfigPath } from "./sensitive-paths.js";
45
import type { ConfigValidationIssue } from "./types.js";
@@ -255,7 +256,7 @@ function stringifyReceivedValue(value: unknown): string | null {
255256
if (serialized === undefined) {
256257
return null;
257258
}
258-
return serialized.length > 160 ? `${serialized.slice(0, 157)}...` : serialized;
259+
return serialized.length > 160 ? `${truncateUtf16Safe(serialized, 157)}...` : serialized;
259260
} catch {
260261
return null;
261262
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Parsing for the retired Web Push JSON stores: raw legacy file contents in,
2+
// validated domain shapes out. Doctor-only, split from
3+
// state-migrations.web-push.ts which owns detection/claiming/DB import.
4+
import { isRecord } from "@openclaw/normalization-core/record-coerce";
5+
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
6+
import {
7+
createWebPushVapidKeyPair,
8+
hashWebPushEndpoint,
9+
isValidWebPushEndpoint,
10+
isValidWebPushKey,
11+
DEFAULT_WEB_PUSH_VAPID_SUBJECT,
12+
type VapidKeyPair,
13+
type WebPushSubscription,
14+
} from "./push-web-store.js";
15+
16+
const SUBSCRIPTION_STORE_KEYS = new Set(["subscriptionsByEndpointHash"]);
17+
const SUBSCRIPTION_KEYS = new Set([
18+
"subscriptionId",
19+
"endpoint",
20+
"keys",
21+
"createdAtMs",
22+
"updatedAtMs",
23+
]);
24+
const PUSH_KEYS = new Set(["p256dh", "auth"]);
25+
const VAPID_KEYS = new Set(["publicKey", "privateKey", "subject"]);
26+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
27+
28+
function assertOnlyKeys(
29+
value: Record<string, unknown>,
30+
allowed: ReadonlySet<string>,
31+
label: string,
32+
) {
33+
const unexpected = Object.keys(value).find((key) => !allowed.has(key));
34+
if (unexpected) {
35+
throw new Error(`${label} has unexpected field ${unexpected}`);
36+
}
37+
}
38+
39+
export function parseLegacySubscriptions(raw: string): Map<string, WebPushSubscription> {
40+
const parsed = JSON.parse(raw) as unknown;
41+
if (!isRecord(parsed) || !isRecord(parsed.subscriptionsByEndpointHash)) {
42+
throw new Error("legacy Web Push subscriptions must be an object");
43+
}
44+
assertOnlyKeys(parsed, SUBSCRIPTION_STORE_KEYS, "legacy Web Push subscriptions store");
45+
46+
const subscriptions = new Map<string, WebPushSubscription>();
47+
const subscriptionIds = new Set<string>();
48+
for (const [endpointHash, rawSubscription] of Object.entries(
49+
parsed.subscriptionsByEndpointHash,
50+
)) {
51+
if (!isRecord(rawSubscription) || !isRecord(rawSubscription.keys)) {
52+
throw new Error("legacy Web Push subscription is not an object");
53+
}
54+
assertOnlyKeys(rawSubscription, SUBSCRIPTION_KEYS, "legacy Web Push subscription");
55+
assertOnlyKeys(rawSubscription.keys, PUSH_KEYS, "legacy Web Push subscription keys");
56+
const { subscriptionId, endpoint, createdAtMs, updatedAtMs } = rawSubscription;
57+
const p256dh = rawSubscription.keys.p256dh;
58+
const auth = rawSubscription.keys.auth;
59+
if (
60+
typeof subscriptionId !== "string" ||
61+
!UUID_RE.test(subscriptionId) ||
62+
typeof endpoint !== "string" ||
63+
!isValidWebPushEndpoint(endpoint) ||
64+
hashWebPushEndpoint(endpoint) !== endpointHash ||
65+
!isValidWebPushKey(p256dh) ||
66+
!isValidWebPushKey(auth) ||
67+
typeof createdAtMs !== "number" ||
68+
!Number.isSafeInteger(createdAtMs) ||
69+
createdAtMs < 0 ||
70+
typeof updatedAtMs !== "number" ||
71+
!Number.isSafeInteger(updatedAtMs) ||
72+
updatedAtMs < createdAtMs
73+
) {
74+
throw new Error("legacy Web Push subscription is invalid");
75+
}
76+
if (subscriptionIds.has(subscriptionId)) {
77+
throw new Error("legacy Web Push subscriptions contain a duplicate subscription id");
78+
}
79+
subscriptionIds.add(subscriptionId);
80+
subscriptions.set(endpointHash, {
81+
subscriptionId,
82+
endpoint,
83+
keys: { p256dh, auth },
84+
createdAtMs,
85+
updatedAtMs,
86+
});
87+
}
88+
return subscriptions;
89+
}
90+
91+
export function parseLegacyVapidKeys(raw: string, env: NodeJS.ProcessEnv): VapidKeyPair {
92+
const parsed = JSON.parse(raw) as unknown;
93+
if (!isRecord(parsed)) {
94+
throw new Error("legacy Web Push VAPID keys must be an object");
95+
}
96+
assertOnlyKeys(parsed, VAPID_KEYS, "legacy Web Push VAPID keys");
97+
if (parsed.subject !== undefined && typeof parsed.subject !== "string") {
98+
throw new Error("legacy Web Push VAPID keys are invalid");
99+
}
100+
const subject =
101+
normalizeOptionalString(parsed.subject) ??
102+
normalizeOptionalString(env.OPENCLAW_VAPID_SUBJECT) ??
103+
DEFAULT_WEB_PUSH_VAPID_SUBJECT;
104+
if (
105+
!isValidWebPushKey(parsed.publicKey) ||
106+
!isValidWebPushKey(parsed.privateKey) ||
107+
subject.length > 512
108+
) {
109+
throw new Error("legacy Web Push VAPID keys are invalid");
110+
}
111+
return createWebPushVapidKeyPair(parsed.publicKey, parsed.privateKey, subject);
112+
}

0 commit comments

Comments
 (0)