Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
158 changes: 158 additions & 0 deletions companion/src/inbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// Files a paired phone drops onto this computer.
//
// The harness has no upload route. Desktop attachments are already-on-disk
// paths (`<attached-file path="…" />`); the agent opens them where it runs.
// A photo on a phone is not on this disk, so the sidecar writes it here and
// the phone sends that path as the message — the same shape every driver
// already knows, and no harness change.
//
// Lives under the sidecar's own directory, not ~/.openmausbot. The two
// processes do not share a layout; the agent still reads the file because
// the path we return is an ordinary absolute path on this machine.
import { randomBytes } from "node:crypto";
import {
chmodSync,
closeSync,
constants,
fstatSync,
lstatSync,
mkdirSync,
openSync,
readSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { basename, join, resolve, sep } from "node:path";

import { FILE_MODE } from "./state.ts";

/** Eight megabytes. A phone photo after JPEG compression fits; a raw
* burst or a video does not, and this process should not hold one. */
export const MAX_INBOX_BYTES = 8 * 1024 * 1024;

export type StoredInboxFile = {
path: string;
name: string;
size: number;
};

/** Where new files land. Read at call time so tests can point `OMB_COMPANION_DIR`
* without reloading this module. */
export function inboxRoot(): string {
const base = process.env.OMB_COMPANION_DIR ?? join(homedir(), ".openmausbot-companion");
return join(base, "inbox");
}

/** A filename that cannot walk out of the inbox. Basename only, a short
* allowlist of characters, no leading dots. Empty input becomes `file`. */
export function safeFilename(input: string): string {
const base = basename(String(input ?? "")).replaceAll("\0", "");
const cleaned = base.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^\.+/, "");
return (cleaned || "file").slice(0, 80);
}

/** Write `bytes` into the inbox and return the path the phone should send. */
export function storeInboxFile(
bytes: Buffer,
filename: string,
root = inboxRoot(),
): StoredInboxFile {
if (bytes.length === 0) throw new Error("empty file");
if (bytes.length > MAX_INBOX_BYTES) throw new Error("body too large");

const name = safeFilename(filename);
mkdirSync(root, { recursive: true, mode: 0o700 });
try {
chmodSync(root, 0o700);
} catch {
/* existing dir on a filesystem that will not chmod — the write still works */
}

const stored = `${Date.now()}-${randomBytes(4).toString("hex")}-${name}`;
const path = join(root, stored);
const resolvedRoot = resolve(root);
const resolvedPath = resolve(path);
if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(resolvedRoot + sep)) {
throw new Error("invalid filename");
}

writeFileSync(path, bytes, { mode: FILE_MODE });
return { path, name, size: bytes.length };
}

/** Stored inbox names start with a digit (the timestamp). A leading dot is
* a traversal or a hidden file and is not one of ours. */
const INBOX_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;

/** Read a file the phone previously stored. Basename only, and only from
* this inbox — a stolen token must not become a reader for the rest of the
* disk. Missing or invalid names are `null`, not thrown. */
export function readInboxFile(
filename: string,
root = inboxRoot(),
): { bytes: Buffer; type: string } | null {
if (filename !== basename(filename) || !INBOX_NAME.test(filename)) return null;
const path = join(root, filename);
const resolvedRoot = resolve(root);
const resolvedPath = resolve(path);
if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(resolvedRoot + sep)) {
return null;
}
let fd: number | undefined;
try {
// Open once, then fstat/read that descriptor. `lstat` then `readFile`
// is two path lookups: a symlink swapped in between them would follow
// out of the inbox. `O_NOFOLLOW` is POSIX; Windows does not expose it
// (or exposes `0`), so a reparse point is refused with `lstat` first.
// That is still a path check — not the same as no-follow open — but
// following here would read outside the inbox.
const noFollow = constants.O_NOFOLLOW;
const hasNoFollow = typeof noFollow === "number" && noFollow !== 0;
if (!hasNoFollow && lstatSync(path).isSymbolicLink()) return null;
const flags = hasNoFollow ? constants.O_RDONLY | noFollow : constants.O_RDONLY;
fd = openSync(path, flags);
const st = fstatSync(fd);
if (!st.isFile() || st.size > MAX_INBOX_BYTES) return null;
// Read at most the size we just validated. `readFileSync(fd)` would
// follow a concurrent append past MAX_INBOX_BYTES.
const bytes = Buffer.alloc(st.size);
let offset = 0;
while (offset < bytes.length) {
const n = readSync(fd, bytes, offset, bytes.length - offset, offset);
if (n === 0) break;
offset += n;
}
return { bytes: offset === bytes.length ? bytes : bytes.subarray(0, offset), type: inboxType(filename) };
} catch {
return null;
} finally {
if (fd !== undefined) {
try {
closeSync(fd);
} catch {
/* already closed */
}
}
}
}

