Two requests, one file apart. ── The microphone ── Removed from the chat composer. The HOOK stays: `useAudioRecording` and `blobToWav` are also the file browser's DictateDialog, which is not what was asked to go. ── PDFs, by not extracting anything ── A PDF entry sat in the attach menu once with no `onSelect` at all — the menu offered it and did nothing, because nothing here could extract PDF text. It is back and real, and it still extracts nothing: the file is uploaded, left on disk, and the agent is given its PATH. Its own `Read` handles PDFs by page, better than any extraction this could have shipped, and it can go back for a section later without paying for it twice. The alternative was base64 in the message. That is megabytes in the turn, re-sent for the rest of the conversation, to produce worse text. ── What this uncovered ── `attachmentIds` has been on this websocket and in `types.ts` for a long time and NOTHING on the server read it. Images work because they travel separately as base64, so the ids were dead on the wire — and a PDF is exactly the case that needs them. They resolve to paths now, appended to the prompt as `Attached files (read from disk)`. Ids arrive in a websocket message and become a filesystem path, so they are checked rather than trusted: only the `<uuid>.<ext>` shape uploads actually generate, and only in the two directories uploads actually write to. Verified — a valid id resolves, an unknown one yields nothing, and `../../../../etc/passwd` and `; rm -rf /` yield nothing. Both upload directories are searched because the first message of a chat carries ids written to `tmp/` (no session yet) and later ones carry ids under the session. PDFs may be 25 MB against an image's 5: nothing carries them into a prompt, so the ceiling is storage rather than context. tsgo clean, frontend builds, 914 pass / 7 fail unchanged. `[open]` For a MEMBER the file lands under `data/<email>/attachments/`, and whether their Linux user can read it depends on the confinement ACLs. Untested — the owner path is what was verified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
49 lines
2.1 KiB
TypeScript
49 lines
2.1 KiB
TypeScript
import { existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { getAttachmentsDir, getTmpAttachmentsDir } from '../../data-path';
|
|
import { getUserById } from 'officerdb';
|
|
|
|
/**
|
|
* Where an attached file ended up, so the agent can be told to read it.
|
|
*
|
|
* ── Why a path and not the bytes ──
|
|
*
|
|
* An image goes to the model as base64 inside the message. A PDF cannot: it would be megabytes of base64
|
|
* in the turn, re-sent for the rest of the conversation, to produce text the agent's own tools extract
|
|
* better. Reading a document is something `Read` already does well — page ranges, and re-reading a
|
|
* section later without paying for it twice.
|
|
*
|
|
* So the file stays where `/upload` put it and the prompt names it. This is a machine where the agent
|
|
* runs as the person who attached it, so it can simply open it.
|
|
*
|
|
* ── The ids are checked, not trusted ──
|
|
*
|
|
* An id arrives in a websocket message, and it is used to build a path. `/upload` generates them as
|
|
* `<uuid>.<ext>`, so anything that is not that shape did not come from us — and a `..` inside one would
|
|
* name a file outside the attachment directory entirely. Only the two directories uploads actually write
|
|
* to are searched, and only ids matching the shape uploads actually produce.
|
|
*/
|
|
const ATTACHMENT_ID = /^[a-f0-9-]{36}\.[A-Za-z0-9]{1,8}$/;
|
|
|
|
export async function attachedFilePaths(
|
|
userId: number,
|
|
sessionId: string,
|
|
attachmentIds: string[] | undefined,
|
|
): Promise<string[]> {
|
|
if (!attachmentIds?.length) return [];
|
|
|
|
const user = await getUserById(userId).catch(() => null);
|
|
if (!user?.email) return [];
|
|
|
|
const paths: string[] = [];
|
|
for (const id of attachmentIds) {
|
|
if (!ATTACHMENT_ID.test(id)) continue;
|
|
// Uploads before a session exists land in `tmp/`; everything after lands under the session. Both are
|
|
// checked because a first message carries ids from the first shape and later ones from the second.
|
|
const candidates = [join(getAttachmentsDir(user.email, sessionId), id), join(getTmpAttachmentsDir(user.email), id)];
|
|
const found = candidates.find((path) => existsSync(path));
|
|
if (found) paths.push(found);
|
|
}
|
|
return paths;
|
|
}
|