diff --git a/extensions/imessage/src/cli-path.ts b/extensions/imessage/src/cli-path.ts index 2f8f491f3688..6599f63aaf7c 100644 --- a/extensions/imessage/src/cli-path.ts +++ b/extensions/imessage/src/cli-path.ts @@ -15,23 +15,26 @@ const MACH_O_MAGICS = new Set([ "bfbafeca", ]); -function safeHomeDir(): string | undefined { - const home = process.env.HOME?.trim(); +export function resolveIMessageHomeDir(): string | undefined { + const configuredHome = process.env.HOME; + const home = configuredHome?.trim(); if (home) { return home; } try { - return os.homedir().trim() || undefined; + // On POSIX, os.homedir() echoes a defined blank HOME instead of querying the account. + const systemHome = configuredHome === undefined ? os.homedir() : os.userInfo().homedir; + return systemHome.trim() || undefined; } catch { return undefined; } } -function expandIMessageUserPath(value: string): string { +export function expandIMessageUserPath(value: string): string { if (!value.startsWith("~")) { return value; } - const home = safeHomeDir(); + const home = resolveIMessageHomeDir(); return home ? value.replace(/^~(?=$|[\\/])/, home) : value; } @@ -111,7 +114,7 @@ function isLikelyLocalIMessageCliPath(params: { cliPath: string; remoteHost?: st } function defaultMessagesDbPath(): string | undefined { - const home = safeHomeDir(); + const home = resolveIMessageHomeDir(); return home ? path.join(home, "Library", "Messages", "chat.db") : undefined; } diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index e2fcd6737266..a75b2ca878a8 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -1,6 +1,4 @@ // Imessage provider module implements model/runtime integration. -import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { resolveAgentConfig, resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-runtime"; @@ -53,6 +51,7 @@ import { resolveIMessageAccount } from "../accounts.js"; import { pollPendingIMessageApprovalReactions } from "../approval-reaction-poller.js"; import { maybeResolveIMessageApprovalReaction } from "../approval-reactions.js"; import { markIMessageChatRead, sendIMessageTyping } from "../chat.js"; +import { resolveIMessageHomeDir } from "../cli-path.js"; import { createIMessageRpcClient, type IMessageRpcClient } from "../client.js"; import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "../constants.js"; import { @@ -108,6 +107,7 @@ import { loadIMessageRecoveryCursor, resolveIMessageRecoveryCursorDbIdentity, } from "./recovery-cursor.js"; +import { detectRemoteHostFromCliPath } from "./remote-host.js"; import { normalizeAllowList, resolveRuntime } from "./runtime.js"; import { createSelfChatCache } from "./self-chat-cache.js"; import type { IMessageAttachment, IMessagePayload, MonitorIMessageOpts } from "./types.js"; @@ -190,48 +190,11 @@ function formatIMessageInboundMediaBody(params: { }); } -async function detectRemoteHostFromCliPath(cliPath: string): Promise { - try { - const expanded = cliPath.startsWith("~") - ? cliPath.replace(/^~/, process.env.HOME ?? "") - : cliPath; - const content = await fs.readFile(expanded, "utf8"); - - const userHostMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+)/); - if (userHostMatch) { - return userHostMatch[1]; - } - - const hostOnlyMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z][a-zA-Z0-9._-]*)\s+\S*\bimsg\b/); - return hostOnlyMatch?.[1]; - } catch (err) { - const code = (err as NodeJS.ErrnoException)?.code; - if (code !== "ENOENT" && code !== "ENOTDIR") { - logVerbose( - `imessage: failed to inspect cliPath ${cliPath} for remoteHost detection: ${String(err)}`, - ); - } - return undefined; - } -} - -function resolveLocalMessagesHomeDir(): string | undefined { - const home = process.env.HOME?.trim(); - if (home) { - return home; - } - try { - return os.homedir().trim() || undefined; - } catch { - return undefined; - } -} - function resolveLocalMessagesDbPath(dbPath: string): string { if (!dbPath.startsWith("~")) { return dbPath; } - const home = resolveLocalMessagesHomeDir(); + const home = resolveIMessageHomeDir(); return home ? path.join(home, dbPath.slice(1).replace(/^\/+/, "")) : dbPath; } @@ -255,7 +218,7 @@ function resolveIMessageWatchSourceDbPath(params: { if (cliPath !== "imsg" && path.basename(cliPath) !== "imsg") { return undefined; } - const home = resolveLocalMessagesHomeDir(); + const home = resolveIMessageHomeDir(); return home ? path.join(home, "Library", "Messages", "chat.db") : undefined; } diff --git a/extensions/imessage/src/monitor/recovery-cursor.ts b/extensions/imessage/src/monitor/recovery-cursor.ts index 2f870343e5cb..285b257aee80 100644 --- a/extensions/imessage/src/monitor/recovery-cursor.ts +++ b/extensions/imessage/src/monitor/recovery-cursor.ts @@ -6,8 +6,8 @@ // since_rowid for a different one, or repointing `dbPath`/`remoteHost` to a // lower-rowid database silently suppresses every row in it forever (#99638). import { createHash } from "node:crypto"; -import os from "node:os"; import path from "node:path"; +import { resolveIMessageHomeDir } from "../cli-path.js"; import { getIMessageRuntime } from "../runtime.js"; const IMESSAGE_RECOVERY_CURSOR_NAMESPACE = "imessage.recovery-cursor"; @@ -28,27 +28,12 @@ function openRecoveryCursorStore() { }); } -// Mirrors monitor-provider's local Messages home resolution (HOME first, then -// os.homedir) so the identity's default path matches the database the monitor -// actually watches. -function localMessagesHomeDir(): string | undefined { - const home = process.env.HOME?.trim(); - if (home) { - return home; - } - try { - return os.homedir().trim() || undefined; - } catch { - return undefined; - } -} - // Canonicalize a local chat.db path (expand a leading ~, then resolve) so the // implicit default and any explicit spelling of the same file share one identity. function normalizeLocalDbPath(dbPath: string): string { let resolved = dbPath.trim(); if (resolved.startsWith("~")) { - const home = localMessagesHomeDir(); + const home = resolveIMessageHomeDir(); if (home) { resolved = path.join(home, resolved.slice(1).replace(/^\/+/, "")); } @@ -84,7 +69,7 @@ export function resolveIMessageRecoveryCursorDbIdentity(params: { const cliPath = params.cliPath?.trim(); const isDefaultCli = !cliPath || cliPath === "imsg" || path.basename(cliPath) === "imsg"; if (isDefaultCli) { - const home = localMessagesHomeDir(); + const home = resolveIMessageHomeDir(); return home ? `local:${normalizeLocalDbPath(path.join(home, "Library", "Messages", "chat.db"))}` : "local:default"; diff --git a/extensions/imessage/src/monitor/remote-host.test.ts b/extensions/imessage/src/monitor/remote-host.test.ts new file mode 100644 index 000000000000..8bbf98b5e40c --- /dev/null +++ b/extensions/imessage/src/monitor/remote-host.test.ts @@ -0,0 +1,142 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { detectRemoteHostFromCliPath } from "./remote-host.js"; + +const execFileAsync = promisify(execFile); +const cliPathModuleUrl = new URL("../cli-path.ts", import.meta.url).href; +const userPathProbeSource = [ + 'import fs from "node:fs/promises";', + 'import os from "node:os";', + 'import path from "node:path";', + "const { expandIMessageUserPath } = await import(process.argv[1]);", + "const expanded = expandIMessageUserPath(process.argv[2]);", + "const content = await fs.readFile(expanded, 'utf8').catch(() => null);", + "process.stdout.write(JSON.stringify({", + " accountHome: os.userInfo().homedir,", + " expanded,", + " content,", + " systemHome: os.homedir(),", + "}));", +].join("\n"); + +type UserPathProbeResult = { + accountHome: string; + expanded: string; + content: string | null; + systemHome: string; +}; + +async function runUserPathProbe(params: { + cliPath: string; + home: string | undefined; + cwd?: string; +}): Promise { + const env = { ...process.env }; + if (params.home === undefined) { + delete env.HOME; + } else { + env.HOME = params.home; + } + + const { stdout } = await execFileAsync( + process.execPath, + ["--input-type=module", "--eval", userPathProbeSource, cliPathModuleUrl, params.cliPath], + { cwd: params.cwd, env }, + ); + return JSON.parse(stdout) as UserPathProbeResult; +} + +describe("detectRemoteHostFromCliPath", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + await Promise.all( + tempDirs.splice(0).map((dir) => fs.rm(dir, { force: true, recursive: true })), + ); + }); + + it("uses the system home when HOME is blank", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-imessage-home-")); + tempDirs.push(home); + vi.stubEnv("HOME", ""); + vi.spyOn(os, "userInfo").mockReturnValue({ ...os.userInfo(), homedir: home }); + const wrapperDir = path.join(home, ".openclaw"); + const wrapperPath = path.join(wrapperDir, "imsg-remote"); + await fs.mkdir(wrapperDir, { recursive: true }); + await fs.writeFile(wrapperPath, '#!/bin/sh\nexec ssh user@example.test imsg "$@"\n', "utf8"); + + await expect(detectRemoteHostFromCliPath("~/.openclaw/imsg-remote")).resolves.toBe( + "user@example.test", + ); + }); + + it.each([ + { label: "blank", home: "" }, + { label: "whitespace-only", home: " " }, + ])("uses the real OS account home when HOME is $label", async ({ home }) => { + const cliPath = `~/.openclaw/imsg-${randomUUID()}`; + const result = await runUserPathProbe({ cliPath, home }); + + expect(result.expanded).toBe(path.join(result.accountHome, cliPath.slice(2))); + expect(path.isAbsolute(result.expanded)).toBe(true); + }); + + it("preserves the system home when HOME is unset", async () => { + const cliPath = `~/.openclaw/imsg-${randomUUID()}`; + const result = await runUserPathProbe({ cliPath, home: undefined }); + + expect(result.expanded).toBe(path.join(result.systemHome, cliPath.slice(2))); + }); + + it("preserves an explicitly configured nonblank HOME", async () => { + const configuredHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-imessage-home-")); + tempDirs.push(configuredHome); + const cliPath = `~/.openclaw/imsg-${randomUUID()}`; + const result = await runUserPathProbe({ cliPath, home: configuredHome }); + + expect(result.expanded).toBe(path.join(configuredHome, cliPath.slice(2))); + }); + + it("never selects a working-directory tilde shadow when HOME is blank", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-imessage-shadow-")); + tempDirs.push(cwd); + const basename = `imsg-${randomUUID()}`; + const shadowPath = path.join(cwd, "~", ".openclaw", basename); + await fs.mkdir(path.dirname(shadowPath), { recursive: true }); + await fs.writeFile(shadowPath, "#!/bin/sh\nexec ssh rogue@example.test imsg\n", "utf8"); + + const result = await runUserPathProbe({ + cliPath: `~/.openclaw/${basename}`, + home: "", + cwd, + }); + + expect(result.expanded).toBe(path.join(result.accountHome, ".openclaw", basename)); + expect(result.content).toBeNull(); + }); + + it("preserves user-qualified and host-only SSH wrapper detection", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-imessage-wrapper-")); + tempDirs.push(dir); + const userWrapper = path.join(dir, "user-wrapper"); + const hostWrapper = path.join(dir, "host-wrapper"); + await fs.writeFile(userWrapper, "#!/bin/sh\nexec ssh user@example.test imsg\n", "utf8"); + await fs.writeFile(hostWrapper, "#!/bin/sh\nexec ssh -T messages-mac imsg\n", "utf8"); + + await expect(detectRemoteHostFromCliPath(userWrapper)).resolves.toBe("user@example.test"); + await expect(detectRemoteHostFromCliPath(hostWrapper)).resolves.toBe("messages-mac"); + }); + + it("returns undefined when the wrapper does not exist", async () => { + const missingWrapper = path.join(os.tmpdir(), `openclaw-imessage-missing-${randomUUID()}`); + + await expect(detectRemoteHostFromCliPath(missingWrapper)).resolves.toBeUndefined(); + }); +}); diff --git a/extensions/imessage/src/monitor/remote-host.ts b/extensions/imessage/src/monitor/remote-host.ts new file mode 100644 index 000000000000..8d690ff389c9 --- /dev/null +++ b/extensions/imessage/src/monitor/remote-host.ts @@ -0,0 +1,24 @@ +import fs from "node:fs/promises"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { expandIMessageUserPath } from "../cli-path.js"; + +export async function detectRemoteHostFromCliPath(cliPath: string): Promise { + try { + const content = await fs.readFile(expandIMessageUserPath(cliPath), "utf8"); + const userHostMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+)/); + if (userHostMatch) { + return userHostMatch[1]; + } + + const hostOnlyMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z][a-zA-Z0-9._-]*)\s+\S*\bimsg\b/); + return hostOnlyMatch?.[1]; + } catch (err) { + const code = (err as NodeJS.ErrnoException)?.code; + if (code !== "ENOENT" && code !== "ENOTDIR") { + logVerbose( + `imessage: failed to inspect cliPath ${cliPath} for remoteHost detection: ${String(err)}`, + ); + } + return undefined; + } +}