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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ The first run launches a setup wizard, after which the services run in the backg
- **Context-Aware Agent Shortcuts**: Key bar dynamically adapts to the active agent (supporting 24 agents including Claude Code, Codex, and Gemini CLI), with customizable order and keybindings.
- **Native Terminal Styling**: Preserves workstation terminal color schemes, fonts, and sizes, with dynamic font streaming and predictive echo on high-latency links. The interface ships with Maple Mono NF CN, so Chinese and Japanese text stays exactly two cells wide on any device.
- **Windows Shell Support**: Key bar recognizes CMD, PowerShell, and Git Bash, with quick creation of elevated terminal tabs in Herdr.
- **Mobile Image Uploads**: Send photos or screenshots from your phone directly to the workstation, inserting local file paths straight into the agent prompt.
- **Mobile Image & File Uploads**: Send photos, screenshots or any file (up to 3 MB) from your phone directly to the workstation, inserting local file paths straight into the agent prompt.
- **Flexible Connection Modes**: Supports local-only, LAN / Tailscale, official public relay, or self-hosted relays.
- **Multi-Workstation Management**: Manage and switch between multiple Herdr workstations from a single browser session, with an operator dashboard for relay administrators.
- **Cross-Platform**: Runs on Linux, macOS, and Windows 10/11 with a bilingual TUI and background service supervisors.
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ herdr-remote
- **智能体专属快捷键**:按键栏自动跟随当前窗格中的智能体(支持 Claude Code、Codex、Gemini CLI 等 24 种),支持调整顺序与重新绑定。
- **还原工作站终端体验**:自动沿用工作站终端的配色、字体与字号,支持字体动态流式分发与预测回显;界面内置 Maple Mono NF CN,任何设备上中日文都严格占两格。
- **Windows Shell 支持**:按键栏自动识别 CMD、PowerShell 和 Git Bash,支持在 Herdr 中快速新建提权终端标签页。
- **移动端图片直传**:手机上的照片或截图可直接保存到工作站,并将文件路径自动填入当前智能体的输入框。
- **移动端图片与文件直传**:手机上的照片、截图或任意文件(不超过 3 MB)可直接保存到工作站,并将文件路径自动填入当前智能体的输入框。
- **灵活连接模式**:支持本机访问、局域网 / Tailscale、官方公共 Relay 或自建私有 Relay。
- **多工作站管理**:单浏览器统一管理多台工作站实例,提供 Relay 运维管理面板。
- **跨平台支持**:支持 Linux、macOS 与 Windows 10/11,提供中英双语 TUI 与后台守护服务。
Expand Down
31 changes: 21 additions & 10 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,9 +409,9 @@ persisted by the relay.

## Pasted image files

Browsers can transfer clipboard images to the workstation. Because PTY streams only consume text,
the image payload is saved to host state storage and the resulting local file path is pasted
into the active terminal.
Browsers can transfer clipboard images, and files of any type picked from the key bar, to the
workstation. Because PTY streams only consume text, the payload is saved to host state storage
and the resulting local file path is pasted into the active terminal.

### Client to relay (`paste_file`)

