mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
9c77c2f812
* fix(server): always show remote URL + close share-html symlink gap
Two pre-release fixes found by the QA sweep.
1. Remote URL stranding: handleServerReady only opened a browser (silent on
a headless box) and wrote a ready-file; the user-visible URL came solely
from writeRemoteShareLink, gated on sharingEnabled. So a remote/SSH user
with sharing disabled (PLANNOTATOR_SHARE=disabled or config.json
{share:"disabled"}, widened by #921) saw no URL and the agent hung on
waitForDecision() forever. Now handleServerReady prints the reachable
localhost URL whenever the session is remote, independent of sharing; the
share link stays an extra. Pi/OpenCode already printed the URL
unconditionally and are unaffected.
2. share-html symlink gap: the #927 symlink-containment fix hardened the
/api/html-assets asset sinks but missed packages/server/annotate.ts's
/api/share-html containment, which stayed lexical. A symlinked *.html
inside the doc directory pointing outside it leaked the target's contents
into the share payload. Now realpath-resolves both root and target like
the other sinks. Bun-only — Pi's copy was already hardened.
Adds regression tests for both (handleServerReady remote stderr; share-html
returns 403 on symlink escape).
* refactor(server): single shared isWithinDirectory for all asset/share sinks
The symlink-containment check existed as four byte-identical copies (Bun
html-assets route, share inliner, annotate /api/share-html, Pi server). That
duplication is exactly why the escape was missed in one sink before — #927
hardened three and missed the fourth, and the #929 fix added a fifth-in-waiting.
Export the single canonical isWithinDirectory from the shared (Pi-vendored)
html-assets-node module and have every sink import it; delete the three
duplicate bodies and their now-dead realpathSync/relative/isAbsolute imports.
A new sink can no longer silently diverge. Regression tests + 690 server/shared
tests pass; build:pi clean.
* fix(pi): surface the session URL in the in-turn 'opened' notice for remote
Remote Pi users never saw a review/annotate/last URL: it was emitted from
openBrowserForServer AFTER the command's turn ended (fire-and-forget), and
Pi only renders a notify during an active turn — so it silently dropped.
The 'X opened. You can keep chatting' notice fires in-turn and DOES show,
so fold the URL into it for remote sessions (single-line, matching the
notify convention). Local sessions are unchanged (browser auto-opens).
102 lines
3.1 KiB
TypeScript
102 lines
3.1 KiB
TypeScript
import { dirname, resolve as resolvePath } from "path";
|
|
import {
|
|
HTML_ASSET_ROUTE_PREFIX,
|
|
encodeHtmlAssetPath,
|
|
htmlAssetContentType,
|
|
normalizeHtmlAssetRoutePath,
|
|
rewriteHtmlAssetReferences,
|
|
} from "@plannotator/shared/html-assets";
|
|
import {
|
|
inlineHtmlLocalAssets,
|
|
isWithinDirectory,
|
|
MAX_HTML_ASSET_BYTES,
|
|
} from "@plannotator/shared/html-assets-node";
|
|
|
|
export { inlineHtmlLocalAssets };
|
|
|
|
export function createHtmlAssetRegistry() {
|
|
const rootsByToken = new Map<string, string>();
|
|
const tokensByRoot = new Map<string, string>();
|
|
|
|
function register(baseDir: string): string {
|
|
const root = resolvePath(baseDir);
|
|
const existing = tokensByRoot.get(root);
|
|
if (existing) return existing;
|
|
const token = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
|
|
tokensByRoot.set(root, token);
|
|
rootsByToken.set(token, root);
|
|
return token;
|
|
}
|
|
|
|
function rewriteHtml(html: string, htmlFilePath: string): string {
|
|
if (/^https?:\/\//i.test(htmlFilePath)) return html;
|
|
try {
|
|
const token = register(dirname(resolvePath(htmlFilePath)));
|
|
return rewriteHtmlAssetReferences(
|
|
html,
|
|
(assetPath) => `${HTML_ASSET_ROUTE_PREFIX}/${token}/${encodeHtmlAssetPath(assetPath)}`,
|
|
);
|
|
} catch {
|
|
return html;
|
|
}
|
|
}
|
|
|
|
function inlineHtml(html: string, htmlFilePath: string): string {
|
|
return inlineHtmlLocalAssets(html, htmlFilePath);
|
|
}
|
|
|
|
async function handle(_req: Request, url: URL): Promise<Response | null> {
|
|
const prefix = `${HTML_ASSET_ROUTE_PREFIX}/`;
|
|
if (!url.pathname.startsWith(prefix)) return null;
|
|
|
|
const rest = url.pathname.slice(prefix.length);
|
|
const slash = rest.indexOf("/");
|
|
if (slash <= 0) {
|
|
return Response.json({ error: "Missing asset token or path" }, { status: 404 });
|
|
}
|
|
|
|
const token = rest.slice(0, slash);
|
|
const root = rootsByToken.get(token);
|
|
if (!root) {
|
|
return Response.json({ error: "Unknown asset root" }, { status: 404 });
|
|
}
|
|
|
|
const assetPath = normalizeHtmlAssetRoutePath(rest.slice(slash + 1));
|
|
if (!assetPath) {
|
|
return Response.json({ error: "Invalid asset path" }, { status: 400 });
|
|
}
|
|
|
|
const contentType = htmlAssetContentType(assetPath);
|
|
if (!contentType) {
|
|
return Response.json({ error: "Unsupported asset type" }, { status: 415 });
|
|
}
|
|
|
|
const resolved = resolvePath(root, assetPath);
|
|
if (!isWithinDirectory(resolved, root)) {
|
|
return Response.json({ error: "Access denied" }, { status: 403 });
|
|
}
|
|
|
|
try {
|
|
const file = Bun.file(resolved);
|
|
if (!(await file.exists())) {
|
|
return Response.json({ error: "Asset not found" }, { status: 404 });
|
|
}
|
|
if (file.size > MAX_HTML_ASSET_BYTES) {
|
|
return Response.json({ error: "Asset too large" }, { status: 413 });
|
|
}
|
|
return new Response(file, {
|
|
headers: {
|
|
"Content-Type": contentType,
|
|
"Cache-Control": "no-store",
|
|
"Access-Control-Allow-Origin": "*",
|
|
},
|
|
});
|
|
} catch {
|
|
return Response.json({ error: "Failed to read asset" }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
return { rewriteHtml, inlineHtml, handle };
|
|
}
|
|
|