From 6e5eddf292cbf930902f1fd5d72c53f8f92c0a40 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 00:34:44 +0000 Subject: [PATCH] fix: avoid imessage rpc restart loop --- CHANGELOG.md | 6 +++ src/imessage/monitor/monitor-provider.ts | 3 ++ src/imessage/probe.test.ts | 41 ++++++++++++++++++++ src/imessage/probe.ts | 49 ++++++++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 src/imessage/probe.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 257f6da23..fa58f250b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,12 @@ - Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE. ### Fixes +- WhatsApp: default response prefix only for self-chat, using identity name when set. +- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel. +- iMessage: treat missing `imsg rpc` support as fatal to avoid restart loops. +- Auth: merge main auth profiles into per-agent stores for sub-agents and document inheritance. (#1013) — thanks @marcmarg. +- Agents: avoid JSON Schema `format` collisions in tool params by renaming snapshot format fields. (#1013) — thanks @marcmarg. +- Fix: make `clawdbot update` auto-update global installs when installed via a package manager. - Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj. - Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr. - Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen. diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 3377f85b5..02e3a4743 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -472,6 +472,9 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P check: async () => { const probe = await probeIMessage(2000, { cliPath, dbPath, runtime }); if (probe.ok) return { ok: true }; + if (probe.fatal) { + throw new Error(probe.error ?? "imsg rpc unavailable"); + } return { ok: false, error: probe.error ?? "unreachable" }; }, }); diff --git a/src/imessage/probe.test.ts b/src/imessage/probe.test.ts new file mode 100644 index 000000000..5a3e030e7 --- /dev/null +++ b/src/imessage/probe.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { probeIMessage } from "./probe.js"; + +const detectBinaryMock = vi.hoisted(() => vi.fn()); +const runCommandWithTimeoutMock = vi.hoisted(() => vi.fn()); +const createIMessageRpcClientMock = vi.hoisted(() => vi.fn()); + +vi.mock("../commands/onboard-helpers.js", () => ({ + detectBinary: (...args: unknown[]) => detectBinaryMock(...args), +})); + +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), +})); + +vi.mock("./client.js", () => ({ + createIMessageRpcClient: (...args: unknown[]) => createIMessageRpcClientMock(...args), +})); + +beforeEach(() => { + detectBinaryMock.mockReset().mockResolvedValue(true); + runCommandWithTimeoutMock.mockReset().mockResolvedValue({ + stdout: "", + stderr: 'unknown command "rpc" for "imsg"', + code: 1, + signal: null, + killed: false, + }); + createIMessageRpcClientMock.mockReset(); +}); + +describe("probeIMessage", () => { + it("marks unknown rpc subcommand as fatal", async () => { + const result = await probeIMessage(1000, { cliPath: "imsg" }); + expect(result.ok).toBe(false); + expect(result.fatal).toBe(true); + expect(result.error).toMatch(/rpc/i); + expect(createIMessageRpcClientMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/imessage/probe.ts b/src/imessage/probe.ts index d59f20512..b81a6f7f3 100644 --- a/src/imessage/probe.ts +++ b/src/imessage/probe.ts @@ -1,11 +1,13 @@ import { detectBinary } from "../commands/onboard-helpers.js"; import { loadConfig } from "../config/config.js"; +import { runCommandWithTimeout } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { createIMessageRpcClient } from "./client.js"; export type IMessageProbe = { ok: boolean; error?: string | null; + fatal?: boolean; }; export type IMessageProbeOptions = { @@ -14,6 +16,44 @@ export type IMessageProbeOptions = { runtime?: RuntimeEnv; }; +type RpcSupportResult = { + supported: boolean; + error?: string; + fatal?: boolean; +}; + +const rpcSupportCache = new Map(); + +async function probeRpcSupport(cliPath: string): Promise { + const cached = rpcSupportCache.get(cliPath); + if (cached) return cached; + try { + const result = await runCommandWithTimeout([cliPath, "rpc", "--help"], { timeoutMs: 2000 }); + const combined = `${result.stdout}\n${result.stderr}`.trim(); + const normalized = combined.toLowerCase(); + if (normalized.includes("unknown command") && normalized.includes("rpc")) { + const fatal = { + supported: false, + fatal: true, + error: 'imsg CLI does not support the "rpc" subcommand (update imsg)', + }; + rpcSupportCache.set(cliPath, fatal); + return fatal; + } + if (result.code === 0) { + const supported = { supported: true }; + rpcSupportCache.set(cliPath, supported); + return supported; + } + return { + supported: false, + error: combined || `imsg rpc --help failed (code ${String(result.code ?? "unknown")})`, + }; + } catch (err) { + return { supported: false, error: String(err) }; + } +} + export async function probeIMessage( timeoutMs = 2000, opts: IMessageProbeOptions = {}, @@ -26,6 +66,15 @@ export async function probeIMessage( return { ok: false, error: `imsg not found (${cliPath})` }; } + const rpcSupport = await probeRpcSupport(cliPath); + if (!rpcSupport.supported) { + return { + ok: false, + error: rpcSupport.error ?? "imsg rpc unavailable", + fatal: rpcSupport.fatal, + }; + } + const client = await createIMessageRpcClient({ cliPath, dbPath,