fix(imessage): detect remote wrappers when HOME is blank (#111715)

* fix(imessage): detect remote hosts when HOME is blank

* fix(imessage): resolve blank HOME in SSH wrappers (#111715)

Resolve explicitly blank or whitespace HOME values from the operating-system account, preserve configured and unset home contracts, and share the canonical resolver with the iMessage monitor and recovery cursor. Add unmocked subprocess coverage for account-home resolution and working-directory tilde shadows.

Co-authored-by: LZY3538 <liu.zhenye@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
LZY3538
2026-07-28 05:14:38 +08:00
committed by GitHub
parent 960b050add
commit 903d8bc9d5
5 changed files with 182 additions and 65 deletions
+9 -6
View File
@@ -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;
}
@@ -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<string | undefined> {
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;
}
@@ -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";
@@ -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<UserPathProbeResult> {
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();
});
});
@@ -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<string | undefined> {
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;
}
}