Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions replicas-matrix-bridge/src/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,25 @@ export async function handleMatrixMessage(
await watcherP;
if (followUp.ok) {
replicaId = existing;
// Opportunistic backfill of the reverse mapping for pre-PR-29
// replicas that don't have one yet. The cross-room guard only
// fires when the reverse exists; writing it on the first
// successful follow-up after upgrade closes the protection
// gap for the 47 mappings that existed when the guard
// shipped. Race-safe: a colliding peer will see mismatch on
// its NEXT follow-up and respawn.
try {
const reverseSet = await env.MAP.get(`replica:${existing}`);
if (!reverseSet) {
const ttlEnv = parseInt(env.REPLICA_TTL_SECONDS, 10);
const opts: KVNamespacePutOptions = ttlEnv > 0
? { expirationTtl: Math.max(60, ttlEnv) }
: {};
await env.MAP.put(`replica:${existing}`, roomId, opts);
}
} catch (e) {
console.log(`[dispatch] reverse-map backfill failed: ${e instanceof Error ? e.message : e}`);
}
} else {
// Any non-ok response (including the explicit `gone` 404/410 and
// every other failure mode — 429, 5xx, network blip, etc.) falls
Expand Down
47 changes: 45 additions & 2 deletions replicas-matrix-bridge/src/listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,13 @@ export class MatrixListener {
// branch below can fetch + decrypt + transcribe. Same shape
// for plain and decrypted-megolm events.
let audioContent: Record<string, unknown> | undefined;
// Content that drives the mention gate / wake-word check.
// For plain m.room.message: ev.content (has body + m.mentions).
// For E2EE: the DECRYPTED inner content (outer wrapper has
// only the megolm ciphertext). For voice: synthetic with the
// transcript as body. Centralizing here so the dispatch gate
// downstream doesn't have to branch on encryption / voice.
let mentionContent: Record<string, unknown> = ev.content ?? {};

if (ev.type === "m.room.message") {
const content = ev.content ?? {};
Expand All @@ -436,13 +443,17 @@ export class MatrixListener {
msgtype = content.msgtype as string | undefined;
body = content.body as string | undefined;
if (msgtype === "m.audio") audioContent = content;
mentionContent = content;
} else if (ev.type === "m.room.encrypted") {
// E2EE event — try to decrypt using imported Megolm keys.
const decrypted = await this.tryDecrypt(roomId, ev, megolmKeys);
if (!decrypted) continue;
msgtype = decrypted.msgtype;
body = decrypted.body;
if (decrypted.msgtype === "m.audio") audioContent = decrypted.content;
// Mention / wake-word gate must see the DECRYPTED content —
// the outer ev.content carries only megolm ciphertext.
mentionContent = decrypted.content;
} else {
continue;
}
Expand All @@ -462,6 +473,12 @@ export class MatrixListener {
body = transcribed;
msgtype = "m.text";
cameFromVoice = true;
// Mention/wake-word gate runs against the TRANSCRIPT so a
// voice "Jada do X" in a group hits the wake-word path
// just like a typed "Jada do X". Without this, voice
// messages in groups were always being dropped because
// the encrypted audio content has no body/m.mentions.
mentionContent = { msgtype: "m.text", body };
}

if (msgtype !== "m.text" || !body) continue;
Expand All @@ -480,7 +497,7 @@ export class MatrixListener {
// Gate auto-dispatch on room size + mention. In a 2-person
// room (you + me), every message is for me. In a larger room
// the bot should stay quiet unless it's been mentioned.
const shouldDispatch = await this.shouldHandleMessage(roomId, ev);
const shouldDispatch = await this.shouldHandleMessage(roomId, { content: mentionContent });
if (!shouldDispatch) continue;

// Mirror mode: if the user's prompt was a voice message, the
Expand Down Expand Up @@ -851,10 +868,32 @@ export class MatrixListener {
console.log(`[listener] voice msg in room=${roomId} but OPENAI_API_KEY not set — skipping`);
return undefined;
}
const info = content.info as { duration?: number; mimetype?: string } | undefined;
const info = content.info as { duration?: number; mimetype?: string; size?: number } | undefined;
const durationMs = info?.duration ?? 0;
const declaredMime = info?.mimetype ?? "audio/ogg";

// Cost guardrail: rate-limit Whisper transcribes to avoid an
// adversarial / runaway sender burning OpenAI credits. We track
// a rolling window of transcription seconds per room across the
// last 60 minutes; if total > VOICE_MINUTES_PER_HOUR_CAP, skip
// new voice messages and log. Caller falls through to "voice
// skipped" UX (same as no-API-key path).
const VOICE_MINUTES_PER_HOUR_CAP = 30;
const WINDOW_MS = 60 * 60 * 1000;
const ledgerKey = `voice-budget:${roomId}`;
const now = Date.now();
const ledger =
(await this.state.storage.get<{ ts: number; sec: number }[]>(ledgerKey)) ?? [];
const fresh = ledger.filter((e) => now - e.ts < WINDOW_MS);
const totalSec = fresh.reduce((acc, e) => acc + e.sec, 0);
const proposedSec = Math.max(0, Math.round(durationMs / 1000));
if (totalSec + proposedSec > VOICE_MINUTES_PER_HOUR_CAP * 60) {
console.log(
`[listener] voice budget exhausted room=${roomId} usedSec=${totalSec} thisSec=${proposedSec} cap=${VOICE_MINUTES_PER_HOUR_CAP * 60} — skipping`,
);
return undefined;
}

const audio = await fetchVoiceAudio(matrixEnv(this.env), {
url: content.url as string | undefined,
file: content.file as EncryptedFile | undefined,
Expand Down Expand Up @@ -889,6 +928,10 @@ export class MatrixListener {
console.log(
`[listener] voice transcribed room=${roomId} duration=${durLabel} text=${JSON.stringify(transcript.slice(0, 80))}`,
);
// Persist the consumed seconds in the budget ledger so subsequent
// voice messages in this room debit against the same window.
fresh.push({ ts: now, sec: proposedSec });
await this.state.storage.put(ledgerKey, fresh);
return `🎤 (voice ${durLabel}) ${transcript}`;
}

Expand Down
54 changes: 54 additions & 0 deletions replicas-matrix-bridge/src/output-archive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { archiveBasename } from "./output-archive";

describe("archiveBasename", () => {
it("uses Read/Edit file_path basename", () => {
expect(archiveBasename("Read", { file_path: "/foo/bar/baz.ts" })).toBe("baz.ts.out.txt");
});

it("sanitizes path special chars in file_path", () => {
const name = archiveBasename("Read", { file_path: "/etc/foo bar?.txt" });
expect(name).not.toContain(" ");
expect(name).not.toContain("?");
});

it("uses Bash command's first NON-env-var token", () => {
// SECURITY: this used to leak secrets when the agent inlined an
// env-var assignment in front of a command, e.g.
// `OPENAI_API_KEY=sk-xxx node srv.js` → basename
// `OPENAI_API_KEY_sk-xxx.out.txt` ending up in a public R2 URL.
const name = archiveBasename("Bash", {
command: "OPENAI_API_KEY=sk-xxx node srv.js",
});
expect(name).toBe("node.out.txt");
expect(name).not.toContain("OPENAI");
expect(name).not.toContain("sk-xxx");
});

it("skips multiple env vars + nohup prefix", () => {
const name = archiveBasename("Bash", {
command: "DEBUG=1 OPENAI_API_KEY=sk-xxx nohup bun build",
});
expect(name).toBe("bun.out.txt");
});

it("strips trailing =… defensively even if regex missed something", () => {
const name = archiveBasename("Bash", { command: "FOO=bar" });
// FOO=bar matches env-var rule and is skipped → falls back to "bash"
expect(name).toBe("bash.out.txt");
});

it("falls back to bash when command is empty", () => {
expect(archiveBasename("Bash", { command: "" })).toBe("bash.out.txt");
});

it("falls back to tool name when no input", () => {
expect(archiveBasename("Glob", undefined)).toBe("Glob-output.txt");
});

it("caps basename length to avoid URL bloat", () => {
const longCmd = "a".repeat(200);
const name = archiveBasename("Bash", { command: longCmd });
expect(name.length).toBeLessThan(60);
});
});
57 changes: 37 additions & 20 deletions replicas-matrix-bridge/src/output-archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
// the Done frame can surface a `📎 View full output` link instead of
// dropping the rest of the content on the floor.
//
// Keying: `<sha256(content)>/<basename>`. Sha-based dedupes identical
// outputs across rooms / sessions; basename is the tool's most likely
// filename hint so a casual click in the browser sees a meaningful
// name in the title bar. Public-read R2 bucket (configured in
// wrangler.toml) so the URL works without auth headers.
// Keying: `<128-bit random>/<basename>`. Random-prefixed so the URL is
// genuinely unguessable — an earlier sha256-derived scheme let an
// attacker who could *guess* the content (e.g. the bytes of a public
// /etc/os-release dump) reconstruct the URL without ever seeing it,
// which leaks data in E2EE rooms where the URL is the only thing
// crossing the encryption boundary. Loses cross-room dedup; gains
// real unguessability. Lifecycle on the bucket prunes objects after
// 30 days so cost stays bounded.

export interface ArchiveEnv {
OUTPUT_ARCHIVE: R2Bucket;
Expand All @@ -25,13 +28,12 @@ const R2_PUBLIC_BASE = "https://pub-54d26cd2ad324055a4a573666935ce53.r2.dev";
export const ARCHIVE_THRESHOLD_BYTES = 2_000;

/**
* SHA-256 of the input, hex-encoded. Used as the R2 key prefix for
* dedupe across rooms.
* 32-hex-char unguessable random prefix (128 bits of entropy from
* crypto.getRandomValues). Used as the R2 key prefix so the public
* URL can't be constructed by guessing the content.
*/
async function sha256Hex(s: string): Promise<string> {
const buf = new TextEncoder().encode(s);
const hash = await crypto.subtle.digest("SHA-256", buf as BufferSource);
const bytes = new Uint8Array(hash);
function randomPrefix(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
let hex = "";
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i]!.toString(16).padStart(2, "0");
Expand All @@ -42,8 +44,7 @@ async function sha256Hex(s: string): Promise<string> {
/**
* Best-effort archive of a tool result. Returns the public r2.dev URL
* on success, undefined on failure (caller falls through to inline
* truncation). Idempotent — re-archiving identical content is a
* no-op put.
* truncation). Each call writes to a fresh random prefix — no dedup.
*/
export async function archiveLargeOutput(
env: ArchiveEnv,
Expand All @@ -53,14 +54,14 @@ export async function archiveLargeOutput(
if (!env.OUTPUT_ARCHIVE) return undefined;
if (content.length < ARCHIVE_THRESHOLD_BYTES) return undefined;
try {
const hex = await sha256Hex(content);
const prefix = randomPrefix();
// Strip path separators from the basename so an attacker can't
// climb the key namespace via `../`. The hash prefix is the
// climb the key namespace via `../`. The random prefix is the
// trust anchor; basename is purely cosmetic.
const cleanBase = suggestedBasename
.replace(/[^a-zA-Z0-9._-]/g, "_")
.slice(0, 80) || "output.txt";
const key = `${hex}/${cleanBase}`;
const key = `${prefix}/${cleanBase}`;
// httpMetadata content-type so a browser renders the blob inline
// instead of forcing a download — most tool outputs are plain text.
await env.OUTPUT_ARCHIVE.put(key, content, {
Expand Down Expand Up @@ -90,12 +91,28 @@ export function archiveBasename(
if (filePath) {
const parts = filePath.split("/");
const base = parts[parts.length - 1] || "output.txt";
return `${base}.out.txt`;
return `${base.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80) || "output.txt"}.out.txt`;
}
if (toolName === "Bash") {
const cmd = typeof input?.command === "string" ? (input.command as string).trim() : "";
const firstWord = cmd.split(/\s+/)[0] ?? "bash";
return `${firstWord.replace(/[^a-zA-Z0-9._-]/g, "_") || "bash"}.out.txt`;
// SECURITY: walk tokens skipping env-var assignments (`KEY=value`)
// and known no-op prefixes so the basename reflects the actual
// command, not the secret values an inline env assignment might
// carry. Otherwise `OPENAI_API_KEY=sk-xxx node srv.js` would
// produce a public R2 URL containing the key.
const tokens = cmd.split(/\s+/);
let firstReal = "bash";
for (const tok of tokens) {
if (/^[A-Z_][A-Z0-9_]*=/.test(tok)) continue; // env var assignment
if (/^(?:nohup|time|sudo|exec|env)$/.test(tok)) continue;
firstReal = tok;
break;
}
// Strip any trailing `=…` just in case the regex above missed
// something (defensive belt-and-suspenders).
if (firstReal.includes("=")) firstReal = firstReal.split("=")[0]!;
const base = firstReal.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 40) || "bash";
return `${base}.out.txt`;
}
return `${toolName.replace(/[^a-zA-Z0-9._-]/g, "_") || "tool"}-output.txt`;
return `${toolName.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 40) || "tool"}-output.txt`;
}
23 changes: 23 additions & 0 deletions replicas-telegram-bridge/src/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,27 @@ describe("markdownToTelegramHtml", () => {
const out = markdownToTelegramHtml("a\u0000PH0\u0000b");
expect(out).not.toContain("\u0000");
});

it("renders GFM tables as <pre> with column-aligned ASCII", () => {
const md = [
"| Tool | Status |",
"|------|--------|",
"| read | done |",
"| edit | pending |",
].join("\n");
const out = markdownToTelegramHtml(md);
expect(out).toContain("<pre>");
// Each cell space-padded to widest in its column; separator uses
// box-drawing chars.
expect(out).toContain("Tool │ Status");
expect(out).toContain("─────┼");
expect(out).toContain("read │ done");
expect(out).toContain("edit │ pending");
});

it("ignores pipe-rows without a separator row", () => {
const out = markdownToTelegramHtml("| just | text |\nnot a separator");
expect(out).not.toContain("<pre>");
expect(out).not.toContain("│");
});
});
67 changes: 67 additions & 0 deletions replicas-telegram-bridge/src/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ export function markdownToTelegramHtml(md: string): string {
// spliced-back content.
md = md.replace(/\u0000/g, "");

// GFM markdown tables → ASCII-aligned <pre> blocks. Telegram's HTML
// parse_mode doesn't support <table>, so we render the same data as a
// monospaced pre-formatted block. Detect `| header | header |` rows
// followed by `|---|---|` separator + body rows; emit a `<pre>` with
// each cell padded to the column's widest value. Runs BEFORE the
// fenced-code-block pass so the pre placeholder is registered
// alongside the other code blocks and survives the rest of the
// markdown passes intact.
md = renderGfmTables(md);

const placeholders: string[] = [];
const placeholder = (html: string): string => {
const key = `\u0000PH${placeholders.length}\u0000`;
Expand Down Expand Up @@ -110,3 +120,60 @@ export function markdownToTelegramHtml(md: string): string {
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

// Pre-pass that walks the markdown line by line, finds GFM tables, and
// rewrites each into a triple-backtick block containing the ASCII-padded
// table. The triple-backtick block is then consumed by the existing
// fenced-code-block extractor, so the table survives unmangled through
// the rest of the markdown passes.
function renderGfmTables(md: string): string {
const lines = md.split("\n");
const out: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i]!;
const isRowLike = /^\s*\|.+\|?\s*$/.test(line);
const isSeparator =
i + 1 < lines.length &&
/^\s*\|?\s*:?-{2,}/.test(lines[i + 1]!) &&
/^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]!);
if (!isRowLike || !isSeparator) {
out.push(line);
i++;
continue;
}
const header = parseTableRow(line);
i += 2;
const body: string[][] = [];
while (i < lines.length && /^\s*\|.+\|?\s*$/.test(lines[i]!)) {
body.push(parseTableRow(lines[i]!));
i++;
}
// Compute column widths.
const widths: number[] = header.map((c) => c.length);
for (const row of body) {
for (let c = 0; c < row.length; c++) {
if ((row[c] ?? "").length > (widths[c] ?? 0)) widths[c] = row[c]!.length;
}
}
const pad = (s: string, w: number): string => s + " ".repeat(Math.max(0, w - s.length));
const renderRow = (cells: string[]): string =>
cells.map((c, idx) => pad(c, widths[idx] ?? c.length)).join(" │ ");
const sep = widths.map((w) => "─".repeat(w)).join("─┼─");
const renderedHeader = renderRow(header);
const renderedBody = body.map(renderRow);
out.push("```");
out.push(renderedHeader);
out.push(sep);
for (const r of renderedBody) out.push(r);
out.push("```");
}
return out.join("\n");
}

function parseTableRow(line: string): string[] {
let s = line.trim();
if (s.startsWith("|")) s = s.slice(1);
if (s.endsWith("|")) s = s.slice(0, -1);
return s.split("|").map((c) => c.trim());
}
Loading