-
Notifications
You must be signed in to change notification settings - Fork 551
ios: attach photos and files from the composer #215
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
8041c88
ios: dictate into the chat composer
mnthr7 bf384b7
ios: harden dictation against CodeRabbit-style review
mnthr7 a2814ad
ios: close remaining CodeRabbit-shaped dictation nits
mnthr7 f20c5ba
ios: ignore speech callbacks from a previous session
mnthr7 9e57267
ios: attach photos and files from the composer
mnthr7 748e2b9
companion: answer 413 before dropping an oversized inbox upload
mnthr7 c973d35
ios: import CompanionCore in the attach composer
mnthr7 8342c15
ios: keep the wrapping composer a rounded rectangle
mnthr7 50b9981
ios: render GFM tables in bot bubbles
mnthr7 e0eb78c
ios: show the photo in the bubble, not the host path tag
mnthr7 8fd24d2
ios: sync App/ from disk so Xcode stops missing pulled files
mnthr7 c0f4ae1
ios: keep a local copy of sent photos so the bubble can show them
mnthr7 62ea4b5
ios: harden inbox reads before the attach PR
mnthr7 464f034
Merge upstream/main into ios composer attach
mnthr7 4869559
Merge upstream/main into ios composer attach
mnthr7 06ac2a2
ios: freeze the composer while attachments upload
mnthr7 d910170
Merge upstream/main into ios composer attach
mnthr7 cfbe0aa
ios: drop stale attachment sends after unpair
mnthr7 ec99542
Merge upstream/main into ios composer attach
mnthr7 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.