diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index d0af01246..951e7dc65 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1,11 +1,14 @@ import express, { type Express } from "express"; import basicAuth from "express-basic-auth"; import { createServer as createHTTPServer, type Server as HTTPServer } from "http"; +import { createReadStream } from "fs"; +import { stat } from "fs/promises"; import { randomUUID } from "node:crypto"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { VoiceAssistantWebSocketServer } from "./websocket-server.js"; +import { DownloadTokenStore } from "./file-download/token-store.js"; import { initializeSTT, type STTConfig } from "./agent/stt-openai.js"; import { initializeTTS, type TTSConfig } from "./agent/tts-openai.js"; import { listConversations, deleteConversation } from "./persistence.js"; @@ -49,6 +52,7 @@ export type PaseoDaemonConfig = { agentRegistryPath: string; agentControlMcp: AgentControlMcpConfig; openai?: PaseoOpenAIConfig; + downloadTokenTtlMs?: number; }; export type PaseoDaemonHandles = { @@ -67,9 +71,12 @@ export async function createPaseoDaemon( const basicAuthUsers = config.auth.basicUsers; const staticDir = config.staticDir; const authRealm = config.auth.realm ?? "Voice Assistant"; + const downloadTokenTtlMs = config.downloadTokenTtlMs ?? 60000; const agentMcpBearerToken = config.auth.agentMcpBearerToken; + const downloadTokenStore = new DownloadTokenStore({ ttlMs: downloadTokenTtlMs }); + const app = express(); // Serve static files from public directory (no auth required for APK downloads) @@ -124,6 +131,56 @@ export async function createPaseoDaemon( } }); + app.get("/api/files/download", async (req, res) => { + const token = + typeof req.query.token === "string" && req.query.token.trim().length > 0 + ? req.query.token.trim() + : null; + + if (!token) { + res.status(400).json({ error: "Missing download token" }); + return; + } + + const entry = downloadTokenStore.consumeToken(token); + if (!entry) { + res.status(403).json({ error: "Invalid or expired token" }); + return; + } + + try { + const fileStats = await stat(entry.absolutePath); + if (!fileStats.isFile()) { + res.status(404).json({ error: "File not found" }); + return; + } + + const safeFileName = entry.fileName.replace(/["\r\n]/g, "_"); + res.setHeader("Content-Type", entry.mimeType); + res.setHeader( + "Content-Disposition", + `attachment; filename="${safeFileName}"` + ); + res.setHeader("Content-Length", entry.size.toString()); + + const stream = createReadStream(entry.absolutePath); + stream.on("error", (error) => { + console.error("[API] Failed to stream download:", error); + if (!res.headersSent) { + res.status(500).json({ error: "Failed to read file" }); + } else { + res.end(); + } + }); + stream.pipe(res); + } catch (error) { + console.error("[API] Failed to download file:", error); + if (!res.headersSent) { + res.status(404).json({ error: "File not found" }); + } + } + }); + const httpServer = createHTTPServer(app); const agentRegistry = new AgentRegistry(config.agentRegistryPath); @@ -252,6 +309,7 @@ export async function createPaseoDaemon( httpServer, agentManager, agentRegistry, + downloadTokenStore, { agentMcpUrl: config.agentControlMcp.url, agentMcpHeaders: config.agentControlMcp.headers, diff --git a/packages/server/src/server/daemon.e2e.test.ts b/packages/server/src/server/daemon.e2e.test.ts index e8098bf2d..142a1c03d 100644 --- a/packages/server/src/server/daemon.e2e.test.ts +++ b/packages/server/src/server/daemon.e2e.test.ts @@ -499,6 +499,136 @@ describe("daemon E2E", () => { ); }); + describe("file download tokens", () => { + test( + "issues token over WS and downloads via HTTP", + async () => { + const cwd = tmpCwd(); + const filePath = path.join(cwd, "download.txt"); + const fileContents = "download test payload"; + writeFileSync(filePath, fileContents, "utf-8"); + + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd, + title: "Download Token Test Agent", + }); + + expect(agent.id).toBeTruthy(); + + const tokenResponse = await ctx.client.requestDownloadToken( + agent.id, + "download.txt" + ); + + expect(tokenResponse.error).toBeNull(); + expect(tokenResponse.token).toBeTruthy(); + expect(tokenResponse.fileName).toBe("download.txt"); + + const authHeader = ctx.daemon.agentMcpAuthHeader; + expect(authHeader).toBeTruthy(); + + const response = await fetch( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`, + { headers: { Authorization: authHeader! } } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + tokenResponse.mimeType + ); + const disposition = response.headers.get("content-disposition") ?? ""; + expect(disposition).toContain("download.txt"); + + const body = await response.text(); + expect(body).toBe(fileContents); + + rmSync(cwd, { recursive: true, force: true }); + }, + 60000 + ); + + test( + "rejects invalid token", + async () => { + const authHeader = ctx.daemon.agentMcpAuthHeader; + expect(authHeader).toBeTruthy(); + + const response = await fetch( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=invalid-token`, + { headers: { Authorization: authHeader! } } + ); + + expect(response.status).toBe(403); + }, + 30000 + ); + + test( + "rejects expired token", + async () => { + await ctx.cleanup(); + ctx = await createDaemonTestContext({ downloadTokenTtlMs: 50 }); + + const cwd = tmpCwd(); + const filePath = path.join(cwd, "expired.txt"); + writeFileSync(filePath, "expired", "utf-8"); + + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd, + title: "Expired Token Test Agent", + }); + + const tokenResponse = await ctx.client.requestDownloadToken( + agent.id, + "expired.txt" + ); + + expect(tokenResponse.error).toBeNull(); + expect(tokenResponse.token).toBeTruthy(); + + await new Promise((resolve) => setTimeout(resolve, 150)); + + const authHeader = ctx.daemon.agentMcpAuthHeader; + expect(authHeader).toBeTruthy(); + + const response = await fetch( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`, + { headers: { Authorization: authHeader! } } + ); + + expect(response.status).toBe(403); + + rmSync(cwd, { recursive: true, force: true }); + }, + 60000 + ); + + test( + "rejects paths outside the agent cwd", + async () => { + const cwd = tmpCwd(); + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd, + title: "Outside Path Token Test Agent", + }); + + const tokenResponse = await ctx.client.requestDownloadToken( + agent.id, + "../outside.txt" + ); + + expect(tokenResponse.token).toBeNull(); + expect(tokenResponse.error).toBeTruthy(); + + rmSync(cwd, { recursive: true, force: true }); + }, + 60000 + ); + }); + describe("persistence flow", () => { test( "persists and resumes Codex agent with conversation history", diff --git a/packages/server/src/server/file-download/token-store.ts b/packages/server/src/server/file-download/token-store.ts new file mode 100644 index 000000000..76e9fabd1 --- /dev/null +++ b/packages/server/src/server/file-download/token-store.ts @@ -0,0 +1,65 @@ +import { randomUUID } from "node:crypto"; + +export type DownloadTokenEntry = { + token: string; + agentId: string; + path: string; + absolutePath: string; + fileName: string; + mimeType: string; + size: number; + expiresAt: number; +}; + +type DownloadTokenStoreOptions = { + ttlMs: number; + now?: () => number; +}; + +export class DownloadTokenStore { + private readonly ttlMs: number; + private readonly now: () => number; + private readonly tokens = new Map(); + + constructor(options: DownloadTokenStoreOptions) { + this.ttlMs = options.ttlMs; + this.now = options.now ?? (() => Date.now()); + } + + issueToken(input: Omit): DownloadTokenEntry { + this.pruneExpired(); + const token = randomUUID(); + const expiresAt = this.now() + this.ttlMs; + const entry: DownloadTokenEntry = { + ...input, + token, + expiresAt, + }; + this.tokens.set(token, entry); + return entry; + } + + consumeToken(token: string): DownloadTokenEntry | null { + const entry = this.tokens.get(token); + if (!entry) { + return null; + } + + this.tokens.delete(token); + + if (entry.expiresAt <= this.now()) { + return null; + } + + return entry; + } + + private pruneExpired(): void { + const now = this.now(); + for (const [token, entry] of this.tokens) { + if (entry.expiresAt <= now) { + this.tokens.delete(token); + } + } + } +} diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index 76158c5b0..dfbd26b1c 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -163,6 +163,39 @@ export async function readExplorerFile({ }; } +export async function getDownloadableFileInfo({ + root, + relativePath, +}: ReadFileParams): Promise<{ + path: string; + absolutePath: string; + fileName: string; + mimeType: string; + size: number; +}> { + const filePath = await resolveScopedPath({ root, relativePath }); + const stats = await fs.stat(filePath); + + if (!stats.isFile()) { + throw new Error("Requested path is not a file"); + } + + const ext = path.extname(filePath).toLowerCase(); + const mimeType = TEXT_EXTENSIONS.has(ext) + ? "text/plain" + : ext in IMAGE_MIME_TYPES + ? IMAGE_MIME_TYPES[ext] + : "application/octet-stream"; + + return { + path: normalizeRelativePath({ root, targetPath: filePath }), + absolutePath: filePath, + fileName: path.basename(filePath), + mimeType, + size: stats.size, + }; +} + async function resolveScopedPath({ root, relativePath = ".", diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index 61c1a0be9..9b99e1280 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -483,6 +483,12 @@ export const FileExplorerRequestSchema = z.object({ mode: z.enum(["list", "file"]), }); +export const FileDownloadTokenRequestSchema = z.object({ + type: z.literal("file_download_token_request"), + agentId: z.string(), + path: z.string(), +}); + export const ClearAgentAttentionMessageSchema = z.object({ type: z.literal("clear_agent_attention"), agentId: z.union([z.string(), z.array(z.string())]), @@ -511,6 +517,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ AgentPermissionResponseMessageSchema, GitDiffRequestSchema, FileExplorerRequestSchema, + FileDownloadTokenRequestSchema, ListPersistedAgentsRequestMessageSchema, GitRepoInfoRequestMessageSchema, ClearAgentAttentionMessageSchema, @@ -744,6 +751,19 @@ export const FileExplorerResponseSchema = z.object({ }), }); +export const FileDownloadTokenResponseSchema = z.object({ + type: z.literal("file_download_token_response"), + payload: z.object({ + agentId: z.string(), + path: z.string(), + token: z.string().nullable(), + fileName: z.string().nullable(), + mimeType: z.string().nullable(), + size: z.number().nullable(), + error: z.string().nullable(), + }), +}); + const GitBranchInfoSchema = z.object({ name: z.string(), isCurrent: z.boolean(), @@ -795,6 +815,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ListPersistedAgentsResponseSchema, GitDiffResponseSchema, FileExplorerResponseSchema, + FileDownloadTokenResponseSchema, GitRepoInfoResponseSchema, ListProviderModelsResponseMessageSchema, ]); @@ -851,6 +872,8 @@ export type GitDiffRequest = z.infer; export type GitDiffResponse = z.infer; export type FileExplorerRequest = z.infer; export type FileExplorerResponse = z.infer; +export type FileDownloadTokenRequest = z.infer; +export type FileDownloadTokenResponse = z.infer; export type RestartServerRequestMessage = z.infer; export type ClearAgentAttentionMessage = z.infer; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8b43579a0..1dfc3e669 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -17,6 +17,7 @@ import { type SessionInboundMessage, type SessionOutboundMessage, type FileExplorerRequest, + type FileDownloadTokenRequest, type GitSetupOptions, } from "./messages.js"; import { getSystemPrompt } from "./agent/system-prompt.js"; @@ -54,7 +55,9 @@ import { expandTilde } from "./terminal-mcp/tmux.js"; import { listDirectoryEntries, readExplorerFile, + getDownloadableFileInfo, } from "./file-explorer/service.js"; +import { DownloadTokenStore } from "./file-download/token-store.js"; import { generateAgentTitle, isTitleGeneratorInitialized, @@ -238,12 +241,14 @@ export class Session { private agentManager: AgentManager; private readonly agentRegistry: AgentRegistry; private readonly agentMcpConfig: AgentMcpClientConfig; + private readonly downloadTokenStore: DownloadTokenStore; private agentTitleCache: Map = new Map(); private unsubscribeAgentEvents: (() => void) | null = null; constructor( clientId: string, onMessage: (msg: SessionOutboundMessage) => void, + downloadTokenStore: DownloadTokenStore, agentManager: AgentManager, agentRegistry: AgentRegistry, agentMcpConfig: AgentMcpClientConfig, @@ -255,6 +260,7 @@ export class Session { this.clientId = clientId; this.conversationId = options?.conversationId || uuidv4(); this.onMessage = onMessage; + this.downloadTokenStore = downloadTokenStore; this.agentManager = agentManager; this.agentRegistry = agentRegistry; this.agentMcpConfig = agentMcpConfig; @@ -842,6 +848,10 @@ export class Session { await this.handleFileExplorerRequest(msg); break; + case "file_download_token_request": + await this.handleFileDownloadTokenRequest(msg); + break; + case "list_persisted_agents_request": await this.handleListPersistedAgentsRequest(msg); break; @@ -2082,6 +2092,84 @@ export class Session { } } + /** + * Handle file download token request scoped to an agent's cwd + */ + private async handleFileDownloadTokenRequest( + request: FileDownloadTokenRequest + ): Promise { + const { agentId, path: requestedPath } = request; + + console.log( + `[Session ${this.clientId}] Handling file download token request for agent ${agentId} (${requestedPath})` + ); + + try { + const agents = this.agentManager.listAgents(); + const agent = agents.find((a) => a.id === agentId); + + if (!agent) { + this.emit({ + type: "file_download_token_response", + payload: { + agentId, + path: requestedPath, + token: null, + fileName: null, + mimeType: null, + size: null, + error: `Agent not found: ${agentId}`, + }, + }); + return; + } + + const info = await getDownloadableFileInfo({ + root: agent.cwd, + relativePath: requestedPath, + }); + + const entry = this.downloadTokenStore.issueToken({ + agentId, + path: info.path, + absolutePath: info.absolutePath, + fileName: info.fileName, + mimeType: info.mimeType, + size: info.size, + }); + + this.emit({ + type: "file_download_token_response", + payload: { + agentId, + path: info.path, + token: entry.token, + fileName: entry.fileName, + mimeType: entry.mimeType, + size: entry.size, + error: null, + }, + }); + } catch (error: any) { + console.error( + `[Session ${this.clientId}] Failed to issue download token for agent ${agentId}:`, + error + ); + this.emit({ + type: "file_download_token_response", + payload: { + agentId, + path: requestedPath, + token: null, + fileName: null, + mimeType: null, + size: null, + error: error.message, + }, + }); + } + } + /** * Send current session state (live agents and commands) to client */ diff --git a/packages/server/src/server/test-utils/daemon-client.ts b/packages/server/src/server/test-utils/daemon-client.ts index 2e7fc33e8..592e7e465 100644 --- a/packages/server/src/server/test-utils/daemon-client.ts +++ b/packages/server/src/server/test-utils/daemon-client.ts @@ -534,6 +534,45 @@ export class DaemonClient { ); } + async requestDownloadToken( + agentId: string, + path: string + ): Promise<{ + agentId: string; + path: string; + token: string | null; + fileName: string | null; + mimeType: string | null; + size: number | null; + error: string | null; + }> { + const startPosition = this.messageQueue.length; + + this.send({ type: "file_download_token_request", agentId, path }); + + return this.waitFor( + (msg) => { + if ( + msg.type === "file_download_token_response" && + msg.payload.agentId === agentId + ) { + return { + agentId: msg.payload.agentId, + path: msg.payload.path, + token: msg.payload.token, + fileName: msg.payload.fileName, + mimeType: msg.payload.mimeType, + size: msg.payload.size, + error: msg.payload.error, + }; + } + return null; + }, + 10000, + { skipQueueBefore: startPosition } + ); + } + // ============================================================================ // Provider Models // ============================================================================ diff --git a/packages/server/src/server/test-utils/daemon-test-context.ts b/packages/server/src/server/test-utils/daemon-test-context.ts index 83c8bdf36..16db24ce3 100644 --- a/packages/server/src/server/test-utils/daemon-test-context.ts +++ b/packages/server/src/server/test-utils/daemon-test-context.ts @@ -31,8 +31,10 @@ export interface DaemonTestContext { * }); * ``` */ -export async function createDaemonTestContext(): Promise { - const daemon = await createTestPaseoDaemon(); +export async function createDaemonTestContext( + options?: Parameters[0] +): Promise { + const daemon = await createTestPaseoDaemon(options); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, authHeader: daemon.agentMcpAuthHeader, diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 7dfaedf52..71ea15c24 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -7,6 +7,7 @@ import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js"; type TestPaseoDaemonOptions = { basicUsers?: Record; + downloadTokenTtlMs?: number; }; export type TestPaseoDaemon = { @@ -73,6 +74,7 @@ export async function createTestPaseoDaemon( ? { headers: { Authorization: agentMcpAuthHeader } } : {}), }, + downloadTokenTtlMs: options.downloadTokenTtlMs, }; const daemon = await createPaseoDaemon(config); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 5fecb72a4..712210eb4 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -12,6 +12,7 @@ import { Session } from "./session.js"; import { loadConversation } from "./persistence.js"; import { AgentManager } from "./agent/agent-manager.js"; import { AgentRegistry } from "./agent/agent-registry.js"; +import { DownloadTokenStore } from "./file-download/token-store.js"; type AgentMcpClientConfig = { agentMcpUrl: string; @@ -29,16 +30,19 @@ export class VoiceAssistantWebSocketServer { private clientIdCounter: number = 0; private agentManager: AgentManager; private agentRegistry: AgentRegistry; + private downloadTokenStore: DownloadTokenStore; private readonly agentMcpConfig: AgentMcpClientConfig; constructor( server: HTTPServer, agentManager: AgentManager, agentRegistry: AgentRegistry, + downloadTokenStore: DownloadTokenStore, agentMcpConfig: AgentMcpClientConfig ) { this.agentManager = agentManager; this.agentRegistry = agentRegistry; + this.downloadTokenStore = downloadTokenStore; this.agentMcpConfig = agentMcpConfig; this.wss = new WebSocketServer({ server, path: "/ws" }); @@ -85,6 +89,7 @@ export class VoiceAssistantWebSocketServer { (msg) => { this.sendToClient(ws, wrapSessionMessage(msg)); }, + this.downloadTokenStore, this.agentManager, this.agentRegistry, this.agentMcpConfig,