From 1e297de73495db72e5d14e49651db4b30b20cd70 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 00:36:52 +0000 Subject: [PATCH] fix: stage iMessage remote attachments (#1054) (thanks @tyler6204) --- CHANGELOG.md | 1 + docs/channels/imessage.md | 6 +- docs/gateway/configuration.md | 4 +- src/auto-reply/reply/stage-sandbox-media.ts | 66 +++++++-------------- src/auto-reply/templating.ts | 2 +- src/config/types.imessage.ts | 2 +- src/imessage/monitor/monitor-provider.ts | 56 +++++++++++++---- src/media/remote.test.ts | 25 ++++++++ src/media/remote.ts | 44 ++++++++++++++ 9 files changed, 145 insertions(+), 61 deletions(-) create mode 100644 src/media/remote.test.ts create mode 100644 src/media/remote.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3672c73be..093c50485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ - Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr. - Security: bump `tar` to 7.5.3. - Models: align ZAI thinking toggles. +- iMessage: fetch remote attachments for SSH wrappers and document `remoteHost`. (#1054) — thanks @tyler6204. ## 2026.1.15 diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index d36ed22ef..565124df5 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -108,7 +108,7 @@ If you want iMessage on another Mac, set `channels.imessage.cliPath` to a wrappe Example wrapper: ```bash #!/usr/bin/env bash -exec ssh -T mac-mini imsg "$@" +exec ssh -T gateway-host imsg "$@" ``` **Remote attachments:** When `cliPath` points to a remote host via SSH, attachment paths in the Messages database reference files on the remote machine. Clawdbot can automatically fetch these over SCP by setting `channels.imessage.remoteHost`: @@ -118,7 +118,7 @@ exec ssh -T mac-mini imsg "$@" channels: { imessage: { cliPath: "~/imsg-ssh", // SSH wrapper to remote Mac - remoteHost: "clawdbot@192.168.64.3", // for SCP file transfer + remoteHost: "user@gateway-host", // for SCP file transfer includeAttachments: true } } @@ -198,7 +198,7 @@ Provider options: - `channels.imessage.enabled`: enable/disable channel startup. - `channels.imessage.cliPath`: path to `imsg`. - `channels.imessage.dbPath`: Messages DB path. -- `channels.imessage.remoteHost`: SSH host for SCP attachment transfer when `cliPath` points to a remote Mac (e.g., `clawdbot@192.168.64.3`). Auto-detected from SSH wrapper if not set. +- `channels.imessage.remoteHost`: SSH host for SCP attachment transfer when `cliPath` points to a remote Mac (e.g., `user@gateway-host`). Auto-detected from SSH wrapper if not set. - `channels.imessage.service`: `imessage | sms | auto`. - `channels.imessage.region`: SMS region. - `channels.imessage.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index ddae75a13..206a5e9ff 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1206,6 +1206,7 @@ Clawdbot spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required. enabled: true, cliPath: "imsg", dbPath: "~/Library/Messages/chat.db", + remoteHost: "user@gateway-host", // SCP for remote attachments when using SSH wrapper dmPolicy: "pairing", // pairing | allowlist | open | disabled allowFrom: ["+15555550123", "user@example.com", "chat_id:123"], historyLimit: 50, // include last N group messages as context (0 disables) @@ -1225,11 +1226,12 @@ Notes: - The first send will prompt for Messages automation permission. - Prefer `chat_id:` targets. Use `imsg chats --limit 20` to list chats. - `channels.imessage.cliPath` can point to a wrapper script (e.g. `ssh` to another Mac that runs `imsg rpc`); use SSH keys to avoid password prompts. +- For remote SSH wrappers, set `channels.imessage.remoteHost` to fetch attachments via SCP when `includeAttachments` is enabled. Example wrapper: ```bash #!/usr/bin/env bash -exec ssh -T mac-mini "imsg rpc" +exec ssh -T gateway-host imsg "$@" ``` ### `agents.defaults.workspace` diff --git a/src/auto-reply/reply/stage-sandbox-media.ts b/src/auto-reply/reply/stage-sandbox-media.ts index a2944a4c9..cb7f27827 100644 --- a/src/auto-reply/reply/stage-sandbox-media.ts +++ b/src/auto-reply/reply/stage-sandbox-media.ts @@ -1,11 +1,12 @@ -import { spawn } from "node:child_process"; +import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { logVerbose } from "../../globals.js"; -import { CONFIG_DIR } from "../../utils.js"; +import { copyRemoteFileViaScp } from "../../media/remote.js"; +import { ensureMediaDir } from "../../media/store.js"; import type { MsgContext, TemplateContext } from "../templating.js"; export async function stageSandboxMedia(params: { @@ -31,11 +32,8 @@ export async function stageSandboxMedia(params: { sessionKey, workspaceDir, }); - - // For remote attachments without sandbox, use ~/.clawdbot/media (not agent workspace for privacy) - const remoteMediaCacheDir = ctx.MediaRemoteHost ? path.join(CONFIG_DIR, "media", "remote-cache", sessionKey) : null; - const effectiveWorkspaceDir = sandbox?.workspaceDir ?? remoteMediaCacheDir; - if (!effectiveWorkspaceDir) return; + const usesRemoteMedia = Boolean(ctx.MediaRemoteHost); + if (!sandbox && !usesRemoteMedia) return; const resolveAbsolutePath = (value: string): string | null => { let resolved = value.trim(); @@ -52,8 +50,9 @@ export async function stageSandboxMedia(params: { }; try { - // For sandbox: /media/inbound, for remote cache: use dir directly - const destDir = sandbox ? path.join(effectiveWorkspaceDir, "media", "inbound") : effectiveWorkspaceDir; + const destDir = sandbox + ? path.join(sandbox.workspaceDir, "media", "inbound") + : path.join(await ensureMediaDir(), "inbound"); await fs.mkdir(destDir, { recursive: true }); const usedNames = new Set(); @@ -68,21 +67,29 @@ export async function stageSandboxMedia(params: { if (!baseName) continue; const parsed = path.parse(baseName); let fileName = baseName; - let suffix = 1; - while (usedNames.has(fileName)) { - fileName = `${parsed.name}-${suffix}${parsed.ext}`; - suffix += 1; + if (!sandbox && usesRemoteMedia) { + fileName = `${parsed.name}-${crypto.randomUUID()}${parsed.ext}`; + } else { + let suffix = 1; + while (usedNames.has(fileName)) { + fileName = `${parsed.name}-${suffix}${parsed.ext}`; + suffix += 1; + } } usedNames.add(fileName); const dest = path.join(destDir, fileName); if (ctx.MediaRemoteHost) { // Always use SCP when remote host is configured - local paths refer to remote machine - await scpFile(ctx.MediaRemoteHost, source, dest); + await copyRemoteFileViaScp({ + remoteHost: ctx.MediaRemoteHost, + remotePath: source, + localPath: dest, + }); } else { await fs.copyFile(source, dest); } - // For sandbox use relative path, for remote cache use absolute path + // For sandbox use relative path, otherwise use absolute path const stagedPath = sandbox ? path.posix.join("media", "inbound", fileName) : dest; staged.set(source, stagedPath); } @@ -124,32 +131,3 @@ export async function stageSandboxMedia(params: { logVerbose(`Failed to stage inbound media for sandbox: ${String(err)}`); } } - -async function scpFile(remoteHost: string, remotePath: string, localPath: string): Promise { - return new Promise((resolve, reject) => { - const child = spawn( - "/usr/bin/scp", - [ - "-o", - "BatchMode=yes", - "-o", - "StrictHostKeyChecking=accept-new", - `${remoteHost}:${remotePath}`, - localPath, - ], - { stdio: ["ignore", "ignore", "pipe"] }, - ); - - let stderr = ""; - child.stderr?.setEncoding("utf8"); - child.stderr?.on("data", (chunk) => { - stderr += chunk; - }); - - child.once("error", reject); - child.once("exit", (code) => { - if (code === 0) resolve(); - else reject(new Error(`scp failed (${code}): ${stderr.trim()}`)); - }); - }); -} diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 721a10eb2..1b8260ffd 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -38,7 +38,7 @@ export type MsgContext = { MediaPaths?: string[]; MediaUrls?: string[]; MediaTypes?: string[]; - /** Remote host for SCP when media lives on a different machine (e.g., clawdbot@192.168.64.3). */ + /** Internal: remote host for inbound media fetch (iMessage SSH wrappers). */ MediaRemoteHost?: string; Transcript?: string; ChatType?: string; diff --git a/src/config/types.imessage.ts b/src/config/types.imessage.ts index 37e4c5453..9b7e91a54 100644 --- a/src/config/types.imessage.ts +++ b/src/config/types.imessage.ts @@ -14,7 +14,7 @@ export type IMessageAccountConfig = { cliPath?: string; /** Optional Messages db path override. */ dbPath?: string; - /** Remote host for SCP when attachments live on a different machine (e.g., clawdbot@192.168.64.3). */ + /** Remote host for SCP when attachments live on a different machine (e.g., user@gateway-host). */ remoteHost?: string; /** Optional default send service (imessage|sms|auto). */ service?: "imessage" | "sms" | "auto"; diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index a0afe4477..36a9e5abf 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -41,7 +41,7 @@ import { upsertChannelPairingRequest, } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; -import { truncateUtf16Safe } from "../../utils.js"; +import { resolveUserPath, truncateUtf16Safe } from "../../utils.js"; import { resolveIMessageAccount } from "../accounts.js"; import { createIMessageRpcClient } from "../client.js"; import { probeIMessage } from "../probe.js"; @@ -63,19 +63,53 @@ import type { IMessagePayload, MonitorIMessageOpts } from "./types.js"; */ async function detectRemoteHostFromCliPath(cliPath: string): Promise { try { - // Expand ~ to home directory - const expanded = cliPath.startsWith("~") - ? cliPath.replace(/^~/, process.env.HOME ?? "") - : cliPath; + const expanded = resolveUserPath(cliPath); + const stat = await fs.stat(expanded).catch(() => null); + if (!stat?.isFile()) return undefined; + const content = await fs.readFile(expanded, "utf8"); + if (!content.includes("ssh")) return undefined; - // Match user@host pattern first (e.g., clawdbot@192.168.64.3) - const userHostMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+)/); - if (userHostMatch) return userHostMatch[1]; + const tokensFromLine = (line: string): string[] => + line.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []; + const stripQuotes = (token: string): string => token.replace(/^['"]|['"]$/g, ""); + const optsWithValue = new Set([ + "-b", + "-c", + "-D", + "-E", + "-F", + "-I", + "-i", + "-J", + "-L", + "-o", + "-p", + "-R", + "-S", + "-W", + "-w", + ]); - // Fallback: match host-only before imsg command (e.g., ssh -T mac-mini imsg) - const hostOnlyMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z][a-zA-Z0-9._-]*)\s+\S*\bimsg\b/); - return hostOnlyMatch?.[1]; + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + if (!line.includes("ssh") || !line.includes("imsg")) continue; + const tokens = tokensFromLine(line).map(stripQuotes).filter(Boolean); + const sshIndex = tokens.findIndex((token) => token === "ssh" || token.endsWith("/ssh")); + if (sshIndex < 0) continue; + for (let idx = sshIndex + 1; idx < tokens.length; idx += 1) { + const token = tokens[idx] ?? ""; + if (!token) continue; + if (token.startsWith("-")) { + if (optsWithValue.has(token)) idx += 1; + continue; + } + if (token === "imsg" || token.endsWith("/imsg")) break; + return token; + } + } + return undefined; } catch { return undefined; } diff --git a/src/media/remote.test.ts b/src/media/remote.test.ts new file mode 100644 index 000000000..3aa82b915 --- /dev/null +++ b/src/media/remote.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { formatScpSource, quoteScpPath } from "./remote.js"; + +describe("quoteScpPath", () => { + it("wraps paths in single quotes", () => { + expect(quoteScpPath("/Users/bot/Messages/Attachment One.jpg")).toBe( + "'/Users/bot/Messages/Attachment One.jpg'", + ); + }); + + it("escapes single quotes for remote shell", () => { + expect(quoteScpPath("/Users/bot/It's/Photo.jpg")).toBe( + "'/Users/bot/It'\"'\"'s/Photo.jpg'", + ); + }); +}); + +describe("formatScpSource", () => { + it("formats the scp source with quoted path", () => { + expect(formatScpSource("user@gateway-host", "/Users/bot/Hello World.jpg")).toBe( + "user@gateway-host:'/Users/bot/Hello World.jpg'", + ); + }); +}); diff --git a/src/media/remote.ts b/src/media/remote.ts new file mode 100644 index 000000000..3cbe4983d --- /dev/null +++ b/src/media/remote.ts @@ -0,0 +1,44 @@ +import { spawn } from "node:child_process"; + +const SINGLE_QUOTE_ESCAPE = "'\"'\"'"; + +export function quoteScpPath(value: string): string { + return `'${value.replace(/'/g, SINGLE_QUOTE_ESCAPE)}'`; +} + +export function formatScpSource(remoteHost: string, remotePath: string): string { + return `${remoteHost}:${quoteScpPath(remotePath)}`; +} + +export async function copyRemoteFileViaScp(params: { + remoteHost: string; + remotePath: string; + localPath: string; +}): Promise { + return await new Promise((resolve, reject) => { + const child = spawn( + "/usr/bin/scp", + [ + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + formatScpSource(params.remoteHost, params.remotePath), + params.localPath, + ], + { stdio: ["ignore", "ignore", "pipe"] }, + ); + + let stderr = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk) => { + stderr += chunk; + }); + + child.once("error", reject); + child.once("exit", (code) => { + if (code === 0) resolve(); + else reject(new Error(`scp failed (${code}): ${stderr.trim()}`)); + }); + }); +}