fix: stage iMessage remote attachments (#1054) (thanks @tyler6204)

This commit is contained in:
Peter Steinberger 2026-01-17 00:36:52 +00:00
parent 8144668b1a
commit 1e297de734
9 changed files with 145 additions and 61 deletions

View File

@ -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

View File

@ -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).

View File

@ -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:<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`

View File

@ -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: <workspace>/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<string>();
@ -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<void> {
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()}`));
});
});
}

View File

@ -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;

View File

@ -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";

View File

@ -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<string | undefined> {
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;
}

25
src/media/remote.test.ts Normal file
View File

@ -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'",
);
});
});

44
src/media/remote.ts Normal file
View File

@ -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<void> {
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()}`));
});
});
}