From 7a9ff182608b0c083ed57e3f199495ac93f4a079 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Fri, 16 Jan 2026 15:51:42 -0800 Subject: [PATCH] iMessage: Add remote attachment support for VM/SSH deployments --- docs/channels/imessage.md | 19 +++++++- src/auto-reply/reply/stage-sandbox-media.ts | 52 +++++++++++++++++++-- src/auto-reply/templating.ts | 2 + src/config/types.imessage.ts | 2 + src/config/zod-schema.providers-core.ts | 1 + src/imessage/monitor/monitor-provider.ts | 38 +++++++++++++++ 6 files changed, 108 insertions(+), 6 deletions(-) diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index 6d98b9f73..d36ed22ef 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -111,7 +111,23 @@ Example wrapper: exec ssh -T mac-mini imsg "$@" ``` -Multi-account support: use `channels.imessage.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. Don’t commit `~/.clawdbot/clawdbot.json` (it often contains tokens). +**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`: + +```json5 +{ + channels: { + imessage: { + cliPath: "~/imsg-ssh", // SSH wrapper to remote Mac + remoteHost: "clawdbot@192.168.64.3", // for SCP file transfer + includeAttachments: true + } + } +} +``` + +If `remoteHost` is not set, Clawdbot attempts to auto-detect it by parsing the SSH command in your wrapper script. Explicit configuration is recommended for reliability. + +Multi-account support: use `channels.imessage.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. Don't commit `~/.clawdbot/clawdbot.json` (it often contains tokens). ## Access control (DMs + groups) DMs: @@ -182,6 +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.service`: `imessage | sms | auto`. - `channels.imessage.region`: SMS region. - `channels.imessage.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). diff --git a/src/auto-reply/reply/stage-sandbox-media.ts b/src/auto-reply/reply/stage-sandbox-media.ts index 4840b3a05..a2944a4c9 100644 --- a/src/auto-reply/reply/stage-sandbox-media.ts +++ b/src/auto-reply/reply/stage-sandbox-media.ts @@ -1,9 +1,11 @@ +import { spawn } from "node:child_process"; 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 type { MsgContext, TemplateContext } from "../templating.js"; export async function stageSandboxMedia(params: { @@ -29,7 +31,11 @@ export async function stageSandboxMedia(params: { sessionKey, workspaceDir, }); - if (!sandbox) return; + + // 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 resolveAbsolutePath = (value: string): string | null => { let resolved = value.trim(); @@ -46,7 +52,8 @@ export async function stageSandboxMedia(params: { }; try { - const destDir = path.join(sandbox.workspaceDir, "media", "inbound"); + // For sandbox: /media/inbound, for remote cache: use dir directly + const destDir = sandbox ? path.join(effectiveWorkspaceDir, "media", "inbound") : effectiveWorkspaceDir; await fs.mkdir(destDir, { recursive: true }); const usedNames = new Set(); @@ -69,9 +76,15 @@ export async function stageSandboxMedia(params: { usedNames.add(fileName); const dest = path.join(destDir, fileName); - await fs.copyFile(source, dest); - const relative = path.posix.join("media", "inbound", fileName); - staged.set(source, relative); + if (ctx.MediaRemoteHost) { + // Always use SCP when remote host is configured - local paths refer to remote machine + await scpFile(ctx.MediaRemoteHost, source, dest); + } else { + await fs.copyFile(source, dest); + } + // For sandbox use relative path, for remote cache use absolute path + const stagedPath = sandbox ? path.posix.join("media", "inbound", fileName) : dest; + staged.set(source, stagedPath); } const rewriteIfStaged = (value: string | undefined): string | undefined => { @@ -111,3 +124,32 @@ 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 610e2f8dc..721a10eb2 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -38,6 +38,8 @@ 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). */ + MediaRemoteHost?: string; Transcript?: string; ChatType?: string; GroupSubject?: string; diff --git a/src/config/types.imessage.ts b/src/config/types.imessage.ts index bc86ecdbe..37e4c5453 100644 --- a/src/config/types.imessage.ts +++ b/src/config/types.imessage.ts @@ -14,6 +14,8 @@ 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). */ + remoteHost?: string; /** Optional default send service (imessage|sms|auto). */ service?: "imessage" | "sms" | "auto"; /** Optional default region (used when sending SMS). */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index fc97e1b24..fa5955aa1 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -367,6 +367,7 @@ export const IMessageAccountSchemaBase = z.object({ configWrites: z.boolean().optional(), cliPath: ExecutableTokenSchema.optional(), dbPath: z.string().optional(), + remoteHost: z.string().optional(), service: z.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]).optional(), region: z.string().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 67e5d55e9..80b6d9860 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -1,3 +1,5 @@ +import fs from "node:fs/promises"; + import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig, @@ -52,6 +54,32 @@ import { deliverReplies } from "./deliver.js"; import { normalizeAllowList, resolveRuntime } from "./runtime.js"; import type { IMessagePayload, MonitorIMessageOpts } from "./types.js"; +/** + * Try to detect remote host from an SSH wrapper script like: + * exec ssh -T clawdbot@192.168.64.3 /opt/homebrew/bin/imsg "$@" + * exec ssh -T mac-mini imsg "$@" + * Returns the user@host or host portion if found, undefined otherwise. + */ +async function detectRemoteHostFromCliPath(cliPath: string): Promise { + try { + // Expand ~ to home directory + const expanded = cliPath.startsWith("~") + ? cliPath.replace(/^~/, process.env.HOME ?? "") + : cliPath; + const content = await fs.readFile(expanded, "utf8"); + + // 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]; + + // 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]; + } catch { + return undefined; + } +} + export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): Promise { const runtime = resolveRuntime(opts); const cfg = opts.config ?? loadConfig(); @@ -81,6 +109,15 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P const cliPath = opts.cliPath ?? imessageCfg.cliPath ?? "imsg"; const dbPath = opts.dbPath ?? imessageCfg.dbPath; + // Resolve remoteHost: explicit config, or auto-detect from SSH wrapper script + let remoteHost = imessageCfg.remoteHost; + if (!remoteHost && cliPath && cliPath !== "imsg") { + remoteHost = await detectRemoteHostFromCliPath(cliPath); + if (remoteHost) { + logVerbose(`imessage: detected remoteHost=${remoteHost} from cliPath`); + } + } + const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "imessage" }); const inboundDebouncer = createInboundDebouncer<{ message: IMessagePayload }>({ debounceMs: inboundDebounceMs, @@ -362,6 +399,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P MediaPath: mediaPath, MediaType: mediaType, MediaUrl: mediaPath, + MediaRemoteHost: remoteHost, WasMentioned: effectiveWasMentioned, CommandAuthorized: commandAuthorized, // Originating channel for reply routing.