export function inboxType(filename: string): string {
switch (filename.split(".").pop()?.toLowerCase()) {
case "jpg":
case "jpeg":
return "image/jpeg";
case "png":
return "image/png";
case "gif":
return "image/gif";
case "webp":
return "image/webp";
case "heic":
case "heif":
return "image/heic";
case "pdf":
return "application/pdf";
default:
return "application/octet-stream";
}
}
86 changes: 86 additions & 0 deletions companion/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import { request as httpRequest, type IncomingMessage, type ServerResponse } from "node:http";

import { bearerToken } from "./devices.ts";
import { MAX_INBOX_BYTES, readInboxFile, storeInboxFile } from "./inbox.ts";
import { denyReason, isCloudDesktopJoin } from "./routes.ts";
import { createSseScrubber, isJson, scrub } from "./wire.ts";

Expand Down Expand Up @@ -83,6 +84,38 @@ const readJson = (req: IncomingMessage, limit = 64 * 1024): Promise<Record<strin
});
});

/** Read a raw body, bounded. Used for the inbox: those bytes are a file,
* not JSON, and an unbounded read is a way to be memory-exhausted. Over
* the ceiling we reject without `destroy()` — the handler still has a 413
* to write, and killing the socket first is how a client sees a dropped
* connection instead of that status. */
const readBytes = (req: IncomingMessage, limit: number): Promise<Buffer> =>
new Promise((resolve, reject) => {
let size = 0;
let settled = false;
const chunks: Buffer[] = [];
const fail = (error: Error) => {
if (settled) return;
settled = true;
reject(error);
};
req.on("data", (chunk: Buffer) => {
if (settled) return;
size += chunk.length;
if (size > limit) {
fail(new Error("body too large"));
return;
}
chunks.push(chunk);
});
req.on("error", fail);
req.on("end", () => {
if (settled) return;
settled = true;
resolve(Buffer.concat(chunks));
});
});

