Merge pull request #1054 from tyler6204/fix/imsg-remote-attachments

iMessage: Add remote attachment support for VM/SSH deployments
This commit is contained in:
Peter Steinberger
2026-01-17 00:37:21 +00:00
committed by GitHub
6 changed files with 108 additions and 6 deletions

View File

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

View File

@@ -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: <workspace>/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<string>();
@@ -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<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,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;

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import fs from "node:fs/promises";
import {
resolveEffectiveMessagesConfig,
resolveHumanDelayConfig,
@@ -53,6 +55,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<string | undefined> {
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<void> {
const runtime = resolveRuntime(opts);
const cfg = opts.config ?? loadConfig();
@@ -82,6 +110,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,
@@ -369,6 +406,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.