Add file download tokens over WS and HTTP
This commit is contained in:
@@ -1,11 +1,14 @@
|
|||||||
import express, { type Express } from "express";
|
import express, { type Express } from "express";
|
||||||
import basicAuth from "express-basic-auth";
|
import basicAuth from "express-basic-auth";
|
||||||
import { createServer as createHTTPServer, type Server as HTTPServer } from "http";
|
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 { randomUUID } from "node:crypto";
|
||||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
|
||||||
import { VoiceAssistantWebSocketServer } from "./websocket-server.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 { initializeSTT, type STTConfig } from "./agent/stt-openai.js";
|
||||||
import { initializeTTS, type TTSConfig } from "./agent/tts-openai.js";
|
import { initializeTTS, type TTSConfig } from "./agent/tts-openai.js";
|
||||||
import { listConversations, deleteConversation } from "./persistence.js";
|
import { listConversations, deleteConversation } from "./persistence.js";
|
||||||
@@ -49,6 +52,7 @@ export type PaseoDaemonConfig = {
|
|||||||
agentRegistryPath: string;
|
agentRegistryPath: string;
|
||||||
agentControlMcp: AgentControlMcpConfig;
|
agentControlMcp: AgentControlMcpConfig;
|
||||||
openai?: PaseoOpenAIConfig;
|
openai?: PaseoOpenAIConfig;
|
||||||
|
downloadTokenTtlMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PaseoDaemonHandles = {
|
export type PaseoDaemonHandles = {
|
||||||
@@ -67,9 +71,12 @@ export async function createPaseoDaemon(
|
|||||||
const basicAuthUsers = config.auth.basicUsers;
|
const basicAuthUsers = config.auth.basicUsers;
|
||||||
const staticDir = config.staticDir;
|
const staticDir = config.staticDir;
|
||||||
const authRealm = config.auth.realm ?? "Voice Assistant";
|
const authRealm = config.auth.realm ?? "Voice Assistant";
|
||||||
|
const downloadTokenTtlMs = config.downloadTokenTtlMs ?? 60000;
|
||||||
|
|
||||||
const agentMcpBearerToken = config.auth.agentMcpBearerToken;
|
const agentMcpBearerToken = config.auth.agentMcpBearerToken;
|
||||||
|
|
||||||
|
const downloadTokenStore = new DownloadTokenStore({ ttlMs: downloadTokenTtlMs });
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
// Serve static files from public directory (no auth required for APK downloads)
|
// 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 httpServer = createHTTPServer(app);
|
||||||
|
|
||||||
const agentRegistry = new AgentRegistry(config.agentRegistryPath);
|
const agentRegistry = new AgentRegistry(config.agentRegistryPath);
|
||||||
@@ -252,6 +309,7 @@ export async function createPaseoDaemon(
|
|||||||
httpServer,
|
httpServer,
|
||||||
agentManager,
|
agentManager,
|
||||||
agentRegistry,
|
agentRegistry,
|
||||||
|
downloadTokenStore,
|
||||||
{
|
{
|
||||||
agentMcpUrl: config.agentControlMcp.url,
|
agentMcpUrl: config.agentControlMcp.url,
|
||||||
agentMcpHeaders: config.agentControlMcp.headers,
|
agentMcpHeaders: config.agentControlMcp.headers,
|
||||||
|
|||||||
@@ -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", () => {
|
describe("persistence flow", () => {
|
||||||
test(
|
test(
|
||||||
"persists and resumes Codex agent with conversation history",
|
"persists and resumes Codex agent with conversation history",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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({
|
async function resolveScopedPath({
|
||||||
root,
|
root,
|
||||||
relativePath = ".",
|
relativePath = ".",
|
||||||
|
|||||||
@@ -483,6 +483,12 @@ export const FileExplorerRequestSchema = z.object({
|
|||||||
mode: z.enum(["list", "file"]),
|
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({
|
export const ClearAgentAttentionMessageSchema = z.object({
|
||||||
type: z.literal("clear_agent_attention"),
|
type: z.literal("clear_agent_attention"),
|
||||||
agentId: z.union([z.string(), z.array(z.string())]),
|
agentId: z.union([z.string(), z.array(z.string())]),
|
||||||
@@ -511,6 +517,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
AgentPermissionResponseMessageSchema,
|
AgentPermissionResponseMessageSchema,
|
||||||
GitDiffRequestSchema,
|
GitDiffRequestSchema,
|
||||||
FileExplorerRequestSchema,
|
FileExplorerRequestSchema,
|
||||||
|
FileDownloadTokenRequestSchema,
|
||||||
ListPersistedAgentsRequestMessageSchema,
|
ListPersistedAgentsRequestMessageSchema,
|
||||||
GitRepoInfoRequestMessageSchema,
|
GitRepoInfoRequestMessageSchema,
|
||||||
ClearAgentAttentionMessageSchema,
|
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({
|
const GitBranchInfoSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
isCurrent: z.boolean(),
|
isCurrent: z.boolean(),
|
||||||
@@ -795,6 +815,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ListPersistedAgentsResponseSchema,
|
ListPersistedAgentsResponseSchema,
|
||||||
GitDiffResponseSchema,
|
GitDiffResponseSchema,
|
||||||
FileExplorerResponseSchema,
|
FileExplorerResponseSchema,
|
||||||
|
FileDownloadTokenResponseSchema,
|
||||||
GitRepoInfoResponseSchema,
|
GitRepoInfoResponseSchema,
|
||||||
ListProviderModelsResponseMessageSchema,
|
ListProviderModelsResponseMessageSchema,
|
||||||
]);
|
]);
|
||||||
@@ -851,6 +872,8 @@ export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
|
|||||||
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
|
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
|
||||||
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
||||||
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
|
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 RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>;
|
||||||
export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema>;
|
export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type SessionInboundMessage,
|
type SessionInboundMessage,
|
||||||
type SessionOutboundMessage,
|
type SessionOutboundMessage,
|
||||||
type FileExplorerRequest,
|
type FileExplorerRequest,
|
||||||
|
type FileDownloadTokenRequest,
|
||||||
type GitSetupOptions,
|
type GitSetupOptions,
|
||||||
} from "./messages.js";
|
} from "./messages.js";
|
||||||
import { getSystemPrompt } from "./agent/system-prompt.js";
|
import { getSystemPrompt } from "./agent/system-prompt.js";
|
||||||
@@ -54,7 +55,9 @@ import { expandTilde } from "./terminal-mcp/tmux.js";
|
|||||||
import {
|
import {
|
||||||
listDirectoryEntries,
|
listDirectoryEntries,
|
||||||
readExplorerFile,
|
readExplorerFile,
|
||||||
|
getDownloadableFileInfo,
|
||||||
} from "./file-explorer/service.js";
|
} from "./file-explorer/service.js";
|
||||||
|
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||||
import {
|
import {
|
||||||
generateAgentTitle,
|
generateAgentTitle,
|
||||||
isTitleGeneratorInitialized,
|
isTitleGeneratorInitialized,
|
||||||
@@ -238,12 +241,14 @@ export class Session {
|
|||||||
private agentManager: AgentManager;
|
private agentManager: AgentManager;
|
||||||
private readonly agentRegistry: AgentRegistry;
|
private readonly agentRegistry: AgentRegistry;
|
||||||
private readonly agentMcpConfig: AgentMcpClientConfig;
|
private readonly agentMcpConfig: AgentMcpClientConfig;
|
||||||
|
private readonly downloadTokenStore: DownloadTokenStore;
|
||||||
private agentTitleCache: Map<string, string | null> = new Map();
|
private agentTitleCache: Map<string, string | null> = new Map();
|
||||||
private unsubscribeAgentEvents: (() => void) | null = null;
|
private unsubscribeAgentEvents: (() => void) | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
clientId: string,
|
clientId: string,
|
||||||
onMessage: (msg: SessionOutboundMessage) => void,
|
onMessage: (msg: SessionOutboundMessage) => void,
|
||||||
|
downloadTokenStore: DownloadTokenStore,
|
||||||
agentManager: AgentManager,
|
agentManager: AgentManager,
|
||||||
agentRegistry: AgentRegistry,
|
agentRegistry: AgentRegistry,
|
||||||
agentMcpConfig: AgentMcpClientConfig,
|
agentMcpConfig: AgentMcpClientConfig,
|
||||||
@@ -255,6 +260,7 @@ export class Session {
|
|||||||
this.clientId = clientId;
|
this.clientId = clientId;
|
||||||
this.conversationId = options?.conversationId || uuidv4();
|
this.conversationId = options?.conversationId || uuidv4();
|
||||||
this.onMessage = onMessage;
|
this.onMessage = onMessage;
|
||||||
|
this.downloadTokenStore = downloadTokenStore;
|
||||||
this.agentManager = agentManager;
|
this.agentManager = agentManager;
|
||||||
this.agentRegistry = agentRegistry;
|
this.agentRegistry = agentRegistry;
|
||||||
this.agentMcpConfig = agentMcpConfig;
|
this.agentMcpConfig = agentMcpConfig;
|
||||||
@@ -842,6 +848,10 @@ export class Session {
|
|||||||
await this.handleFileExplorerRequest(msg);
|
await this.handleFileExplorerRequest(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "file_download_token_request":
|
||||||
|
await this.handleFileDownloadTokenRequest(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
case "list_persisted_agents_request":
|
case "list_persisted_agents_request":
|
||||||
await this.handleListPersistedAgentsRequest(msg);
|
await this.handleListPersistedAgentsRequest(msg);
|
||||||
break;
|
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
|
* Send current session state (live agents and commands) to client
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
// Provider Models
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -31,8 +31,10 @@ export interface DaemonTestContext {
|
|||||||
* });
|
* });
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export async function createDaemonTestContext(): Promise<DaemonTestContext> {
|
export async function createDaemonTestContext(
|
||||||
const daemon = await createTestPaseoDaemon();
|
options?: Parameters<typeof createTestPaseoDaemon>[0]
|
||||||
|
): Promise<DaemonTestContext> {
|
||||||
|
const daemon = await createTestPaseoDaemon(options);
|
||||||
const client = new DaemonClient({
|
const client = new DaemonClient({
|
||||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||||
authHeader: daemon.agentMcpAuthHeader,
|
authHeader: daemon.agentMcpAuthHeader,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js";
|
|||||||
|
|
||||||
type TestPaseoDaemonOptions = {
|
type TestPaseoDaemonOptions = {
|
||||||
basicUsers?: Record<string, string>;
|
basicUsers?: Record<string, string>;
|
||||||
|
downloadTokenTtlMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TestPaseoDaemon = {
|
export type TestPaseoDaemon = {
|
||||||
@@ -73,6 +74,7 @@ export async function createTestPaseoDaemon(
|
|||||||
? { headers: { Authorization: agentMcpAuthHeader } }
|
? { headers: { Authorization: agentMcpAuthHeader } }
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
|
downloadTokenTtlMs: options.downloadTokenTtlMs,
|
||||||
};
|
};
|
||||||
|
|
||||||
const daemon = await createPaseoDaemon(config);
|
const daemon = await createPaseoDaemon(config);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Session } from "./session.js";
|
|||||||
import { loadConversation } from "./persistence.js";
|
import { loadConversation } from "./persistence.js";
|
||||||
import { AgentManager } from "./agent/agent-manager.js";
|
import { AgentManager } from "./agent/agent-manager.js";
|
||||||
import { AgentRegistry } from "./agent/agent-registry.js";
|
import { AgentRegistry } from "./agent/agent-registry.js";
|
||||||
|
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||||
|
|
||||||
type AgentMcpClientConfig = {
|
type AgentMcpClientConfig = {
|
||||||
agentMcpUrl: string;
|
agentMcpUrl: string;
|
||||||
@@ -29,16 +30,19 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
private clientIdCounter: number = 0;
|
private clientIdCounter: number = 0;
|
||||||
private agentManager: AgentManager;
|
private agentManager: AgentManager;
|
||||||
private agentRegistry: AgentRegistry;
|
private agentRegistry: AgentRegistry;
|
||||||
|
private downloadTokenStore: DownloadTokenStore;
|
||||||
private readonly agentMcpConfig: AgentMcpClientConfig;
|
private readonly agentMcpConfig: AgentMcpClientConfig;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
server: HTTPServer,
|
server: HTTPServer,
|
||||||
agentManager: AgentManager,
|
agentManager: AgentManager,
|
||||||
agentRegistry: AgentRegistry,
|
agentRegistry: AgentRegistry,
|
||||||
|
downloadTokenStore: DownloadTokenStore,
|
||||||
agentMcpConfig: AgentMcpClientConfig
|
agentMcpConfig: AgentMcpClientConfig
|
||||||
) {
|
) {
|
||||||
this.agentManager = agentManager;
|
this.agentManager = agentManager;
|
||||||
this.agentRegistry = agentRegistry;
|
this.agentRegistry = agentRegistry;
|
||||||
|
this.downloadTokenStore = downloadTokenStore;
|
||||||
this.agentMcpConfig = agentMcpConfig;
|
this.agentMcpConfig = agentMcpConfig;
|
||||||
this.wss = new WebSocketServer({ server, path: "/ws" });
|
this.wss = new WebSocketServer({ server, path: "/ws" });
|
||||||
|
|
||||||
@@ -85,6 +89,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
(msg) => {
|
(msg) => {
|
||||||
this.sendToClient(ws, wrapSessionMessage(msg));
|
this.sendToClient(ws, wrapSessionMessage(msg));
|
||||||
},
|
},
|
||||||
|
this.downloadTokenStore,
|
||||||
this.agentManager,
|
this.agentManager,
|
||||||
this.agentRegistry,
|
this.agentRegistry,
|
||||||
this.agentMcpConfig,
|
this.agentMcpConfig,
|
||||||
|
|||||||
Reference in New Issue
Block a user