Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad6f69f9af | ||
|
|
a4a2c3bc82 |
No files matched your search
+1
-2
@@ -24,6 +24,7 @@
|
||||
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
|
||||
- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup.
|
||||
- Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs.
|
||||
- Config: support env var substitution in config values. (#1044) — thanks @sebslight.
|
||||
|
||||
### Fixes
|
||||
- Messages: `/stop` now hard-aborts queued followups and sub-agent runs; suppress zero-count stop notes.
|
||||
@@ -40,7 +41,6 @@
|
||||
- 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
|
||||
|
||||
@@ -103,7 +103,6 @@
|
||||
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
||||
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
|
||||
- Fix: honor message tool channel for duplicate suppression (prefer `NO_REPLY` after `message` tool sends). (#1053) — thanks @sashcatanzarite.
|
||||
- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)
|
||||
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
||||
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
||||
|
||||
@@ -108,26 +108,10 @@ If you want iMessage on another Mac, set `channels.imessage.cliPath` to a wrappe
|
||||
Example wrapper:
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
exec ssh -T gateway-host imsg "$@"
|
||||
exec ssh -T mac-mini 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`:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
imessage: {
|
||||
cliPath: "~/imsg-ssh", // SSH wrapper to remote Mac
|
||||
remoteHost: "user@gateway-host", // 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).
|
||||
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:
|
||||
@@ -198,7 +182,6 @@ 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., `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).
|
||||
|
||||
@@ -1206,7 +1206,6 @@ 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)
|
||||
@@ -1226,12 +1225,11 @@ 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 gateway-host imsg "$@"
|
||||
exec ssh -T mac-mini "imsg rpc"
|
||||
```
|
||||
|
||||
### `agents.defaults.workspace`
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { extractMessagingToolSend } from "./pi-embedded-subscribe.tools.js";
|
||||
|
||||
describe("extractMessagingToolSend", () => {
|
||||
it("uses channel as provider for message tool", () => {
|
||||
const result = extractMessagingToolSend("message", {
|
||||
action: "send",
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
});
|
||||
|
||||
expect(result?.tool).toBe("message");
|
||||
expect(result?.provider).toBe("telegram");
|
||||
expect(result?.to).toBe("telegram:123");
|
||||
});
|
||||
|
||||
it("prefers provider when both provider and channel are set", () => {
|
||||
const result = extractMessagingToolSend("message", {
|
||||
action: "send",
|
||||
provider: "slack",
|
||||
channel: "telegram",
|
||||
to: "channel:C1",
|
||||
});
|
||||
|
||||
expect(result?.tool).toBe("message");
|
||||
expect(result?.provider).toBe("slack");
|
||||
expect(result?.to).toBe("channel:c1");
|
||||
});
|
||||
});
|
||||
@@ -57,10 +57,8 @@ export function extractMessagingToolSend(
|
||||
const toRaw = typeof args.to === "string" ? args.to : undefined;
|
||||
if (!toRaw) return undefined;
|
||||
const providerRaw = typeof args.provider === "string" ? args.provider.trim() : "";
|
||||
const channelRaw = typeof args.channel === "string" ? args.channel.trim() : "";
|
||||
const providerHint = providerRaw || channelRaw;
|
||||
const providerId = providerHint ? normalizeChannelId(providerHint) : null;
|
||||
const provider = providerId ?? (providerHint ? providerHint.toLowerCase() : "message");
|
||||
const providerId = providerRaw ? normalizeChannelId(providerRaw) : null;
|
||||
const provider = providerId ?? (providerRaw ? providerRaw.toLowerCase() : "message");
|
||||
const to = normalizeTargetForProvider(provider, toRaw);
|
||||
return to ? { tool: toolName, provider, accountId, to } : undefined;
|
||||
}
|
||||
|
||||
@@ -216,7 +216,6 @@ describe("buildAgentSystemPrompt", () => {
|
||||
|
||||
expect(prompt).toContain("message: Send messages and channel actions");
|
||||
expect(prompt).toContain("### message tool");
|
||||
expect(prompt).toContain("respond with ONLY: NO_REPLY");
|
||||
});
|
||||
|
||||
it("includes runtime provider capabilities when present", () => {
|
||||
|
||||
@@ -95,7 +95,6 @@ function buildMessagingSection(params: {
|
||||
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
|
||||
"- For `action=send`, include `to` and `message`.",
|
||||
`- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`,
|
||||
`- If you use \`message\` (\`action=send\`) to deliver your user-visible reply, respond with ONLY: ${SILENT_REPLY_TOKEN} (avoid duplicate replies).`,
|
||||
params.inlineButtonsEnabled
|
||||
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
|
||||
: params.runtimeChannel
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
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 { copyRemoteFileViaScp } from "../../media/remote.js";
|
||||
import { ensureMediaDir } from "../../media/store.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
|
||||
export async function stageSandboxMedia(params: {
|
||||
@@ -32,8 +29,7 @@ export async function stageSandboxMedia(params: {
|
||||
sessionKey,
|
||||
workspaceDir,
|
||||
});
|
||||
const usesRemoteMedia = Boolean(ctx.MediaRemoteHost);
|
||||
if (!sandbox && !usesRemoteMedia) return;
|
||||
if (!sandbox) return;
|
||||
|
||||
const resolveAbsolutePath = (value: string): string | null => {
|
||||
let resolved = value.trim();
|
||||
@@ -50,9 +46,7 @@ export async function stageSandboxMedia(params: {
|
||||
};
|
||||
|
||||
try {
|
||||
const destDir = sandbox
|
||||
? path.join(sandbox.workspaceDir, "media", "inbound")
|
||||
: path.join(await ensureMediaDir(), "inbound");
|
||||
const destDir = path.join(sandbox.workspaceDir, "media", "inbound");
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
|
||||
const usedNames = new Set<string>();
|
||||
@@ -67,31 +61,17 @@ export async function stageSandboxMedia(params: {
|
||||
if (!baseName) continue;
|
||||
const parsed = path.parse(baseName);
|
||||
let fileName = baseName;
|
||||
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;
|
||||
}
|
||||
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 copyRemoteFileViaScp({
|
||||
remoteHost: ctx.MediaRemoteHost,
|
||||
remotePath: source,
|
||||
localPath: dest,
|
||||
});
|
||||
} else {
|
||||
await fs.copyFile(source, dest);
|
||||
}
|
||||
// For sandbox use relative path, otherwise use absolute path
|
||||
const stagedPath = sandbox ? path.posix.join("media", "inbound", fileName) : dest;
|
||||
staged.set(source, stagedPath);
|
||||
await fs.copyFile(source, dest);
|
||||
const relative = path.posix.join("media", "inbound", fileName);
|
||||
staged.set(source, relative);
|
||||
}
|
||||
|
||||
const rewriteIfStaged = (value: string | undefined): string | undefined => {
|
||||
|
||||
@@ -38,8 +38,6 @@ export type MsgContext = {
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
/** Internal: remote host for inbound media fetch (iMessage SSH wrappers). */
|
||||
MediaRemoteHost?: string;
|
||||
Transcript?: string;
|
||||
ChatType?: string;
|
||||
GroupSubject?: string;
|
||||
|
||||
@@ -129,10 +129,25 @@ describe("resolveConfigEnvVars", () => {
|
||||
expect(result).toEqual({ key: "resolved/${LITERAL}" });
|
||||
});
|
||||
|
||||
it("handles escaped and unescaped of the same var (escaped first)", () => {
|
||||
const result = resolveConfigEnvVars({ key: "$${FOO} ${FOO}" }, { FOO: "bar" });
|
||||
expect(result).toEqual({ key: "${FOO} bar" });
|
||||
});
|
||||
|
||||
it("handles escaped and unescaped of the same var (unescaped first)", () => {
|
||||
const result = resolveConfigEnvVars({ key: "${FOO} $${FOO}" }, { FOO: "bar" });
|
||||
expect(result).toEqual({ key: "bar ${FOO}" });
|
||||
});
|
||||
|
||||
it("handles multiple escaped vars", () => {
|
||||
const result = resolveConfigEnvVars({ key: "$${A}:$${B}" }, {});
|
||||
expect(result).toEqual({ key: "${A}:${B}" });
|
||||
});
|
||||
|
||||
it("does not unescape $${VAR} sequences from env values", () => {
|
||||
const result = resolveConfigEnvVars({ key: "${FOO}" }, { FOO: "$${BAR}" });
|
||||
expect(result).toEqual({ key: "$${BAR}" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-matching patterns unchanged", () => {
|
||||
|
||||
@@ -22,10 +22,7 @@
|
||||
|
||||
// Pattern for valid uppercase env var names: starts with letter or underscore,
|
||||
// followed by letters, numbers, or underscores (all uppercase)
|
||||
const ENV_VAR_PATTERN = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
|
||||
|
||||
// Pattern for escaped env vars: $${...} -> ${...}
|
||||
const ESCAPED_PATTERN = /\$\$\{([A-Z_][A-Z0-9_]*)\}/g;
|
||||
const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
||||
|
||||
export class MissingEnvVarError extends Error {
|
||||
constructor(
|
||||
@@ -46,26 +43,65 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
);
|
||||
}
|
||||
|
||||
function readEnvVarName(
|
||||
value: string,
|
||||
start: number,
|
||||
): { name: string | null; end: number } {
|
||||
const end = value.indexOf("}", start);
|
||||
if (end === -1) {
|
||||
return { name: null, end: -1 };
|
||||
}
|
||||
|
||||
const name = value.slice(start, end);
|
||||
if (!ENV_VAR_NAME_PATTERN.test(name)) {
|
||||
return { name: null, end: -1 };
|
||||
}
|
||||
|
||||
return { name, end };
|
||||
}
|
||||
|
||||
function substituteString(value: string, env: NodeJS.ProcessEnv, configPath: string): string {
|
||||
// First pass: substitute real env vars
|
||||
const substituted = value.replace(ENV_VAR_PATTERN, (match, varName: string) => {
|
||||
// Check if this is actually an escaped var (preceded by $)
|
||||
// We handle this in the second pass, so check the original string
|
||||
const idx = value.indexOf(match);
|
||||
if (idx > 0 && value[idx - 1] === "$") {
|
||||
// This will be handled by ESCAPED_PATTERN, return as-is for now
|
||||
return match;
|
||||
let result = "";
|
||||
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
const char = value[i];
|
||||
if (char !== "$") {
|
||||
result += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
const envValue = env[varName];
|
||||
if (envValue === undefined || envValue === "") {
|
||||
throw new MissingEnvVarError(varName, configPath);
|
||||
}
|
||||
return envValue;
|
||||
});
|
||||
const next = value[i + 1];
|
||||
const afterNext = value[i + 2];
|
||||
|
||||
// Second pass: convert escaped $${VAR} to literal ${VAR}
|
||||
return substituted.replace(ESCAPED_PATTERN, (_, varName: string) => `\${${varName}}`);
|
||||
// Escaped: $${VAR} -> ${VAR}
|
||||
if (next === "$" && afterNext === "{") {
|
||||
const { name, end } = readEnvVarName(value, i + 3);
|
||||
if (name !== null) {
|
||||
result += `\${${name}}`;
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Substitution: ${VAR} -> value
|
||||
if (next === "{") {
|
||||
const { name, end } = readEnvVarName(value, i + 2);
|
||||
if (name !== null) {
|
||||
const envValue = env[name];
|
||||
if (envValue === undefined || envValue === "") {
|
||||
throw new MissingEnvVarError(name, configPath);
|
||||
}
|
||||
result += envValue;
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Leave untouched if not a recognized pattern
|
||||
result += char;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function substituteAny(value: unknown, env: NodeJS.ProcessEnv, path: string): unknown {
|
||||
|
||||
@@ -14,8 +14,6 @@ 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., user@gateway-host). */
|
||||
remoteHost?: string;
|
||||
/** Optional default send service (imessage|sms|auto). */
|
||||
service?: "imessage" | "sms" | "auto";
|
||||
/** Optional default region (used when sending SMS). */
|
||||
|
||||
@@ -367,7 +367,6 @@ 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"),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import {
|
||||
resolveEffectiveMessagesConfig,
|
||||
resolveHumanDelayConfig,
|
||||
@@ -41,7 +39,7 @@ import {
|
||||
upsertChannelPairingRequest,
|
||||
} from "../../pairing/pairing-store.js";
|
||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import { resolveUserPath, truncateUtf16Safe } from "../../utils.js";
|
||||
import { truncateUtf16Safe } from "../../utils.js";
|
||||
import { resolveIMessageAccount } from "../accounts.js";
|
||||
import { createIMessageRpcClient } from "../client.js";
|
||||
import { probeIMessage } from "../probe.js";
|
||||
@@ -55,66 +53,6 @@ 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 {
|
||||
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;
|
||||
|
||||
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",
|
||||
]);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): Promise<void> {
|
||||
const runtime = resolveRuntime(opts);
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
@@ -144,15 +82,6 @@ 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,
|
||||
@@ -440,7 +369,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
MediaPath: mediaPath,
|
||||
MediaType: mediaType,
|
||||
MediaUrl: mediaPath,
|
||||
MediaRemoteHost: remoteHost,
|
||||
WasMentioned: effectiveWasMentioned,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
// Originating channel for reply routing.
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
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()}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user