Skip to content

Commit 26be454

Browse files
committed
feat: add file download endpoint returning raw file
GET /api/session/:code/download?path=/remote/file returns the actual file bytes (not base64 JSON). Uses file_read WebSocket message to stream file from bridge. - Bridge: handle file_read message, read file, base64, send file_read_result back (50MB limit) - Server: downloadRoute sends file_read WS message, decodes result, returns raw Response with Content-Disposition
1 parent 8c38ef3 commit 26be454

2 files changed

Lines changed: 177 additions & 12 deletions

File tree

bridge/main.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"bytes"
55
"context"
6+
"encoding/base64"
67
"encoding/json"
78
"fmt"
89
"io"
@@ -28,6 +29,7 @@ const (
2829
reconnectBaseDelay = 1 * time.Second
2930
reconnectMaxDelay = 30 * time.Second
3031
pongWait = 60 * time.Second
32+
maxFileSize = 50 * 1024 * 1024
3133
)
3234

3335
var sessionCodePattern = regexp.MustCompile(`^[0-9a-f]{12}$`)
@@ -324,13 +326,80 @@ func readCommands(wsc *wsConn, dot string) bool {
324326
"exit_code": code,
325327
"truncated": truncated,
326328
})
329+
case "file_read":
330+
var msg struct {
331+
ID string `json:"id"`
332+
Path string `json:"path"`
333+
}
334+
_ = json.Unmarshal(data, &msg)
335+
if msg.ID == "" {
336+
continue
337+
}
338+
sendFile(wsc, msg.ID, msg.Path)
327339
case "bye":
328340
wsc.close()
329341
return false
330342
}
331343
}
332344
}
333345

346+
func sendFile(wsc *wsConn, id, path string) {
347+
f, err := os.Open(path)
348+
if err != nil {
349+
wsc.sendJSON(map[string]any{
350+
"type": "file_read_result",
351+
"id": id,
352+
"path": path,
353+
"error": err.Error(),
354+
})
355+
return
356+
}
357+
defer f.Close()
358+
359+
fi, err := f.Stat()
360+
if err != nil {
361+
wsc.sendJSON(map[string]any{
362+
"type": "file_read_result",
363+
"id": id,
364+
"path": path,
365+
"error": err.Error(),
366+
})
367+
return
368+
}
369+
370+
size := fi.Size()
371+
if size > maxFileSize {
372+
wsc.sendJSON(map[string]any{
373+
"type": "file_read_result",
374+
"id": id,
375+
"path": path,
376+
"error": fmt.Sprintf("file too large: %d bytes (max %d)", size, maxFileSize),
377+
})
378+
return
379+
}
380+
381+
data, err := io.ReadAll(f)
382+
if err != nil {
383+
wsc.sendJSON(map[string]any{
384+
"type": "file_read_result",
385+
"id": id,
386+
"path": path,
387+
"error": err.Error(),
388+
})
389+
return
390+
}
391+
392+
encoded := base64.StdEncoding.EncodeToString(data)
393+
wsc.sendJSON(map[string]any{
394+
"type": "file_read_result",
395+
"id": id,
396+
"path": path,
397+
"data": encoded,
398+
"size": size,
399+
"encoding": "base64",
400+
})
401+
}
402+
334403
// resolveWithFallback resolves hostname, falling back to 8.8.8.8:53 if the
335404
// system resolver fails (common on Android/Termux where /etc/resolv.conf
336405
// points to a non-existent localhost DNS server).

server/app.ts

Lines changed: 108 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,20 @@ type PendingCommand = {
5858
timer: Timer;
5959
};
6060

61+
type PendingFileRead = {
62+
resolve: (value: { path: string; data: string; size: number }) => void;
63+
reject: (error: Error) => void;
64+
timer: Timer;
65+
};
66+
6167
interface Session {
6268
code: string;
6369
meta?: AgentMeta;
6470
createdAt: number;
6571
lastActivity: number;
6672
agent: ServerWebSocket<unknown> | null;
6773
pendingHttp: Map<string, PendingCommand>;
74+
pendingFileRead: Map<string, PendingFileRead>;
6875
}
6976

7077
type ProtocolMsg =
@@ -79,7 +86,15 @@ type ProtocolMsg =
7986
| { type: "error"; message: string }
8087
| { type: "bye"; reason?: string }
8188
| { type: "ping" }
82-
| { type: "pong" };
89+
| { type: "pong" }
90+
| {
91+
type: "file_read_result";
92+
id: string;
93+
path: string;
94+
data: string;
95+
size: number;
96+
error?: string;
97+
};
8398

8499
type RouteRequest = Request & { params: Record<string, string | undefined> };
85100