Expand All @@ -425,16 +425,22 @@ Sent as a JSON message over `/ws/client`:
}
```

- Allowed MIME types: `image/png`, `image/jpeg`, `image/webp`, `image/gif`.
- Size limit: 3 MB decoded raw binary (base64 payload must not exceed ~4 MB).
- Allowed MIME types: `image/png`, `image/jpeg`, `image/webp`, `image/gif`, and
`application/octet-stream` for a file of any type. Only a file carries `name`, its own file
name, which is never used as a path (see below).
- Size limit: 3 MB decoded raw binary (base64 payload must not exceed ~4 MB). The browser
compresses an image to fit; it refuses a file over the limit without sending it.
- Error codes returned on client error:
- `paste_file_unsupported`: MIME type is not in the whitelist or payload is malformed.
- `paste_file_too_large`: Decoded payload exceeds 3 MB. The connection remains open.
- `paste_file_host_outdated`: A file was sent to a host that does not advertise the
`paste_any_file` capability, i.e. one from before file uploads.

### Relay to host

The relay validates the MIME whitelist and 3 MB payload cap, stamps the message with the
originating client's `streamId`, and forwards it over `/ws/host`:
The relay validates the MIME whitelist, the 3 MB payload cap and an image's magic bytes,
replaces a file's `name` with its sanitized form (`sanitizePasteFileName`), stamps the message
with the originating client's `streamId`, and forwards it over `/ws/host`:

```json
{
Expand All @@ -450,15 +456,20 @@ originating client's `streamId`, and forwards it over `/ws/host`:

The host connector performs strict independent security checks before writing:
1. **Pinned directory**: Files are written strictly to `<stateDir>/pasted` (directory mode `0o700`).
2. **Unpredictable filename**: Generated via `crypto.randomUUID()`; client-supplied names are never used.
3. **Whitelisted extension**: Derived directly from the validated MIME (`.png`, `.jpg`, `.webp`, `.gif`).
2. **Unpredictable filename**: Generated via `crypto.randomUUID()`. An image ignores any client-supplied
name; a file is saved as `<uuid>-<name>`, its name sanitized again on the host: no directories, no
leading dots, only letters, digits, `.`, `_` and `-`, at most 120 characters — so the pasted path
needs no shell quoting.
3. **Whitelisted extension**: An image's is derived directly from the validated MIME (`.png`, `.jpg`,
`.webp`, `.gif`); a file keeps the extension of its sanitized name.
4. **Host size re-validation**: The host independently enforces the 3 MB ceiling.
5. **Magic bytes sniffing**: Raw binary headers are validated against declared MIME:
- PNG: `89 50 4E 47`
- JPEG: `FF D8 FF`
- GIF: `47 49 46 38`
- WebP: `RIFF` (bytes 0..3) and `WEBP` (bytes 8..11)
Mismatches are rejected to prevent file-type spoofing.
Mismatches are rejected to prevent file-type spoofing. Files of any type are not sniffed:
they claim no type to spoof.
6. **File mode**: Created with mode `0o600`.
7. **Retention and pruning**: Caps at 20 files and 50 MB (oldest deleted first). Residual files older than 24 hours are deleted upon startup.

Expand Down
11 changes: 9 additions & 2 deletions packages/cli/src/connector/host-connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,9 +391,16 @@ export class HostConnector {
return this.sessions.start(message);
}

