mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user