@@ -102,6 +117,7 @@ export function createSession(code: string): Session {
102117
lastActivity: now,
103118
agent: null,
104119
pendingHttp: new Map(),
120+
pendingFileRead: new Map(),
105121
};
106122
sessions.set(code, session);
107123
return session;
@@ -120,6 +136,11 @@ export function closeSession(code: string): void {
120136
pending.reject(new Error("Session closed"));
121137
}
122138
session.pendingHttp.clear();
139+
for (const pending of session.pendingFileRead.values()) {
140+
clearTimeout(pending.timer);
141+
pending.reject(new Error("Session closed"));
142+
}
143+
session.pendingFileRead.clear();
123144
session.agent?.close();
124145
sessions.delete(code);
125146
}
@@ -138,6 +159,9 @@ export const routes = {
138159
GET: commandRoute,
139160
POST: commandRoute,
140161
},
162+
"/api/session/:code/download": {
163+
GET: downloadRoute,
164+
},
141165
"/api/session/:code/prompt.md": { GET: apiPromptRoute },
142166
"/c/:code": connectRoute,
143167
"/c/:code/windows.ps1": connectWindowsRoute,
@@ -184,6 +208,35 @@ export function commandRoute(req: RouteRequest): Promise<Response> {
184208
return handleCommand(req, new URL(req.url), session);
185209
}
186210

211+
export async function downloadRoute(req: RouteRequest): Promise<Response> {
212+
const code = routeCode(req);
213+
if (!code) return notFound();
214+
const session = getSession(code);
215+
if (!session) return notFound();
216+
if (!session.agent) return json({ error: "Agent not connected" }, 409);
217+
218+
const url = new URL(req.url);
219+
const path = url.searchParams.get("path");
220+
if (!path) return json({ error: "Missing ?path= query parameter" }, 400);
221+
222+
try {
223+
const result = await executeFileRead(session, path);
224+
const bytes = Buffer.from(result.data, "base64");
225+
const filename = path.split("/").filter(Boolean).pop() || "download";
226+
return new Response(bytes, {
227+
headers: {
228+
"Content-Type": "application/octet-stream",
229+
"Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`,
230+
"Content-Length": String(result.size),
231+
...NO_CACHE,
232+
},
233+
});
234+
} catch (error) {
235+
const msg = error instanceof Error ? error.message : "Download failed";
236+
return json({ error: msg }, 500);
237+
}
238+
}
239+
187240
export function apiPromptRoute(req: RouteRequest): Response {
188241
return promptResponse(req);
189242
}
@@ -265,20 +318,36 @@ export function handleAgentMessage(
265318
raw: string,
266319
): void {
267320
const msg = parseMessage(raw);
268-
if (!msg || msg.type !== "command_result") return;
321+
if (!msg) return;
269322

270323
for (const session of sessions.values()) {
271324
if (session.agent !== ws) continue;
272-
const pending = session.pendingHttp.get(msg.id);
273-
if (!pending) return;
274-
clearTimeout(pending.timer);
275-
session.pendingHttp.delete(msg.id);
276-
pending.resolve({
277-
output: msg.output,
278-
exit_code: msg.exit_code,
279-
truncated: msg.truncated === true,
280-
});
281-
return;
325+
326+
if (msg.type === "command_result") {
327+
const pending = session.pendingHttp.get(msg.id);
328+
if (!pending) return;
329+
clearTimeout(pending.timer);
330+
session.pendingHttp.delete(msg.id);
331+
pending.resolve({
332+
output: msg.output,
333+
exit_code: msg.exit_code,
334+
truncated: msg.truncated === true,
335+
});
336+
return;
337+
}
338+
339+
if (msg.type === "file_read_result" && msg.id) {
340+
const pending = session.pendingFileRead.get(msg.id);
341+
if (!pending) return;
342+
clearTimeout(pending.timer);
343+
session.pendingFileRead.delete(msg.id);
344+
if (msg.error) {
345+
pending.reject(new Error(msg.error));
346+
} else {
347+
pending.resolve({ path: msg.path, data: msg.data, size: msg.size });
348+
}
349+
return;
350+
}
282351
}
283352
}
284353

@@ -288,6 +357,11 @@ export function handleDisconnect(ws: ServerWebSocket<unknown>): void {
288357
session.agent = null;
289358
session.meta = undefined;
290359
session.lastActivity = Date.now();
360+
for (const pending of session.pendingFileRead.values()) {
361+
clearTimeout(pending.timer);
362+
pending.reject(new Error("Agent disconnected"));
363+
}
364+
session.pendingFileRead.clear();
291365
return;
292366
}
293367
}
@@ -524,6 +598,28 @@ function executeHttpCommand(
524598
});
525599
}
526600

601+
function executeFileRead(
602+
session: Session,
603+
path: string,
604+
timeoutSec = 30,
605+
): Promise<{ path: string; data: string; size: number }> {
606+
if (!session.agent) return Promise.reject(new Error("Agent not connected"));
607+
608+
const id = crypto.randomUUID();
609+
const timeoutMs = timeoutSec * 1000;
610+
611+
return new Promise((resolve, reject) => {
612+
const timer = setTimeout(() => {
613+
session.pendingFileRead.delete(id);
614+
reject(new Error(`File read timed out after ${Math.round(timeoutMs / 1000)}s`));
615+
}, timeoutMs);
616+
617+
session.pendingFileRead.set(id, { resolve, reject, timer });
618+
session.agent!.send(JSON.stringify({ type: "file_read", id, path }));
619+
session.lastActivity = Date.now();
620+
});
621+
}
622+
527623
function createUniqueSessionCode(): string {
528624
const code = generateCode();
529625
if (sessions.has(code)) return createUniqueSessionCode();

0 commit comments

Comments
 (0)