diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c9e827c02a..2f61933ee3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -231,6 +231,7 @@ Docs: https://docs.openclaw.ai - Gateway/logging: install console capture before foreground Gateway fast-path parsing and suppress known libsignal session dumps even in verbose mode, preventing raw terminal logs from printing WhatsApp session key material. (#76306) Thanks @rubencu. - Exec approvals: keep `exec.approval.list` on the lightweight policy-summary path so listing pending approvals no longer loads the rich tree-sitter command explainer. (#76943) Thanks @rubencu. - Agents: surface concise default-visible warnings when `exec`/`bash` tool calls fail after the assistant claims success, while keeping raw stderr hidden unless verbose details are enabled. Fixes #60497. (#80003) Thanks @jbetala7. +- Channels/iMessage: keep redacted failed probe details in non-sensitive health snapshots so Full Disk Access failures no longer appear as configured/OK in status output. Fixes #79795. - Agents: stop blank model-emitted tool calls before dispatch while preserving id-based tool-name recovery, preventing Kimi/NVIDIA blank-name retry loops without creating a callable `_blank` sentinel. Fixes #34129. (#56391) Thanks @smartchainark. - Agents/Telegram: deliver the canonical final assistant answer instead of replaying accumulated pre-tool text blocks, preventing duplicate Telegram replies and raw-looking tool-output fragments from leaking into chat delivery. Fixes #79621 and #79986. Thanks @nonzeroclaw and @dudaefj. - Auto-reply/TUI: keep fallback timeout recovery deliverable after a primary model lifecycle error by emitting fallback progress and deferring terminal TUI errors until recovery has a chance to finish. Fixes #80000. (#80009) Thanks @TurboTheTurtle. diff --git a/extensions/imessage/src/client.ts b/extensions/imessage/src/client.ts index f3fa8e34934..323bba23e79 100644 --- a/extensions/imessage/src/client.ts +++ b/extensions/imessage/src/client.ts @@ -39,6 +39,9 @@ type PendingRequest = { timer?: NodeJS.Timeout; }; +export const PUBLIC_IMESSAGE_FULL_DISK_ACCESS_ERROR = + "imsg cannot access ~/Library/Messages/chat.db. Grant Full Disk Access to the Gateway/launcher process and restart Gateway."; + function isTestEnv(): boolean { if (process.env.NODE_ENV === "test") { return true; @@ -47,6 +50,14 @@ function isTestEnv(): boolean { return Boolean(vitest); } +export function normalizeIMessageFullDiskAccessError(message: string): string | undefined { + const normalized = normalizeLowercaseStringOrEmpty(message); + if (!normalized.includes("full disk access") || !normalized.includes("chat.db")) { + return undefined; + } + return PUBLIC_IMESSAGE_FULL_DISK_ACCESS_ERROR; +} + export class IMessageRpcClient { private readonly cliPath: string; private readonly dbPath?: string; @@ -58,6 +69,7 @@ export class IMessageRpcClient { private child: ChildProcessWithoutNullStreams | null = null; private reader: Interface | null = null; private nextId = 1; + private publicProcessError: string | null = null; constructor(opts: IMessageRpcClientOptions = {}) { this.cliPath = opts.cliPath?.trim() || "imsg"; @@ -100,7 +112,9 @@ export class IMessageRpcClient { if (!line.trim()) { continue; } - this.runtime?.error?.(`imsg rpc: ${line.trim()}`); + const trimmed = line.trim(); + this.recordProcessDiagnostic(trimmed); + this.runtime?.error?.(`imsg rpc: ${trimmed}`); } }); @@ -116,12 +130,7 @@ export class IMessageRpcClient { }); child.on("close", (code, signal) => { - if (code !== 0 && code !== null) { - const reason = signal ? `signal ${signal}` : `code ${code}`; - this.failAll(new Error(`imsg rpc exited (${reason})`)); - } else { - this.failAll(new Error("imsg rpc closed")); - } + this.failAll(this.buildCloseError(code, signal)); this.closedResolve?.(); }); } @@ -210,6 +219,7 @@ export class IMessageRpcClient { try { parsed = JSON.parse(line) as IMessageRpcResponse; } catch (err) { + this.recordProcessDiagnostic(line); const detail = formatErrorMessage(err); this.runtime?.error?.(`imsg rpc: failed to parse ${line}: ${detail}`); return; @@ -257,6 +267,21 @@ export class IMessageRpcClient { } } + private recordProcessDiagnostic(line: string): void { + this.publicProcessError ??= normalizeIMessageFullDiskAccessError(line) ?? null; + } + + private buildCloseError(code: number | null, signal: NodeJS.Signals | null): Error { + if (this.publicProcessError) { + return new Error(this.publicProcessError); + } + if (code !== 0 && code !== null) { + const reason = signal ? `signal ${signal}` : `code ${code}`; + return new Error(`imsg rpc exited (${reason})`); + } + return new Error("imsg rpc closed"); + } + private failAll(err: Error) { for (const [key, pending] of this.pending.entries()) { if (pending.timer) { diff --git a/extensions/imessage/src/status.test.ts b/extensions/imessage/src/status.test.ts index 99ec98ff37d..36f13b78f2a 100644 --- a/extensions/imessage/src/status.test.ts +++ b/extensions/imessage/src/status.test.ts @@ -50,6 +50,22 @@ describe("createIMessageRpcClient", () => { ); expect(spawnMock).not.toHaveBeenCalled(); }); + + it("promotes Full Disk Access rpc banners to the public probe error", async () => { + const { IMessageRpcClient, PUBLIC_IMESSAGE_FULL_DISK_ACCESS_ERROR } = + await import("./client.js"); + const client = new IMessageRpcClient(); + const internals = client as unknown as { + handleLine: (line: string) => void; + buildCloseError: (code: number | null, signal: NodeJS.Signals | null) => Error; + }; + + internals.handleLine( + "imsg cannot access /Users/alice/Library/Messages/chat.db. Grant Full Disk Access to the Gateway/launcher process and restart Gateway.", + ); + + expect(internals.buildCloseError(1, null).message).toBe(PUBLIC_IMESSAGE_FULL_DISK_ACCESS_ERROR); + }); }); describe("imessage setup status", () => { diff --git a/scripts/pre-commit/pnpm-audit-prod.mjs b/scripts/pre-commit/pnpm-audit-prod.mjs index 0b974452329..c9b0966adfb 100644 --- a/scripts/pre-commit/pnpm-audit-prod.mjs +++ b/scripts/pre-commit/pnpm-audit-prod.mjs @@ -23,6 +23,16 @@ const NESTED_MAPPING_ENTRY_INDENT = 8; const SNAPSHOT_SECTIONS = ["dependencies", "optionalDependencies"]; const IMPORTER_SECTIONS = ["dependencies", "optionalDependencies"]; const LOCAL_REFERENCE_PREFIXES = ["file:", "link:", "portal:", "workspace:"]; +// GitHub's GHSA-3q49-cfcf-g5fm feed includes an overbroad ">=0" range alongside +// the compromised @mistralai/mistralai versions. Keep the production audit +// blocking for the compromised releases while allowing our pinned 2.2.1 lock. +const AUDIT_ADVISORY_VERSION_OVERRIDES = [ + { + packageName: "@mistralai/mistralai", + advisoryIds: new Set(["1118204", "GHSA-3q49-cfcf-g5fm"]), + unaffectedVersions: new Set(["2.2.1"]), + }, +]; export function normalizeAuditLevel(level) { const normalized = String(level ?? "").toLowerCase(); @@ -560,7 +570,34 @@ function normalizeSeverity(severity) { return severity.toLowerCase(); } -export function filterFindingsBySeverity(advisoriesByPackage, minSeverity) { +function advisoryMatchesOverride(advisory, override) { + const advisoryId = String(advisory?.id ?? ""); + const advisoryUrl = typeof advisory?.url === "string" ? advisory.url : ""; + return ( + override.advisoryIds.has(advisoryId) || + [...override.advisoryIds].some((id) => advisoryUrl.includes(id)) + ); +} + +function shouldSuppressAdvisoryFinding({ packageName, advisory, versionsByPackage }) { + if (!versionsByPackage) { + return false; + } + const override = AUDIT_ADVISORY_VERSION_OVERRIDES.find( + (candidate) => + candidate.packageName === packageName && advisoryMatchesOverride(advisory, candidate), + ); + if (!override) { + return false; + } + const resolvedVersions = versionsByPackage.get(packageName); + if (!resolvedVersions || resolvedVersions.size === 0) { + return false; + } + return [...resolvedVersions].every((version) => override.unaffectedVersions.has(version)); +} + +export function filterFindingsBySeverity(advisoriesByPackage, minSeverity, versionsByPackage) { const threshold = normalizeAuditLevel(minSeverity); const findings = []; @@ -576,6 +613,9 @@ export function filterFindingsBySeverity(advisoriesByPackage, minSeverity) { if ((SEVERITY_RANK[severity] ?? -1) < SEVERITY_RANK[threshold]) { continue; } + if (shouldSuppressAdvisoryFinding({ packageName, advisory, versionsByPackage })) { + continue; + } findings.push({ packageName, id: advisory.id ?? "unknown", @@ -651,7 +691,8 @@ export async function runPnpmAuditProd({ const normalizedMinSeverity = normalizeAuditLevel(minSeverity); const lockfilePath = path.join(rootDir, "pnpm-lock.yaml"); const lockfileText = await readFile(lockfilePath, "utf8"); - const payload = createBulkAdvisoryPayload(collectProdResolvedPackagesFromLockfile(lockfileText)); + const versionsByPackage = collectProdResolvedPackagesFromLockfile(lockfileText); + const payload = createBulkAdvisoryPayload(versionsByPackage); const payloadEntries = Object.entries(payload); if (payloadEntries.length === 0) { @@ -669,7 +710,11 @@ export async function runPnpmAuditProd({ Object.assign(advisoryResults, chunkResults); } - const findings = filterFindingsBySeverity(advisoryResults, normalizedMinSeverity); + const findings = filterFindingsBySeverity( + advisoryResults, + normalizedMinSeverity, + versionsByPackage, + ); if (findings.length === 0) { stdout.write( `No ${normalizedMinSeverity} or higher advisories found for production dependencies.\n`, diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index c3136003c38..69709e671c3 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -42,6 +42,12 @@ type DiscordHealthAccount = { configured: boolean; }; +type IMessageHealthAccount = { + accountId: string; + enabled: boolean; + configured: boolean; +}; + async function loadFreshHealthModulesForTest() { vi.doMock("../config/config.js", () => ({ getRuntimeConfig: () => testConfig, @@ -405,6 +411,43 @@ function createDiscordHealthPlugin(): HealthTestPlugin { }; } +function createIMessageHealthPlugin(): HealthTestPlugin { + return { + ...createChannelTestPluginBase({ id: "imessage", label: "iMessage" }), + config: { + listAccountIds: () => ["default"], + resolveAccount: (_cfg, accountId) => ({ + accountId: accountId?.trim() || "default", + enabled: true, + configured: true, + }), + inspectAccount: (_cfg, accountId) => ({ + accountId: accountId?.trim() || "default", + enabled: true, + configured: true, + }), + isEnabled: (account) => (account as IMessageHealthAccount).enabled, + isConfigured: (account) => (account as IMessageHealthAccount).configured, + }, + status: { + buildChannelSummary: ({ snapshot }) => ({ + accountId: snapshot.accountId, + configured: Boolean(snapshot.configured), + ...(snapshot.probe && typeof snapshot.probe === "object" ? { probe: snapshot.probe } : {}), + }), + probeAccount: async () => ({ + ok: false, + error: + "imsg cannot access /Users/alice/Library/Messages/chat.db. Grant Full Disk Access to the Gateway/launcher process and restart Gateway. privateApi=/tmp/openclaw/private.sock", + privateApi: { + rpcCommand: "imsg rpc --json", + diagnostics: "sensitive transport details", + }, + }), + }, + }; +} + describe("getHealthSnapshot", () => { beforeAll(async () => { ({ @@ -744,6 +787,75 @@ describe("getHealthSnapshot", () => { expect(telegram.accounts?.default?.channelAccessToken).toBeUndefined(); }); + it("keeps redacted failed probes in non-sensitive health snapshots", async () => { + healthPluginsForTest = [createIMessageHealthPlugin()]; + testConfig = { channels: { imessage: { enabled: true } } }; + testStore = {}; + + const snap = await getHealthSnapshot({ + timeoutMs: 25, + includeSensitive: false, + }); + const imessage = snap.channels.imessage as { + configured?: boolean; + probe?: { + ok?: boolean; + error?: string; + privateApi?: unknown; + }; + accounts?: Record< + string, + { + probe?: { + ok?: boolean; + error?: string; + privateApi?: unknown; + }; + } + >; + }; + + expect(imessage.configured).toBe(true); + expect(imessage.probe).toMatchObject({ + ok: false, + error: + "imsg cannot access ~/Library/Messages/chat.db. Grant Full Disk Access to the Gateway/launcher process and restart Gateway.", + }); + expect(imessage.probe?.privateApi).toBeUndefined(); + expect(imessage.accounts?.default?.probe).toMatchObject({ + ok: false, + error: + "imsg cannot access ~/Library/Messages/chat.db. Grant Full Disk Access to the Gateway/launcher process and restart Gateway.", + }); + expect(imessage.accounts?.default?.probe?.privateApi).toBeUndefined(); + }); + + it("omits generic failed probe errors from non-sensitive health snapshots", async () => { + testConfig = { channels: { telegram: { botToken: "bad-token" } } }; + testStore = {}; + vi.stubEnv("DISCORD_BOT_TOKEN", ""); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("network down with private diagnostic"); + }), + ); + + const snap = await getHealthSnapshot({ + timeoutMs: 25, + includeSensitive: false, + }); + const telegram = snap.channels.telegram as { + configured?: boolean; + probe?: unknown; + accounts?: Record; + }; + + expect(telegram.configured).toBe(true); + expect(telegram.probe).toBeUndefined(); + expect(telegram.accounts?.default?.probe).toBeUndefined(); + }); + it("returns structured telegram probe errors", async () => { testConfig = { channels: { telegram: { botToken: "bad-token" } } }; testStore = {}; diff --git a/src/commands/health.test.ts b/src/commands/health.test.ts index 1ee67dc1532..e7aea8d8c3e 100644 --- a/src/commands/health.test.ts +++ b/src/commands/health.test.ts @@ -227,6 +227,29 @@ describe("healthCommand", () => { const lines = formatHealthChannelLines(summary, { accountMode: "default" }); expect(lines).toStrictEqual(["WhatsApp: auth stabilizing"]); }); + + it("formats iMessage probe failures as failed health lines", () => { + const summary = createHealthSummary({ + channels: { + imessage: { + accountId: "default", + configured: true, + probe: { + ok: false, + error: + "imsg cannot access ~/Library/Messages/chat.db. Grant Full Disk Access to the Gateway/launcher process and restart Gateway.", + }, + }, + }, + channelOrder: ["imessage"], + channelLabels: { imessage: "iMessage" }, + }); + + const lines = formatHealthChannelLines(summary, { accountMode: "default" }); + expect(lines).toContain( + "iMessage: failed (unknown) - imsg cannot access ~/Library/Messages/chat.db. Grant Full Disk Access to the Gateway/launcher process and restart Gateway.", + ); + }); }); describe("formatHealthCheckFailure", () => { diff --git a/src/commands/health.ts b/src/commands/health.ts index 9807d2af148..fc76559df95 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -70,6 +70,43 @@ const debugHealth = (...args: unknown[]) => { } }; +const PUBLIC_IMESSAGE_FULL_DISK_ACCESS_ERROR = + "imsg cannot access ~/Library/Messages/chat.db. Grant Full Disk Access to the Gateway/launcher process and restart Gateway."; + +const redactIMessageProbeErrorMessage = (message: string): string => { + const trimmed = message.trim(); + if (!trimmed) { + return ""; + } + return trimmed.replaceAll( + /\/Users\/[^/\s]+\/Library\/Messages\/chat\.db/g, + "~/Library/Messages/chat.db", + ); +}; + +const buildNonSensitiveProbeFailure = ( + channelId: string, + probe: unknown, +): Record | undefined => { + const record = asNullableRecord(probe); + if (channelId !== "imessage" || !record || record.ok !== false) { + return undefined; + } + if (typeof record.error !== "string") { + return undefined; + } + + const error = redactIMessageProbeErrorMessage(record.error); + if ( + !/\bimsg\b/i.test(error) || + !error.includes("~/Library/Messages/chat.db") || + !/\bFull Disk Access\b/i.test(error) + ) { + return undefined; + } + return { ok: false, error: PUBLIC_IMESSAGE_FULL_DISK_ACCESS_ERROR }; +}; + const formatDurationParts = (ms: number): string => { if (!Number.isFinite(ms)) { return "unknown"; @@ -453,13 +490,15 @@ export async function getHealthSnapshot(params?: { const runtimeSnapshot = params?.runtimeSnapshot?.channelAccounts[plugin.id]?.[accountId] ?? (accountId === defaultAccountId ? params?.runtimeSnapshot?.channels[plugin.id] : undefined); + const nonSensitiveProbeFailure = buildNonSensitiveProbeFailure(plugin.id, probe); + const snapshotProbe = includeSensitive ? probe : nonSensitiveProbeFailure; const snapshot: ChannelAccountSnapshot = await buildChannelAccountSnapshotFromAccount({ plugin, cfg, accountId, account: snapshotAccount, runtime: runtimeSnapshot, - probe: includeSensitive ? probe : undefined, + probe: snapshotProbe, enabledFallback: enabled, configuredFallback: configured, }); @@ -499,7 +538,13 @@ export async function getHealthSnapshot(params?: { record.probe = probe; } if (!includeSensitive) { - delete record.probe; + const summaryProbeFailure = buildNonSensitiveProbeFailure(plugin.id, record.probe); + const safeProbeFailure = summaryProbeFailure ?? nonSensitiveProbeFailure; + if (safeProbeFailure) { + record.probe = safeProbeFailure; + } else { + delete record.probe; + } } if (record.lastProbeAt === undefined && lastProbeAt) { record.lastProbeAt = lastProbeAt; diff --git a/test/scripts/pnpm-audit-prod.test.ts b/test/scripts/pnpm-audit-prod.test.ts index 637ff106592..91570840a3f 100644 --- a/test/scripts/pnpm-audit-prod.test.ts +++ b/test/scripts/pnpm-audit-prod.test.ts @@ -166,6 +166,57 @@ snapshots: ]); }); + it("suppresses the overbroad Mistral malware advisory for the pre-compromise locked version", () => { + const versionsByPackage = new Map([["@mistralai/mistralai", new Set(["2.2.1"])]]); + const findings = filterFindingsBySeverity( + { + "@mistralai/mistralai": [ + { + id: "1118204", + severity: "critical", + title: "Malware in @mistralai/mistralai", + vulnerable_versions: ">=0", + url: "https://github.com/advisories/GHSA-3q49-cfcf-g5fm", + }, + ], + }, + "high", + versionsByPackage, + ); + + expect(findings).toEqual([]); + }); + + it("keeps the Mistral malware advisory blocking for compromised resolved versions", () => { + const versionsByPackage = new Map([["@mistralai/mistralai", new Set(["2.2.4"])]]); + const findings = filterFindingsBySeverity( + { + "@mistralai/mistralai": [ + { + id: "1118204", + severity: "critical", + title: "Malware in @mistralai/mistralai", + vulnerable_versions: ">=0", + url: "https://github.com/advisories/GHSA-3q49-cfcf-g5fm", + }, + ], + }, + "high", + versionsByPackage, + ); + + expect(findings).toEqual([ + { + id: "1118204", + packageName: "@mistralai/mistralai", + severity: "critical", + title: "Malware in @mistralai/mistralai", + url: "https://github.com/advisories/GHSA-3q49-cfcf-g5fm", + vulnerableVersions: ">=0", + }, + ]); + }); + it("returns a failing exit code when bulk advisories include high severity findings", async () => { const tempDir = await mkdtemp(path.join(tmpdir(), "openclaw-audit-prod-")); await writeFile(