From 92fadd67001b953d7008c9611bd23eafdcd7d5a1 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Sat, 21 Feb 2026 13:45:11 -0800 Subject: [PATCH] fix: filter non-parent events in main NDJSON handler and fix workspace creation path Bug 1: Subagent events from gateway broadcasts were processed as parent events because the sessionKey filter was accidentally removed during the subagent decoupling refactor. Re-add the filter in wireChildProcess. Bug 2: Creating workspaces at custom paths failed because: - mkdir API rejected absolute paths outside workspace root - Directory picker started at workspace root, not home - Error responses from mkdir were silently swallowed Add absolute path support to mkdir, handle errors in picker UI, start picker at home dir, and normalize init route paths. --- apps/web/app/api/workspace/browse/route.ts | 5 +++ apps/web/app/api/workspace/init/route.ts | 1 + apps/web/app/api/workspace/mkdir/route.ts | 32 +++++++++++++++---- .../workspace/create-workspace-dialog.tsx | 1 + .../workspace/directory-picker-modal.tsx | 9 ++++-- apps/web/lib/active-runs.ts | 6 ++++ 6 files changed, 45 insertions(+), 9 deletions(-) diff --git a/apps/web/app/api/workspace/browse/route.ts b/apps/web/app/api/workspace/browse/route.ts index 8fa024977c6..bba02cbddd7 100644 --- a/apps/web/app/api/workspace/browse/route.ts +++ b/apps/web/app/api/workspace/browse/route.ts @@ -1,5 +1,6 @@ import { readdirSync, statSync, type Dirent } from "node:fs"; import { join, dirname, resolve } from "node:path"; +import { homedir } from "node:os"; import { resolveWorkspaceRoot } from "@/lib/workspace"; export const dynamic = "force-dynamic"; @@ -116,6 +117,10 @@ export async function GET(req: Request) { ); } + if (dir.startsWith("~")) { + dir = join(homedir(), dir.slice(1)); + } + const resolved = resolve(dir); const entries = buildBrowseTree(resolved, 3, 0, showHidden); diff --git a/apps/web/app/api/workspace/init/route.ts b/apps/web/app/api/workspace/init/route.ts index 6fd4165c7c5..eef98d4c744 100644 --- a/apps/web/app/api/workspace/init/route.ts +++ b/apps/web/app/api/workspace/init/route.ts @@ -248,6 +248,7 @@ export async function POST(req: Request) { if (workspaceDir.startsWith("~")) { workspaceDir = join(homedir(), workspaceDir.slice(1)); } + workspaceDir = resolve(workspaceDir); } else { const stateDir = resolveOpenClawStateDir(); if (profileName && profileName !== "default") { diff --git a/apps/web/app/api/workspace/mkdir/route.ts b/apps/web/app/api/workspace/mkdir/route.ts index 14b513956e2..fcbe54acb73 100644 --- a/apps/web/app/api/workspace/mkdir/route.ts +++ b/apps/web/app/api/workspace/mkdir/route.ts @@ -1,4 +1,5 @@ import { mkdirSync, existsSync } from "node:fs"; +import { resolve, normalize } from "node:path"; import { safeResolveNewPath } from "@/lib/workspace"; export const dynamic = "force-dynamic"; @@ -6,27 +7,44 @@ export const runtime = "nodejs"; /** * POST /api/workspace/mkdir - * Body: { path: string } + * Body: { path: string; absolute?: boolean } * - * Creates a new directory in the workspace. + * Creates a new directory. By default paths are resolved relative to the + * workspace root. When `absolute` is true the path is treated as a + * filesystem-absolute path (used by the directory picker for workspace + * creation outside the current workspace). */ export async function POST(req: Request) { - let body: { path?: string }; + let body: { path?: string; absolute?: boolean }; try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON body" }, { status: 400 }); } - const { path: relPath } = body; - if (!relPath || typeof relPath !== "string") { + const { path: rawPath, absolute: useAbsolute } = body; + if (!rawPath || typeof rawPath !== "string") { return Response.json( { error: "Missing 'path' field" }, { status: 400 }, ); } - const absPath = safeResolveNewPath(relPath); + let absPath: string | null; + + if (useAbsolute) { + const normalized = normalize(rawPath); + if (normalized.includes("/../") || normalized.includes("/..")) { + return Response.json( + { error: "Path traversal rejected" }, + { status: 400 }, + ); + } + absPath = resolve(normalized); + } else { + absPath = safeResolveNewPath(rawPath); + } + if (!absPath) { return Response.json( { error: "Invalid path or path traversal rejected" }, @@ -43,7 +61,7 @@ export async function POST(req: Request) { try { mkdirSync(absPath, { recursive: true }); - return Response.json({ ok: true, path: relPath }); + return Response.json({ ok: true, path: absPath }); } catch (err) { return Response.json( { error: err instanceof Error ? err.message : "mkdir failed" }, diff --git a/apps/web/app/components/workspace/create-workspace-dialog.tsx b/apps/web/app/components/workspace/create-workspace-dialog.tsx index 4f6ba07f8d4..8272f25be6b 100644 --- a/apps/web/app/components/workspace/create-workspace-dialog.tsx +++ b/apps/web/app/components/workspace/create-workspace-dialog.tsx @@ -369,6 +369,7 @@ export function CreateWorkspaceDialog({ isOpen, onClose, onCreated }: CreateWork open={showDirPicker} onClose={() => setShowDirPicker(false)} onSelect={(path) => setCustomPath(path)} + startDir="~" /> ); diff --git a/apps/web/app/components/workspace/directory-picker-modal.tsx b/apps/web/app/components/workspace/directory-picker-modal.tsx index ed56bad6323..f0c882c4f41 100644 --- a/apps/web/app/components/workspace/directory-picker-modal.tsx +++ b/apps/web/app/components/workspace/directory-picker-modal.tsx @@ -151,11 +151,16 @@ export function DirectoryPickerModal({ if (!newFolderName.trim() || !displayDir) {return;} const folderPath = `${displayDir}/${newFolderName.trim()}`; try { - await fetch("/api/workspace/mkdir", { + const res = await fetch("/api/workspace/mkdir", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ path: folderPath }), + body: JSON.stringify({ path: folderPath, absolute: true }), }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError((data as { error?: string }).error || "Failed to create folder"); + return; + } setCreatingFolder(false); setNewFolderName(""); void fetchDir(currentDir); diff --git a/apps/web/lib/active-runs.ts b/apps/web/lib/active-runs.ts index 12b104d0be1..5e8ef67468a 100644 --- a/apps/web/lib/active-runs.ts +++ b/apps/web/lib/active-runs.ts @@ -803,6 +803,12 @@ function wireChildProcess(run: ActiveRun): void { return; } + // Skip events from other sessions (e.g. subagent broadcasts that + // the gateway delivers on the same WS connection). + if (ev.sessionKey && ev.sessionKey !== parentSessionKey) { + return; + } + // Track the global event cursor from the gateway for replay on handoff. const gSeq = typeof (ev as Record).globalSeq === "number" ? (ev as Record).globalSeq as number