/** Answer with JSON the sidecar wrote itself — a refusal, or a pairing
* result. Anything from the harness goes out through the proxy path instead.
*
Expand Down Expand Up @@ -185,6 +218,59 @@ export function createProxyHandler(options: ProxyOptions) {
return;
}

// Phone attachments terminate here. Forwarding them would hand the
// harness a route it does not have. The sidecar writes the bytes onto
// this computer and returns the path; the next request is an ordinary
// text message carrying `<attached-file path="…">`.
if (method === "POST" && path === "/api/inbox") {
readBytes(req, MAX_INBOX_BYTES).then(
(bytes) => {
try {
const header = req.headers["x-openmaus-filename"];
const raw = Array.isArray(header) ? header[0] : header;
let filename = "file";
try {
filename = decodeURIComponent(String(raw ?? "file"));
} catch {
filename = String(raw ?? "file");
}
return sendJson(res, 201, storeInboxFile(bytes, filename));
} catch (error) {
const message = error instanceof Error ? error.message : "could not store that file";
return sendJson(res, message.includes("too large") ? 413 : 400, { error: message });
}
},
(error: Error) => {
sendJson(res, error.message === "body too large" ? 413 : 400, { error: error.message });
// Drain whatever is still in flight so the 413 is not sitting
// behind a half-read body. Destroying the socket here is how a
// client sees a dropped connection instead of that status.
req.resume();
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
return;
}

if (method === "GET") {
const match = /^\/api\/inbox\/([^/]+)$/.exec(path);
if (match) {
let name = match[1];
try {
name = decodeURIComponent(name);
} catch {
return sendJson(res, 400, { error: "invalid filename" });
}
const stored = readInboxFile(name);
if (!stored) return sendJson(res, 404, { error: "no such file" });
res.writeHead(200, {
"content-type": stored.type,
"content-length": stored.bytes.length,
});
res.end(stored.bytes);
return;
}
}

const upstream = httpRequest(
{
hostname: "127.0.0.1",
Expand Down
6 changes: 6 additions & 0 deletions companion/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ const ALLOWED: ReadonlyArray<{ method: string; path: RegExp }> = [
{ method: "GET", path: /^\/api\/threads\/[\w-]+\/export$/ },
{ method: "POST", path: /^\/api\/threads\/[\w-]+\/respond$/ },
{ method: "GET", path: /^\/api\/search$/ },

// Phone photos and files. Handled by the sidecar itself — the harness
// has no upload route. The phone then sends the returned host path as
// a normal text message, the same shape the desktop composer already uses.
{ method: "POST", path: /^\/api\/inbox$/ },
{ method: "GET", path: /^\/api\/inbox\/[A-Za-z0-9][\w.-]*$/ },
];

/** Route families worth naming in the refusal.
Expand Down
94 changes: 94 additions & 0 deletions companion/test/inbox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";

import { MAX_INBOX_BYTES, readInboxFile, safeFilename, storeInboxFile } from "../src/inbox.ts";

let root: string | undefined;

afterEach(() => {
if (root) rmSync(root, { recursive: true, force: true });
root = undefined;
});

const dir = () => (root = mkdtempSync(join(tmpdir(), "inbox-")));

describe("safeFilename", () => {
it("keeps a plain name", () => {
expect(safeFilename("notes.txt")).toBe("notes.txt");
});

it("strips directory components so a traversal cannot leave the inbox", () => {
expect(safeFilename("../../etc/passwd")).toBe("passwd");
expect(safeFilename("foo/bar/photo.jpg")).toBe("photo.jpg");
});

it("replaces characters a path would treat specially", () => {
expect(safeFilename("my photo (1).jpg")).toBe("my_photo_1_.jpg");
expect(safeFilename("a\0b.png")).toBe("ab.png");
});

it("does not keep a leading dot, and empty input is still a name", () => {
expect(safeFilename("")).toBe("file");
expect(safeFilename("...")).toBe("file");
expect(safeFilename(".hidden")).toBe("hidden");
});

it("caps a long name so the inbox path stays short", () => {
expect(safeFilename("a".repeat(200)).length).toBe(80);
});
});

describe("storeInboxFile", () => {
it("writes the bytes and returns an absolute path inside the root", () => {
const stored = storeInboxFile(Buffer.from("hello"), "notes.txt", dir());
expect(stored.name).toBe("notes.txt");
expect(stored.size).toBe(5);
expect(stored.path.startsWith(root!)).toBe(true);
expect(readFileSync(stored.path, "utf8")).toBe("hello");
});

it("refuses an empty body and a body over the ceiling", () => {
expect(() => storeInboxFile(Buffer.alloc(0), "a.txt", dir())).toThrow(/empty/);
expect(() => storeInboxFile(Buffer.alloc(MAX_INBOX_BYTES + 1), "a.bin", dir())).toThrow(
/too large/,
);
});

it("does not collide when the same name is stored twice", () => {
const a = storeInboxFile(Buffer.from("one"), "same.txt", dir());
const b = storeInboxFile(Buffer.from("two"), "same.txt", root!);
expect(a.path).not.toBe(b.path);
expect(readFileSync(a.path, "utf8")).toBe("one");
expect(readFileSync(b.path, "utf8")).toBe("two");
});

it("reads a stored file back, and refuses a name that could leave the inbox", () => {
const stored = storeInboxFile(Buffer.from("hello"), "notes.txt", dir());
const got = readInboxFile(basename(stored.path), root!);
expect(got?.bytes.toString("utf8")).toBe("hello");
expect(got?.type).toBe("application/octet-stream");
expect(readInboxFile("../notes.txt", root!)).toBeNull();
expect(readInboxFile("..", root!)).toBeNull();
expect(readInboxFile(".hidden", root!)).toBeNull();
expect(readInboxFile("missing.txt", root!)).toBeNull();
});

it("refuses a symlink, a directory, and a body over the ceiling", () => {
const root = dir();
writeFileSync(join(root, "target.txt"), "secret");
const name = "1-abcd1234-notes.txt";
try {
symlinkSync("target.txt", join(root, name));
expect(readInboxFile(name, root)).toBeNull();
} catch {
// some CI filesystems will not make a symlink; the other two
// refusals below still cover the same read path
}
mkdirSync(join(root, "2-abcd1234-dir"));
expect(readInboxFile("2-abcd1234-dir", root)).toBeNull();
writeFileSync(join(root, "3-abcd1234-big.bin"), Buffer.alloc(MAX_INBOX_BYTES + 1));
expect(readInboxFile("3-abcd1234-big.bin", root)).toBeNull();
});
});
Loading