feat(relay): zero-config remote access via tunnel#830
feat(relay): zero-config remote access via tunnel#830lucasygu wants to merge 1 commit intopaperclipai:masterfrom
Conversation
Greptile SummaryThis PR introduces a zero-config relay tunnel feature that gives any Paperclip instance a public HTTPS subdomain URL via an outbound WebSocket connection to a Cloudflare Workers-based relay server. The implementation is well-structured — the relay module is cleanly separated into Key issues found:
Confidence Score: 3/5
Important Files Changed
|
server/src/relay/auto-register.ts
Outdated
| */ | ||
|
|
||
| import fs from "node:fs"; | ||
| import path from "node:path"; |
There was a problem hiding this comment.
Unused import
path is imported but never referenced anywhere in the file. TypeScript strict-mode compilation may still pass, but this is dead code.
| import path from "node:path"; | |
| import fs from "node:fs"; | |
| import { resolvePaperclipConfigPath, resolvePaperclipEnvPath } from "../paths.js"; |
Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/relay/auto-register.ts
Line: 8
Comment:
**Unused import**
`path` is imported but never referenced anywhere in the file. TypeScript strict-mode compilation may still pass, but this is dead code.
```suggestion
import fs from "node:fs";
import { resolvePaperclipConfigPath, resolvePaperclipEnvPath } from "../paths.js";
```
How can I resolve this? If you propose a fix, please make it concise.
server/src/relay/ws-bridge.ts
Outdated
| localWs.on("message", (data: { toString(): string }) => { | ||
| send({ | ||
| type: "ws-message", | ||
| id: msg.id, | ||
| data: data.toString(), | ||
| }); |
There was a problem hiding this comment.
Binary WebSocket frames are silently corrupted, not rejected
The file header correctly documents that binary frames are unsupported and "will be corrupted," but the code calls data.toString() on whatever the ws library delivers. For binary frames the library delivers a Buffer, and calling .toString() decodes it as UTF-8 — which corrupts arbitrary binary payloads. The corruption is invisible to the sender and the relay consumer: both sides will receive malformed data with no error signal.
If Paperclip's local WebSocket endpoints ever emit binary frames (e.g. Uint8Array, protobuf payloads), this would produce hard-to-debug data corruption. At a minimum the handler should detect and drop/error on binary:
| localWs.on("message", (data: { toString(): string }) => { | |
| send({ | |
| type: "ws-message", | |
| id: msg.id, | |
| data: data.toString(), | |
| }); | |
| localWs.on("message", (data: Buffer | string) => { | |
| if (Buffer.isBuffer(data)) { | |
| // Binary frames are not supported over the relay tunnel (JSON text only). | |
| // Drop and notify the relay so the client connection can be closed cleanly. | |
| send({ type: "ws-error", id: msg.id, message: "Binary WebSocket frames are not supported through the relay tunnel" }); | |
| return; | |
| } | |
| send({ | |
| type: "ws-message", | |
| id: msg.id, | |
| data: data.toString(), | |
| }); | |
| }); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/relay/ws-bridge.ts
Line: 54-59
Comment:
**Binary WebSocket frames are silently corrupted, not rejected**
The file header correctly documents that binary frames are unsupported and "will be corrupted," but the code calls `data.toString()` on whatever the `ws` library delivers. For binary frames the library delivers a `Buffer`, and calling `.toString()` decodes it as UTF-8 — which corrupts arbitrary binary payloads. The corruption is invisible to the sender and the relay consumer: both sides will receive malformed data with no error signal.
If Paperclip's local WebSocket endpoints ever emit binary frames (e.g. Uint8Array, protobuf payloads), this would produce hard-to-debug data corruption. At a minimum the handler should detect and drop/error on binary:
```suggestion
localWs.on("message", (data: Buffer | string) => {
if (Buffer.isBuffer(data)) {
// Binary frames are not supported over the relay tunnel (JSON text only).
// Drop and notify the relay so the client connection can be closed cleanly.
send({ type: "ws-error", id: msg.id, message: "Binary WebSocket frames are not supported through the relay tunnel" });
return;
}
send({
type: "ws-message",
id: msg.id,
data: data.toString(),
});
});
```
How can I resolve this? If you propose a fix, please make it concise.| try { | ||
| let content = ""; | ||
| if (fs.existsSync(envPath)) { | ||
| content = fs.readFileSync(envPath, "utf-8"); | ||
| if (!content.endsWith("\n")) content += "\n"; | ||
| } | ||
| // Remove existing key if present (shouldn't be, but safe) | ||
| const lines = content.split("\n").filter((l) => !l.startsWith(`${key}=`)); | ||
| lines.push(`${key}=${value}`); | ||
| fs.writeFileSync(envPath, lines.join("\n") + "\n", "utf-8"); |
There was a problem hiding this comment.
Extra blank line inserted into .env on every registration
When the existing .env file ends with a newline (the standard UNIX case), content.split("\n") produces a trailing empty string: ["FOO=bar", ""]. The filter step does not remove this empty string, so after lines.push(...) the array becomes ["FOO=bar", "", "<KEY>=<value>"]. Joining then adds \n at the end, producing an unwanted blank line between the existing entries and the newly appended key.
Consider filtering out empty strings before pushing the new entry:
| try { | |
| let content = ""; | |
| if (fs.existsSync(envPath)) { | |
| content = fs.readFileSync(envPath, "utf-8"); | |
| if (!content.endsWith("\n")) content += "\n"; | |
| } | |
| // Remove existing key if present (shouldn't be, but safe) | |
| const lines = content.split("\n").filter((l) => !l.startsWith(`${key}=`)); | |
| lines.push(`${key}=${value}`); | |
| fs.writeFileSync(envPath, lines.join("\n") + "\n", "utf-8"); | |
| // Remove existing key if present (shouldn't be, but safe) | |
| const lines = content.split("\n").filter((l) => l !== "" && !l.startsWith(`${key}=`)); | |
| lines.push(`${key}=${value}`); | |
| fs.writeFileSync(envPath, lines.join("\n") + "\n", "utf-8"); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/relay/auto-register.ts
Line: 70-79
Comment:
**Extra blank line inserted into `.env` on every registration**
When the existing `.env` file ends with a newline (the standard UNIX case), `content.split("\n")` produces a trailing empty string: `["FOO=bar", ""]`. The `filter` step does not remove this empty string, so after `lines.push(...)` the array becomes `["FOO=bar", "", "<KEY>=<value>"]`. Joining then adds `\n` at the end, producing an unwanted blank line between the existing entries and the newly appended key.
Consider filtering out empty strings before pushing the new entry:
```suggestion
// Remove existing key if present (shouldn't be, but safe)
const lines = content.split("\n").filter((l) => l !== "" && !l.startsWith(`${key}=`));
lines.push(`${key}=${value}`);
fs.writeFileSync(envPath, lines.join("\n") + "\n", "utf-8");
```
How can I resolve this? If you propose a fix, please make it concise.Adds a relay tunnel client that connects to a Cloudflare Workers-based relay server, enabling remote access to Paperclip instances without port forwarding. Each instance gets a unique subdomain (e.g. d4lsc.relay.com). Server changes (server/src/relay/): - Auto-registration: POST /register on first start, persist token to .env, add relay hostname to config.json allowedHostnames - HTTP forwarder with proxyRes error handling and multi-value set-cookie - WebSocket bridge with close code validation (RFC 6455) - Relay client with exponential backoff reconnect and graceful shutdown - Token sent via Authorization header (not URL query string) Security: - Relay requires authenticated deployment mode — guard fires before any registration to prevent orphaned credentials in local_trusted - Follows existing Tailscale guard pattern in server/src/index.ts Documentation: - doc/RELAY.md: full relay server source, architecture, security model - docs/deploy/relay-tunnel.md: user-facing setup guide (Mintlify) - Updated deployment-modes, overview, env-vars, Tailscale, CLI, DEVELOPING, local-development, and README with relay references - All examples use generic your-relay-server.com; paperclip-relay.com clearly labeled as community test relay (not affiliated with Paperclip AI) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
35dc304 to
95e8642
Compare
|
Superseded by a new PR with binary multiplexing protocol, isBinary fix, and board mutation guard fix. |
Summary
<id>.relay-domain.com)PAPERCLIP_RELAY_URLis set, persisting token to.envand adding the relay hostname toallowedHostnamesDetails
Server-side changes:
server/src/relay/— new module withrelay-client.ts,http-forwarder.ts,ws-bridge.ts,auto-register.ts,protocol.tsserver/src/index.ts— deployment mode guard, auto-registration, relay client startup, unconditional graceful shutdownAuthorization: Bearerheader (not URL query string)/tunnelconnectionnew URL()parsingDocumentation:
doc/RELAY.md— full relay server architecture and Cloudflare Worker sourcedocs/deploy/relay-tunnel.md— user-facing Mintlify guide with setup steps, comparison table, troubleshootingdocs/deploy/deployment-modes.md,overview.md,tailscale-private-access.md,environment-variables.md,local-development.mddoc/DEVELOPING.md,doc/CLI.mdCloses #742
Test plan
paperclip-relay.com🤖 Generated with Claude Code