private savePaste(streamId: string | null, message: { mime: string; dataBase64: string }): void {
private savePaste(
streamId: string | null,
message: { mime: string; dataBase64: string; name?: string },
): void {
try {
const savedPath = savePastedFile({ mime: message.mime, dataBase64: message.dataBase64 });
const savedPath = savePastedFile({
mime: message.mime,
dataBase64: message.dataBase64,
name: message.name,
});
sendJson(this.ws, { type: 'paste_file_ready', clientId: streamId, path: savedPath });
} catch (error) {
sendJson(this.ws, {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/connector/host-hello.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function hostHello(identity: HostHelloIdentity) {
CAPABILITY.hostHandoff,
CAPABILITY.idleHeartbeat,
CAPABILITY.binaryFrameV2,
CAPABILITY.pasteAnyFile,
...(process.platform === 'win32' ? [CAPABILITY.adminTabs] : []),
],
};
Expand Down
23 changes: 16 additions & 7 deletions packages/cli/src/pasted-files.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
/**
* Secure file storage and validation for pasted clipboard images.
* Secure file storage and validation for pasted clipboard images and uploaded files.
*
* Security requirements:
* 1. Pinned directory: writes strictly to path.join(stateDir(), 'pasted') (directory mode 0o700).
* 2. Unpredictable filename: generated via crypto.randomUUID(), never using client-provided strings.
* 3. Whitelisted extension: derived exclusively from verified MIME type.
* 2. Unpredictable filename: starts with crypto.randomUUID(); a client-provided name is only
* appended to a file's UUID after sanitizePasteFileName, never used as a path.
* 3. Whitelisted extension: an image's is derived exclusively from its verified MIME type.
* 4. Host-side size check: re-validated to enforce <= 3 MB independently of relay checks.
* 5. Magic bytes sniffing: inspects raw binary headers (PNG, JPEG, GIF, WebP) to prevent file-type spoofing.
* 5. Magic bytes sniffing: inspects raw binary headers (PNG, JPEG, GIF, WebP) to prevent image
* type spoofing. Files of any type are not sniffed: nothing claims what they contain.
* 6. File mode: files are created with mode 0o600.
* 7. Automatic pruning: caps at 20 files and 50 MB (oldest deleted first), removes >24h stale files at startup.
*/
Expand All @@ -17,10 +19,12 @@ import path from 'node:path';
import { stateDir } from './paths.js';
import { ensureDir } from 'herdr-remote-relay/state';
import {
PASTE_FILE_MIME,
PASTE_MAX_BYTES,
PASTE_IMAGE_EXTENSIONS,
isPasteImageMime,
hasImageSignature,
sanitizePasteFileName,
} from 'herdr-remote-relay/protocol';

const MAX_SAVED_FILES = 20;
Expand Down Expand Up @@ -98,13 +102,16 @@ function cleanPastedDir({
function savePastedFile({
mime,
dataBase64,
name,
dir = getPastedDir(),
}: {
mime: unknown;
dataBase64: unknown;
name?: unknown;
dir?: string;
}): string {
if (!isPasteImageMime(mime)) {
const imageMime = isPasteImageMime(mime) ? mime : null;
if (!imageMime && mime !== PASTE_FILE_MIME) {
throw new Error(`unsupported MIME type: ${mime}`);
}
if (typeof dataBase64 !== 'string') {
Expand All @@ -119,14 +126,16 @@ function savePastedFile({
throw new Error('file payload is empty');
}

if (!hasImageSignature(mime, buf)) {
if (imageMime && !hasImageSignature(imageMime, buf)) {
throw new Error(`file magic bytes do not match declared MIME type ${mime}`);
}

// Prune before writing new file
cleanPastedDir({ dir });

const fileName = `${crypto.randomUUID()}${PASTE_IMAGE_EXTENSIONS[mime]}`;
const fileName = imageMime
? `${crypto.randomUUID()}${PASTE_IMAGE_EXTENSIONS[imageMime]}`
: `${crypto.randomUUID()}-${sanitizePasteFileName(name)}`;
const filePath = path.join(dir, fileName);

fs.writeFileSync(filePath, buf, { mode: 0o600, flag: 'wx' });
Expand Down
56 changes: 55 additions & 1 deletion packages/cli/tests/pasted-files.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { savePastedFile, cleanPastedDir } from '../src/pasted-files.js';
import { PASTE_MAX_BYTES, PASTE_IMAGE_EXTENSIONS } from 'herdr-remote-relay/protocol';
import {
PASTE_FILE_NAME_MAX_CHARS,
PASTE_IMAGE_EXTENSIONS,
PASTE_MAX_BYTES,
sanitizePasteFileName,
} from 'herdr-remote-relay/protocol';

// Valid magic byte fixtures for each supported image type
const FIXTURES = {
Expand Down Expand Up @@ -199,3 +204,52 @@ test('cleanPastedDir purges stale files older than 24 hours', (t) => {
assert.equal(fs.existsSync(freshPath), true, 'fresh file must remain');
assert.equal(fs.existsSync(stalePath), false, 'stale file >24h must be deleted');
});

test('savePastedFile saves any file under its UUID and sanitized name, without sniffing', (t) => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'host-paste-test-'));
t.onTestFinished(() => fs.rmSync(tmpDir, { recursive: true, force: true }));

const content = Buffer.from('#!/bin/sh\necho not an image\n');
const savedPath = savePastedFile({
mime: 'application/octet-stream',
dataBase64: content.toString('base64'),
name: '..\\..\\Windows/.bashrc copy.sh',
dir: tmpDir,
});

assert.equal(path.dirname(savedPath), tmpDir);
assert.match(
path.basename(savedPath),
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-bashrc_copy\.sh$/i,
);
assert.deepEqual(fs.readFileSync(savedPath), content);
if (process.platform !== 'win32') {
assert.equal(fs.statSync(savedPath).mode & 0o777, 0o600);
}
});

test('sanitizePasteFileName keeps a pasteable name and its extension', () => {
assert.equal(sanitizePasteFileName('报告 2026.pdf'), '报告_2026.pdf');
assert.equal(sanitizePasteFileName('a;rm -rf ~;$(x).txt'), 'a_rm_-rf_x_.txt');
assert.equal(sanitizePasteFileName('...'), 'file');
assert.equal(sanitizePasteFileName(undefined), 'file');
const long = sanitizePasteFileName(`${'x'.repeat(300)}.tar.gz`);
assert.equal(long.length, PASTE_FILE_NAME_MAX_CHARS);
assert.ok(long.endsWith('.gz'));
});

test('savePastedFile still caps any file at the paste size limit', (t) => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'host-paste-test-'));
t.onTestFinished(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
assert.throws(
() =>
savePastedFile({
mime: 'application/octet-stream',
dataBase64: Buffer.alloc(PASTE_MAX_BYTES + 1).toString('base64'),
name: 'big.bin',
dir: tmpDir,
}),
/exceeds maximum limit/,
);
assert.deepEqual(fs.readdirSync(tmpDir), []);
});
6 changes: 6 additions & 0 deletions packages/relay/src/protocol/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export const CAPABILITY = {
binaryFrameV2: 'binary_frame_v2',
/** The host can open an elevated PowerShell as a tab of its Windows Herdr. */
adminTabs: 'admin_tabs',
/** The host saves pasted files of any type, not just images. */
pasteAnyFile: 'paste_any_file',
} as const;

export type ClientRole = 'controller' | 'viewer';
Expand Down Expand Up @@ -64,6 +66,8 @@ export interface ClientPasteFileMessage {
type: 'paste_file';
mime: string;
dataBase64: string;
/** The file's own name; only read when `mime` is `PASTE_FILE_MIME`. */
name?: string;
}

/**
Expand Down Expand Up @@ -470,6 +474,8 @@ export interface RelayPasteFileMessage extends StreamAddressed {
type: 'paste_file';
mime: string;
dataBase64: string;
/** Sanitized file name, present only when `mime` is `PASTE_FILE_MIME`. */
name?: string;
}

/** Sent instead of `host_ready` when the handshake is refused. */
Expand Down
39 changes: 35 additions & 4 deletions packages/relay/src/protocol/paste.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,41 @@
// Rules for an image pasted in a browser: the browser compresses to fit them,
// the relay refuses what breaks them, and the host checks again before it
// writes a file. Browser-safe: no Node APIs.
// Rules for an image or file pasted in a browser: the browser compresses or
// refuses to fit them, the relay refuses what breaks them, and the host checks
// again before it writes a file. Browser-safe: no Node APIs.

/** Largest decoded image a browser may paste. */
/** Largest decoded image or file a browser may paste. */
export const PASTE_MAX_BYTES = 3 * 1024 * 1024;

/**
* The MIME a paste carries when it is any file rather than an image. Its bytes
* are never sniffed, so it is saved under its (sanitized) name, not a type.
*/
export const PASTE_FILE_MIME = 'application/octet-stream';

/** Longest file name, in characters, a pasted file keeps on the host. */
export const PASTE_FILE_NAME_MAX_CHARS = 120;

/**
* A pasted file's name made safe to save and to paste into a shell unquoted:
* no directories, no hidden files, nothing but letters, digits, `.`, `_` and
* `-`. A name with nothing left becomes `file`.
*/
export function sanitizePasteFileName(name: unknown): string {
if (typeof name !== 'string') return 'file';
const base = name.normalize('NFC').split(/[\\/]/).pop() ?? '';
let safe = base
.replace(/[^\p{L}\p{N}._-]+/gu, '_')
// Leading dots would hide the file; trailing ones Windows drops silently.
.replace(/^[._]+|[._]+$/g, '');
if ([...safe].length > PASTE_FILE_NAME_MAX_CHARS) {
const dot = safe.lastIndexOf('.');
// Keep a short extension: it is what tells an agent what the file is.
const ext = dot > 0 && safe.length - dot <= 16 ? safe.slice(dot) : '';
const stem = [...safe.slice(0, safe.length - ext.length)];
safe = stem.slice(0, PASTE_FILE_NAME_MAX_CHARS - ext.length).join('') + ext;
}
return safe || 'file';
}

/** Image types a paste may carry, with the extension the host saves them under. */
export const PASTE_IMAGE_EXTENSIONS = {
'image/png': '.png',
Expand Down
Loading
Loading