diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js
index 419a0f2a5..9291b5790 100644
--- a/packages/coding-agent/src/core/export-html/template.js
+++ b/packages/coding-agent/src/core/export-html/template.js
@@ -1734,6 +1734,10 @@
codespan(token) {
return `${escapeHtml(token.text)}`;
},
+ // Raw HTML blocks: escape to prevent XSS
+ html(token) {
+ return escapeHtml(token.text);
+ },
},
});
diff --git a/src/resources/extensions/sf/self-feedback-drain.js b/src/resources/extensions/sf/self-feedback-drain.js
index 8c3f9dcc0..77fc69151 100644
--- a/src/resources/extensions/sf/self-feedback-drain.js
+++ b/src/resources/extensions/sf/self-feedback-drain.js
@@ -125,6 +125,7 @@ function sameIds(a, b) {
return a.length === b.length && a.every((id, idx) => id === b[idx]);
}
function claimStillFresh(claim, ids) {
+ if (!Array.isArray(claim?.ids)) return false;
if (!sameIds(claim.ids, ids)) return false;
const age = Date.now() - new Date(claim.dispatchedAt).getTime();
return Number.isFinite(age) && age >= 0 && age < CLAIM_TTL_MS;
@@ -337,6 +338,15 @@ export function dispatchSelfFeedbackInlineFixIfNeeded(basePath, ctx, pi) {
child.on("error", (err) => {
writeFailedClaim(basePath, ids, getErrorMessage(err));
});
+ child.on("exit", (code) => {
+ if (code !== 0) {
+ writeFailedClaim(
+ basePath,
+ ids,
+ `Child exited with code ${code ?? "null"}`,
+ );
+ }
+ });
child.unref();
} catch (err) {
writeFailedClaim(basePath, ids, getErrorMessage(err));
diff --git a/web/app/api/browse-directories/route.ts b/web/app/api/browse-directories/route.ts
index d513109bb..36c2b4437 100644
--- a/web/app/api/browse-directories/route.ts
+++ b/web/app/api/browse-directories/route.ts
@@ -88,10 +88,30 @@ export async function GET(request: Request): Promise {
// Also allow navigation to common mount points (/media, /mnt, /run/media) on Linux
const devRootParent = dirname(devRoot);
const additionalRoots = getAdditionalRoots();
- const isAllowedPath =
- targetPath.startsWith(devRoot) ||
- targetPath === devRootParent ||
- additionalRoots.some((root) => targetPath.startsWith(root));
+
+ // Use realpath + relative to prevent prefix-based path traversal
+ // (e.g. /home/user/projects-backup matching /home/user/projects)
+ const isAllowedPath = (() => {
+ try {
+ const realTarget = realpathSync(targetPath);
+ const realDevRoot = realpathSync(devRoot);
+ const relToDevRoot = relative(realDevRoot, realTarget);
+ const inDevRoot =
+ relToDevRoot === "" ||
+ (!relToDevRoot.startsWith("..") && !isAbsolute(relToDevRoot));
+ if (inDevRoot) return true;
+ const realDevRootParent = realpathSync(devRootParent);
+ if (realTarget === realDevRootParent) return true;
+ return additionalRoots.some((root) => {
+ if (!existsSync(root)) return false;
+ const realRoot = realpathSync(root);
+ const rel = relative(realRoot, realTarget);
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
+ });
+ } catch {
+ return false;
+ }
+ })();
if (!isAllowedPath) {
return Response.json(
@@ -117,8 +137,19 @@ export async function GET(request: Request): Promise {
const parentPath = dirname(targetPath);
// Only offer the parent navigation if it's within the allowed scope
- const parentAllowed =
- parentPath.startsWith(devRootParent) && parentPath !== targetPath;
+ const parentAllowed = (() => {
+ try {
+ const realParent = realpathSync(parentPath);
+ const realDevRootParent = realpathSync(devRootParent);
+ const rel = relative(realDevRootParent, realParent);
+ return (
+ (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) &&
+ parentPath !== targetPath
+ );
+ } catch {
+ return false;
+ }
+ })();
const entries: Array<{ name: string; path: string }> = [];
// On Linux, show mount points as quick-access when browsing from home directory
diff --git a/web/app/api/terminal/sessions/route.ts b/web/app/api/terminal/sessions/route.ts
index 02601550f..eea1f108a 100644
--- a/web/app/api/terminal/sessions/route.ts
+++ b/web/app/api/terminal/sessions/route.ts
@@ -46,7 +46,15 @@ export async function POST(request: Request): Promise {
);
}
- getOrCreateSession(id, projectCwd, command);
+ try {
+ getOrCreateSession(id, projectCwd, command);
+ } catch (error) {
+ console.error("[pty-sessions] Failed to create session:", error);
+ return Response.json(
+ { error: "Failed to create PTY session", detail: String(error) },
+ { status: 500 },
+ );
+ }
return Response.json({ id });
}
diff --git a/web/app/api/terminal/stream/route.ts b/web/app/api/terminal/stream/route.ts
index 192625665..94bb6f84d 100644
--- a/web/app/api/terminal/stream/route.ts
+++ b/web/app/api/terminal/stream/route.ts
@@ -9,6 +9,7 @@
import { requireProjectCwd } from "../../../../../src/web/bridge-service.ts";
import {
addListener,
+ destroySession,
getOrCreateSession,
isAllowedTerminalCommand,
} from "../../../../lib/pty-manager";
@@ -89,6 +90,7 @@ export async function GET(request: Request): Promise {
closed = true;
removeListener?.();
removeListener = null;
+ destroySession(sessionId);
},
});
diff --git a/web/middleware.ts b/web/middleware.ts
index e567b3513..ff0b626b9 100644
--- a/web/middleware.ts
+++ b/web/middleware.ts
@@ -17,6 +17,9 @@ export function middleware(request: NextRequest): NextResponse {
// Only gate API routes
if (!pathname.startsWith("/api/")) return NextResponse.next();
+ // Skip auth for health/readiness endpoints
+ if (pathname === "/api/shutdown" || pathname === "/api/update") return NextResponse.next();
+
const expectedToken = process.env.SF_WEB_AUTH_TOKEN;
if (!expectedToken) {
// If no token was configured (e.g. dev mode without launch harness),