Files
openclaw/extensions/imessage/src/cli-path.ts
LZY3538 903d8bc9d5 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>
2026-07-27 17:14:38 -04:00

149 lines
4.4 KiB
TypeScript

// Imessage plugin module classifies CLI and Messages database locality.
import { constants, accessSync, readFileSync, realpathSync } from "node:fs";
import os from "node:os";
import path from "node:path";
const localCliPathCache = new Map<string, boolean>();
const MACH_O_MAGICS = new Set([
"feedface",
"feedfacf",
"cefaedfe",
"cffaedfe",
"cafebabe",
"cafebabf",
"bebafeca",
"bfbafeca",
]);
export function resolveIMessageHomeDir(): string | undefined {
const configuredHome = process.env.HOME;
const home = configuredHome?.trim();
if (home) {
return home;
}
try {
// 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;
}
}
export function expandIMessageUserPath(value: string): string {
if (!value.startsWith("~")) {
return value;
}
const home = resolveIMessageHomeDir();
return home ? value.replace(/^~(?=$|[\\/])/, home) : value;
}
function resolveIMessageExecutable(cliPath: string): string | undefined {
const expanded = expandIMessageUserPath(cliPath);
if (expanded.includes(path.sep)) {
return expanded;
}
for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
if (!directory) {
continue;
}
const candidate = path.join(directory, expanded);
try {
accessSync(candidate, constants.X_OK);
return candidate;
} catch {
// Continue through PATH until an executable candidate is found.
}
}
return undefined;
}
function isMachOExecutable(filePath: string): boolean {
try {
return MACH_O_MAGICS.has(readFileSync(realpathSync(filePath)).subarray(0, 4).toString("hex"));
} catch {
return false;
}
}
function isProvenLocalIMessageCliPath(params: { cliPath: string; remoteHost?: string }): boolean {
if (params.remoteHost?.trim()) {
return false;
}
const cliPath = params.cliPath.trim();
const cacheKey = `${cliPath}\0${process.env.PATH ?? ""}`;
const cached = localCliPathCache.get(cacheKey);
if (cached !== undefined) {
return cached;
}
const executable = resolveIMessageExecutable(cliPath);
let local = executable ? isMachOExecutable(executable) : false;
if (executable && !local) {
try {
const wrapper = readFileSync(realpathSync(executable), "utf8");
const match = wrapper.match(
/^#![^\r\n]+\r?\n\s*exec\s+(?:"([^"]+)"|'([^']+)'|(\S+))\s+"\$@"\s*$/u,
);
const target = match?.[1] ?? match?.[2] ?? match?.[3];
local = Boolean(target && path.isAbsolute(target) && isMachOExecutable(target));
} catch {
local = false;
}
}
// CLI installation is process-stable channel metadata; avoid repeated file reads.
localCliPathCache.set(cacheKey, local);
return local;
}
function isLikelyLocalIMessageCliPath(params: { cliPath: string; remoteHost?: string }): boolean {
if (params.remoteHost?.trim()) {
return false;
}
const cliPath = params.cliPath.trim();
if (cliPath === "imsg") {
return true;
}
if (path.basename(cliPath) !== "imsg") {
return false;
}
try {
return !/\bssh\b[\s\S]*\bimsg\b/u.test(readFileSync(expandIMessageUserPath(cliPath), "utf8"));
} catch {
return true;
}
}
function defaultMessagesDbPath(): string | undefined {
const home = resolveIMessageHomeDir();
return home ? path.join(home, "Library", "Messages", "chat.db") : undefined;
}
export function resolveIMessageChatDbLookupPath(params: {
cliPath: string;
dbPath?: string;
remoteHost?: string;
}): string | undefined {
const configured = params.dbPath?.trim();
if (configured) {
return configured;
}
// Receipt recovery is best effort and preserves the shipped wrapper heuristic.
if (!isLikelyLocalIMessageCliPath({ cliPath: params.cliPath, remoteHost: params.remoteHost })) {
return undefined;
}
return defaultMessagesDbPath();
}
export function resolveLocalIMessageChatDbPath(params: {
cliPath: string;
dbPath?: string;
remoteHost?: string;
}): string | undefined {
// Authorization may use chat.db only after positively attesting a local imsg executable.
if (!isProvenLocalIMessageCliPath({ cliPath: params.cliPath, remoteHost: params.remoteHost })) {
return undefined;
}
const configured = params.dbPath?.trim();
return configured ? expandIMessageUserPath(configured) : defaultMessagesDbPath();
}