Add file download tokens over WS and HTTP

This commit is contained in:
Mohamed Boudra
2025-12-29 12:12:13 +07:00
parent 15b62448cb
commit 57a86d011a
10 changed files with 447 additions and 2 deletions

View File

@@ -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,

View File

@@ -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",

View File

@@ -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<string, DownloadTokenEntry>();
constructor(options: DownloadTokenStoreOptions) {
this.ttlMs = options.ttlMs;
this.now = options.now ?? (() => Date.now());
}
issueToken(input: Omit<DownloadTokenEntry, "token" | "expiresAt">): 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);
}
}
}
}

View File

@@ -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 = ".",

View File

@@ -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<typeof GitDiffRequestSchema>;
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>;
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>;
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>;
export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema>;

View File

@@ -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<string, string | null> = 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<void> {
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
*/

View File

@@ -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
// ============================================================================

View File

@@ -31,8 +31,10 @@ export interface DaemonTestContext {
* });
* ```
*/
export async function createDaemonTestContext(): Promise<DaemonTestContext> {
const daemon = await createTestPaseoDaemon();
export async function createDaemonTestContext(
options?: Parameters<typeof createTestPaseoDaemon>[0]
): Promise<DaemonTestContext> {
const daemon = await createTestPaseoDaemon(options);
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
authHeader: daemon.agentMcpAuthHeader,

View File

@@ -7,6 +7,7 @@ import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js";
type TestPaseoDaemonOptions = {
basicUsers?: Record<string, string>;
downloadTokenTtlMs?: number;
};
export type TestPaseoDaemon = {
@@ -73,6 +74,7 @@ export async function createTestPaseoDaemon(
? { headers: { Authorization: agentMcpAuthHeader } }
: {}),
},
downloadTokenTtlMs: options.downloadTokenTtlMs,
};
const daemon = await createPaseoDaemon(config);

View File

@@ -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,