Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Beyond chat, Argos supports agentic workflows: rich tool calling via MCP (Model

- **Multiple Cloud LLM Providers**: DeepSeek, OpenAI, Moonshot/Kimi, Grok, Gemini, Anthropic, and more — plus any OpenAI-, Gemini-, or Anthropic-compatible API.
- **Local Model Deployment**: Integrated Ollama with download, deploy, and run controls — no command line needed.
- **Rich Chat Experience**: Markdown + [CodeMirror](https://codemirror.net/) rendering, multi-window/multi-tab parallelism, Artifacts, message retry and conversation forking, multi-modal (images, Mermaid, text-to-image), inline source highlighting.
- **Rich Chat Experience**: Markdown + `@tanstack/highlight` code rendering, multi-window/multi-tab parallelism, Artifacts, message retry and conversation forking, multi-modal (images, Mermaid, text-to-image), inline source highlighting.
- **Search Extensions**: Built-in BoSearch and Brave Search MCP integrations, plus any custom search engine via a search-assistant model.
- **MCP Support**: Full Resources/Prompts/Tools coverage, semantic workflows, inMemory services, StreamableHTTP/SSE/Stdio transports, visual debugging, and bundled Bun interpreter.
- **Skills**: Install from folders, ZIPs, or URLs; enable per conversation; import/export with Claude Code, Codex, Cursor, Windsurf, GitHub Copilot, Kiro, Antigravity, OpenCode, Goose, Kilo Code, and more. Built-ins cover code review, document collaboration, Office/PDF, frontend design, MCP development, and others.
Expand Down
7 changes: 4 additions & 3 deletions apps/daemon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@earendil-works/pi-coding-agent": "0.83.0",
"@agentclientprotocol/sdk": "^1.3.0",
"@argos/acp-runtime": "workspace:*",
"@argos/agent-runtime": "workspace:*",
Expand All @@ -26,12 +25,14 @@
"@argos/shared-contracts": "workspace:*",
"@argos/skills-runtime": "workspace:*",
"@duckdb/node-api": "1.5.5-r.3",
"@earendil-works/pi-coding-agent": "0.83.0",
"ai": "catalog:",
"sharp": "^0.35.3",
"typebox": "1.3.9",
"chokidar": "^5.0.0",
"fflate": "catalog:",
"gray-matter": "^4.0.3",
"nanoid": "catalog:",
"sharp": "^0.35.3",
"typebox": "1.3.9",
"zod": "catalog:"
},
"devDependencies": {
Expand Down
140 changes: 140 additions & 0 deletions apps/daemon/src/dispatch/daemonDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,22 @@ import {
modelsSetBatchStatusRoute,
toolsListDefinitionsRoute,
workspaceBrowseDirectoryRoute,
workspaceRegisterRoute,
workspaceUnregisterRoute,
workspaceWatchRoute,
workspaceUnwatchRoute,
workspaceReadDirectoryRoute,
workspaceExpandDirectoryRoute,
workspaceReadFilePreviewRoute,
workspaceReadFileTextRoute,
workspaceWriteFileRoute,
workspaceCreateEntryRoute,
workspaceDeletePathRoute,
workspaceRenameOrMovePathRoute,
workspaceResolveMarkdownLinkedFileRoute,
workspaceGetGitStatusRoute,
workspaceGetGitDiffRoute,
workspaceSearchFilesRoute,
fileIsDirectoryRoute,
filePrepareDirectoryRoute,
fileReadFileRoute,
Expand Down Expand Up @@ -791,6 +807,26 @@ export function createDaemonDispatcher(
orchestrationRuntime?: {
definitions(): unknown[];
},
workspacePresenter?: {
registerWorkspace(workspacePath: string): Promise<void>;
registerWorkdir(workdir: string): Promise<void>;
unregisterWorkspace(workspacePath: string): Promise<void>;
unregisterWorkdir(workdir: string): Promise<void>;
watchWorkspace(workspacePath: string): Promise<void>;
unwatchWorkspace(workspacePath: string): Promise<void>;
readDirectory(dirPath: string): Promise<unknown[]>;
expandDirectory(dirPath: string): Promise<unknown[]>;
readFilePreview(filePath: string): Promise<unknown>;
readFileText(filePath: string): Promise<{ content: string | null; exists: boolean }>;
writeFile(filePath: string, content: string): Promise<void>;
createEntry(parentDir: string, name: string, isDirectory: boolean): Promise<string>;
deletePath(targetPath: string): Promise<void>;
renameOrMovePath(fromPath: string, toPath: string): Promise<string>;
resolveMarkdownLinkedFile(input: unknown): Promise<unknown>;
getGitStatus(workspacePath: string): Promise<unknown>;
getGitDiff(workspacePath: string, filePath?: string): Promise<unknown>;
searchFiles(workspacePath: string, query: string): Promise<unknown[]>;
},
): RouteDispatcher {
const settingsHandler = new SettingsRouteHandler(createSettingsRouteAdapter(configPresenter));
const runtime: {
Expand Down Expand Up @@ -1484,6 +1520,110 @@ export function createDaemonDispatcher(
});
}

if (workspacePresenter && route === workspaceRegisterRoute.name) {
const input = workspaceRegisterRoute.input.parse(rawInput);
if (input.mode === "workdir") await workspacePresenter.registerWorkdir(input.workspacePath);
else await workspacePresenter.registerWorkspace(input.workspacePath);
return workspaceRegisterRoute.output.parse({ registered: true });
}

if (workspacePresenter && route === workspaceUnregisterRoute.name) {
const input = workspaceUnregisterRoute.input.parse(rawInput);
if (input.mode === "workdir") await workspacePresenter.unregisterWorkdir(input.workspacePath);
else await workspacePresenter.unregisterWorkspace(input.workspacePath);
return workspaceUnregisterRoute.output.parse({ unregistered: true });
}

if (workspacePresenter && route === workspaceWatchRoute.name) {
const input = workspaceWatchRoute.input.parse(rawInput);
await workspacePresenter.watchWorkspace(input.workspacePath);
return workspaceWatchRoute.output.parse({ watching: true });
}

if (workspacePresenter && route === workspaceUnwatchRoute.name) {
const input = workspaceUnwatchRoute.input.parse(rawInput);
await workspacePresenter.unwatchWorkspace(input.workspacePath);
return workspaceUnwatchRoute.output.parse({ watching: false });
}

if (workspacePresenter && route === workspaceReadDirectoryRoute.name) {
const input = workspaceReadDirectoryRoute.input.parse(rawInput);
return workspaceReadDirectoryRoute.output.parse({ nodes: await workspacePresenter.readDirectory(input.path) });
}

if (workspacePresenter && route === workspaceExpandDirectoryRoute.name) {
const input = workspaceExpandDirectoryRoute.input.parse(rawInput);
return workspaceExpandDirectoryRoute.output.parse({
nodes: await workspacePresenter.expandDirectory(input.path),
});
}

if (workspacePresenter && route === workspaceReadFilePreviewRoute.name) {
const input = workspaceReadFilePreviewRoute.input.parse(rawInput);
return workspaceReadFilePreviewRoute.output.parse({
preview: await workspacePresenter.readFilePreview(input.path),
});
}

if (workspacePresenter && route === workspaceReadFileTextRoute.name) {
const input = workspaceReadFileTextRoute.input.parse(rawInput);
return workspaceReadFileTextRoute.output.parse(await workspacePresenter.readFileText(input.path));
}

if (workspacePresenter && route === workspaceWriteFileRoute.name) {
const input = workspaceWriteFileRoute.input.parse(rawInput);
await workspacePresenter.writeFile(input.path, input.content);
return workspaceWriteFileRoute.output.parse({ written: true });
}

if (workspacePresenter && route === workspaceCreateEntryRoute.name) {
const input = workspaceCreateEntryRoute.input.parse(rawInput);
return workspaceCreateEntryRoute.output.parse({
path: await workspacePresenter.createEntry(input.parentDir, input.name, input.isDirectory),
});
}

if (workspacePresenter && route === workspaceDeletePathRoute.name) {
const input = workspaceDeletePathRoute.input.parse(rawInput);
await workspacePresenter.deletePath(input.path);
return workspaceDeletePathRoute.output.parse({ deleted: true });
}

if (workspacePresenter && route === workspaceRenameOrMovePathRoute.name) {
const input = workspaceRenameOrMovePathRoute.input.parse(rawInput);
return workspaceRenameOrMovePathRoute.output.parse({
path: await workspacePresenter.renameOrMovePath(input.fromPath, input.toPath),
});
}

if (workspacePresenter && route === workspaceResolveMarkdownLinkedFileRoute.name) {
const input = workspaceResolveMarkdownLinkedFileRoute.input.parse(rawInput);
return workspaceResolveMarkdownLinkedFileRoute.output.parse({
resolution: await workspacePresenter.resolveMarkdownLinkedFile(input),
});
}

if (workspacePresenter && route === workspaceGetGitStatusRoute.name) {
const input = workspaceGetGitStatusRoute.input.parse(rawInput);
return workspaceGetGitStatusRoute.output.parse({
state: await workspacePresenter.getGitStatus(input.workspacePath),
});
}

if (workspacePresenter && route === workspaceGetGitDiffRoute.name) {
const input = workspaceGetGitDiffRoute.input.parse(rawInput);
return workspaceGetGitDiffRoute.output.parse({
diff: await workspacePresenter.getGitDiff(input.workspacePath, input.filePath),
});
}

if (workspacePresenter && route === workspaceSearchFilesRoute.name) {
const input = workspaceSearchFilesRoute.input.parse(rawInput);
return workspaceSearchFilesRoute.output.parse({
nodes: await workspacePresenter.searchFiles(input.workspacePath, input.query),
});
}

if (route === projectListRecentRoute.name) {
const input = projectListRecentRoute.input.parse(rawInput);
// Derive recent projects from sessions' project_dir (most-recent-first).
Expand Down
33 changes: 33 additions & 0 deletions apps/daemon/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { DaemonArgosAgentRuntime } from "./host/daemonArgosAgentRuntime";
import { BunEventPublisher } from "./host/bun-event-publisher";
import { initializeDatabase } from "./host/db-init";
import { createDaemonDispatcher } from "./dispatch/daemonDispatcher";
import { DaemonWorkspacePresenter } from "./workspace/daemonWorkspacePresenter";
import { ProviderImportService } from "@argos/backend-core";
import { PiProviderExecutionPort } from "./host/pi-provider-execution";
import { PiAgentProfileManager } from "./host/piAgentProfileManager";
Expand Down Expand Up @@ -150,6 +151,14 @@ function withCors(response: Response): Response {
});
}

function inferPreviewContentType(filePath: string): string {
const ext = filePath.slice(filePath.lastIndexOf(".") + 1).toLowerCase();
if (ext === "html" || ext === "htm") return "text/html; charset=utf-8";
if (ext === "svg") return "image/svg+xml";
if (ext === "pdf") return "application/pdf";
return "application/octet-stream";
}

function serveStaticWeb(webRoot: string, pathname: string): Response {
const safePath = pathname
.split("/")
Expand Down Expand Up @@ -724,6 +733,8 @@ export async function startDaemon(options?: {
},
});

const workspacePresenter = new DaemonWorkspacePresenter(eventPublisher, "http://127.0.0.1:0");

const dispatcher =
options?.dispatcher ??
createDaemonDispatcher(
Expand All @@ -743,6 +754,7 @@ export async function startDaemon(options?: {
db,
environmentId,
orchestrationRuntime,
workspacePresenter,
);
setRouteDispatcher(dispatcher);

Expand Down Expand Up @@ -813,6 +825,26 @@ export async function startDaemon(options?: {
return withCors(await handleRouteDispatch(request));
}

// Workspace file preview (html/pdf/svg) served as raw bytes. The path must
// resolve inside a registered/allow-listed workspace; otherwise 404.
if (url.pathname === "/api/v1/workspace/preview" && request.method === "GET") {
const targetPath = url.searchParams.get("path");
if (!targetPath || !workspacePresenter.isPathAllowed(targetPath)) {
return new Response("Not found", { status: 404 });
}
try {
const file = Bun.file(targetPath);
if (!(await file.exists())) return new Response("Not found", { status: 404 });
return withCors(
new Response(file, {
headers: { "Content-Type": inferPreviewContentType(targetPath), "Cache-Control": "no-store" },
}),
);
} catch {
return new Response("Not found", { status: 404 });
}
}
Comment on lines +828 to +846

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

The preview endpoint serves workspace HTML and SVG as active content on the daemon origin.

inferPreviewContentType returns text/html; charset=utf-8 for .html and image/svg+xml for .svg. The response comes from the same origin that serves the API and, in web mode, the web UI (Line 790). Any HTML or SVG file inside a registered workspace therefore runs script with access to that origin. A repository under review is untrusted input.

Add X-Content-Type-Options: nosniff and a sandboxing CSP to the preview response.

🔒 Proposed fix
           return withCors(
             new Response(file, {
-              headers: { "Content-Type": inferPreviewContentType(targetPath), "Cache-Control": "no-store" },
+              headers: {
+                "Content-Type": inferPreviewContentType(targetPath),
+                "Cache-Control": "no-store",
+                "X-Content-Type-Options": "nosniff",
+                "Content-Security-Policy": "sandbox; default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'",
+              },
             }),
           );

Also applies to: 154-160

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/daemon/src/index.ts` around lines 828 - 846, Update the preview response
created in the workspace preview handler to include X-Content-Type-Options:
nosniff and a restrictive sandbox Content-Security-Policy header, while
preserving the existing inferred content type and no-store cache behavior. Apply
the same security headers to every preview response path that serves workspace
HTML, PDF, or SVG content.


if (url.pathname === "/api/v1/sessions" && request.method === "GET") {
return withCors(await handleListSessions(sessionAuthRepo));
}
Expand Down Expand Up @@ -909,6 +941,7 @@ export async function startDaemon(options?: {
});

const serverPort = (server as any).port ?? port;
workspacePresenter.setBaseUrl(`http://${host}:${serverPort}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The preview base URL is wrong for wildcard and IPv6 hosts.

host can be 0.0.0.0, ::, or ::1 (see Lines 962-966 and the bracketing at Line 946). setBaseUrl interpolates host directly, so it produces http://0.0.0.0:9527 or http://:::9527. Neither is a URL a browser can load, so previewUrl breaks for HTML, PDF, and SVG previews whenever the daemon binds a wildcard or IPv6 address.

Reuse the same normalization already applied to pluginPresenter.setSettingsBaseUrl.

🐛 Proposed fix
   const serverPort = (server as any).port ?? port;
-  workspacePresenter.setBaseUrl(`http://${host}:${serverPort}`);
+  const previewHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host.includes(":") ? `[${host}]` : host;
+  workspacePresenter.setBaseUrl(`http://${previewHost}:${serverPort}`);
   if (!isNonLoopbackHost(host)) {
     const originHost = host === "::1" ? "[::1]" : host;
     pluginPresenter.setSettingsBaseUrl(`http://${originHost}:${serverPort}`);
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
workspacePresenter.setBaseUrl(`http://${host}:${serverPort}`);
const serverPort = (server as any).port ?? port;
const previewHost =
host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host.includes(":") ? `[${host}]` : host;
workspacePresenter.setBaseUrl(`http://${previewHost}:${serverPort}`);
if (!isNonLoopbackHost(host)) {
const originHost = host === "::1" ? "[::1]" : host;
pluginPresenter.setSettingsBaseUrl(`http://${originHost}:${serverPort}`);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/daemon/src/index.ts` at line 944, Update the
workspacePresenter.setBaseUrl call to reuse the host normalization applied by
pluginPresenter.setSettingsBaseUrl, ensuring wildcard and IPv6 hosts resolve to
browser-loadable URLs with correct IPv6 brackets and an appropriate preview
host.

if (!isNonLoopbackHost(host)) {
const originHost = host === "::1" ? "[::1]" : host;
pluginPresenter.setSettingsBaseUrl(`http://${originHost}:${serverPort}`);
Expand Down
Loading
Loading