mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
2ca55c8332
* feat(bridge): additive live-mode gate + LIVE_BRIDGE_BOOTSTRAP Adds the config-gated live branch to BRIDGE_SCRIPT: frame gate, pinned parent origin, token-stamped postToParent, origin+token checks on both inbound handlers, pinpoint-only clamp, vim and resize off, pageUrl on ready, and coalesced page-change reporting for SPA history navigation. With no config present (srcdoc) every branch is inert and behavior is unchanged; the existing html-viewer suites pass unmodified as the regression proof. LIVE_BRIDGE_BOOTSTRAP installs the annotation CSS from the JSON config prelude before the IIFE runs. New package export exposes the string constants without the React barrel. * feat(ui): live-session parent side for proxied app annotation useHtmlAnnotation gains a live option (origin + token validated before parseBridgeMessage; token + concrete targetOrigin on every outbound post) and a validated page-change message with onPageChange. HtmlViewer gains src/liveSession/currentPageUrl/onPageChange: src-mode iframe with no sandbox and no srcdoc, ready pageUrl handling, per-page restore filtering with explicit clear-marks + re-sync on navigation, and one postToBridge choke point for its direct posts. Annotation.pageUrl is additive; exportAnnotations groups by page (with global numbering kept) only when a pageUrl is present, byte-identical otherwise. AnnotationPanel shows the page label; AnnotationToolstrip can hide the input switch. The editor app wires mode annotate-app: full-viewport live surface, forced pinpoint, vim off, diff/share hidden, pageUrl stamping. * feat(server): loopback reverse proxy for live app annotation Whole-origin mirror of a local dev server on a dedicated 127.0.0.1 port: streaming bridge injection (after the head open tag, before a bare </head>, or appended; exactly one per document; 8-byte holdback plus a state machine for tags split across chunks), header hygiene (upstream Host rewrite, X-Forwarded-*, identity Accept-Encoding on document intent only, hop-by-hop strip), CSP drop-and-replace with frame-ancestors listing the editor origins, X-Frame-Options removal, target-origin Location rewrite, byte-identical passthrough for assets and encoded HTML (no injection, once-per-session diagnostic), SSE streaming, and WebSocket passthrough with a bounded pending queue for HMR. Host header validation runs before any upstream contact; the bind is the literal loopback constant and the advertised-URL override is never applied. Tests boot a fake dev server and cover injection, hygiene, fidelity, WS echo, and the security posture. * feat(annotate): annotate-app server mode + CLI live probe with remote hard-off startAnnotateServer gains mode annotate-app and a liveApp option: it throws under PLANNOTATOR_REMOTE, generates the per-session token, composes the proxy-served bridge body (JSON config prelude with both editor origin forms, localhost first, plus bootstrap and bridge supplied by the caller so packages/server never imports @plannotator/ui), starts the loopback proxy after the annotate port is known, serves the live /api/plan payload (no rawHtml, no version fields, sharing off), and stops the proxy with the server. Version history and durable submission records stay excluded via the explicit mode gate. The CLI resolution probes loopback http URLs (3s, accept text/html) and defaults them to live mode when the probe returns HTML; --static forces conversion, --app forces live and fails loudly on non-loopback, https, unreachable, or non-HTML targets; both flags are mutually exclusive transport-shape flags never echoed in the tolerant handoff. A live resolution under PLANNOTATOR_REMOTE is a startup failure suggesting --static. OpenCode and Pi parsers are untouched this phase. * test(live-annotate): protocol, server, and probe suites + smoke script + docs htmlLiveProtocol.test.tsx covers the parent trust boundary (origin and token rejection before parseBridgeMessage, token + targetOrigin on every outbound post, validated page-change and ready pageUrl, per-page restore filtering with full-list numbering) and the bridge live gate, executed as the composed config + bootstrap + bridge body inside a dedicated harness iframe so the srcdoc suites keep running the same script uncontaminated in this process. annotate.test.ts gains annotate-app cases (live payload shape, composed bridge served by the proxy, no-history version endpoints, proxy stopped with the server, remote rejection); annotate-live-resolution.test.ts covers the probe matrix. The two post helpers now drop unmatched-targetOrigin posts silently, matching browser semantics where some DOM environments throw. Adds the manual Vite/Next smoke script and the AGENTS.md live app annotation section (phase gate, security posture, limitations). * test(annotate-cli): cover the CLI layer of the live app remote hard-off Spawns the real CLI entry (async, so the in-process fake app can answer the live probe) with PLANNOTATOR_REMOTE=1 against a loopback HTML server and asserts the startup-failure exit with the --static hint. Completes per-layer coverage of the three-layer hard-off (CLI exit, server throw, unconditional loopback proxy bind). * fix(live-annotate): harden the loopback trust boundary end to end - isLoopbackHostname (now canonical in live-proxy.ts, re-exported by the CLI resolution) requires localhost, ::1, or a LITERAL 127/8 IPv4 address: DNS names like 127.0.0.1.evil.example no longer classify as loopback, so neither the default probe nor --app can start a live proxy against an off-box origin. - The live-eligibility probe judges the FINAL response URL: a target that redirects off its loopback origin falls back to the static pipeline (or fails loudly under --app) instead of opening a live session whose iframe immediately leaves the proxy. - WS upgrades with a browser Origin not naming the proxy itself are refused, so a hostile page's cross-site connect is never laundered into the origin-less shape dev servers trust as a non-browser client (Vite CVE-2025-24010 class). - /__plannotator__/bridge.js refuses cross-site/same-site Sec-Fetch-Site fetches: the per-session token is no longer readable via an off-origin script include on modern browsers. - X-Frame-Options is stripped only on HTML responses (where frame-ancestors replaces it); non-HTML responses keep the app's own framing protection. - Redirect Locations are re-anchored by loopback-host + port equivalence instead of a string prefix: alternate loopback spellings are now caught and lookalike ports (5173 vs 51730) pass through untouched. - --app on a non-URL target fails loudly instead of being silently swallowed. * fix(live-annotate): session correctness for SPA restores, origins, and pathful targets - A live find-and-mark that resolves nothing keeps its record, seeded with unresolved placeholder targets from the durable anchor/text params, so the mutation-driven reconcile re-acquires the pin once a lazy route or data-dependent tree renders (SPA navigation no longer permanently drops pins). Srcdoc restores keep the fail-closed drop. - The bridge posts every outbound message once per listed editor origin; the browser delivers only the one matching the parent document, so an editor opened at 127.0.0.1 instead of localhost no longer silently loses ready and every subsequent message. - The advertised appUrl is the proxy under its localhost spelling with the target URL's own path and query: the framed app stays same-site with the editor, shares the dev app's host-only localhost cookies and storage, and a pathful target opens its page instead of the app root. The proxy still binds the 127.0.0.1 literal. * ci(live-annotate): run the live protocol DOM suite; document the hardened posture htmlLiveProtocol.test.tsx is DOM-gated and was absent from the workflow's DOM_TESTS file list, so none of its trust-boundary assertions ran in CI. Add it, and update the live-app section of the project docs: literal-loopback gate, probe redirect rule, WS Origin check, bridge.js delivery gate, localhost appUrl advertisement, live restore resilience, and the remote-mode behavior change (loopback URL annotate under PLANNOTATOR_REMOTE now exits asking for --static instead of silently converting). * fix(live-annotate): absorb the v0.27 mainline into the live session surface Post-rebase seam work after replaying the branch onto main (v0.27.4 era): - Route the bridge's unanchored-transparency report through postToParent so live sessions deliver it token-stamped to the listed editor origins; the raw '*' post main introduced for srcdoc would be dropped by the live parent's message authentication exactly where restores fail most. New live-harness test pins the contract. - Extend the live remote hard-off to --tailscale sessions (flag postdates the branch): CLI startup failure + startAnnotateServer throw keyed on tailnetPublished, matching how the annotate agent terminal treats tailnet publication. Covered in annotate.test.ts and documented in AGENTS.md. - Keep main's compact-touch input controls and effective mode/input values on the HTML surface while preserving the live pinpoint-only clamps. - Regenerate the pinned guide-viewer manifest (CSS hash moved with the new UI classes; JS unchanged). * feat(live-annotate): Interact/Annotate mode toggle for live app and raw HTML sessions A live app session used to be unusable: the pinpoint capture-phase click handler owned every click, so buttons, checkboxes, inputs, and links never fired. One boolean mode now governs the HTML/live viewer surface: - Interact: the bridge is fully passive. Pinpoint capture, hover outline, drag-selection toolbar, [data-annotate] clicks, and committed-highlight click interception are all gated behind annotateModeActive, so clicks, forms, text selection, and SPA navigation reach the page natively. Committed markers and highlights stay VISIBLE, and marker buttons keep their clicks (a marker click still opens its comment). - Annotate: classic behavior, unchanged. Live sessions annotate exclusively via pinpoint while armed. Control: a single bubble icon button in the editor header (icon never changes; armed = accent + visible border, idle = transparent border of the same width, so the box is pixel-identical in both states), plus a subtle inset accent ring floated over the viewer while armed (pointer-transparent, no layout shift). Keyboard: Mod+Shift+A through the shortcut registry (html-annotate scope; the bridge mirrors the chord inside the iframe and forwards it over the authenticated postToParent path). Esc gains a final ladder rung: draft closes first, then the hover outline clears, then Esc exits Annotate back to Interact (bridge posts annotate-exit; a parent-side listener covers Esc with editor focus). The parent owns the mode and pushes it with the same re-post-on-ready pattern as set-input-method, so it survives live page changes, HMR reloads, and bridge re-injection without ever reloading the iframe. Defaults: live app sessions START in Interact; static/raw HTML sessions START in Annotate (today's behavior preserved, and the srcdoc bridge default keeps behavior byte-identical when no set-annotate-mode ever arrives). Session-only state, no persistence. Vim navigation is available only while Annotate is armed. Covered by new bridge-harness and parent-side DOM tests in htmlLiveProtocol.test.tsx and htmlPinpointProtocol.test.tsx: Interact pass-through, armed capture, the Esc ladder order, mode survival across re-injection, marker clicks in Interact, and both defaults. * feat(live-annotate): pinpoint-armed default, always-on drag comments, comment-only HTML surfaces Simplifies the Interact/Annotate design after live review. The new contract replaces the previous one where they conflict: - BOTH surfaces (raw HTML and live app) now START ARMED with pinpoint; the live-session Interact default is gone. Esc keeps the ladder (close draft, clear hover, then exit to Interact) and the header toggle re-arms. The bridge also paints the pinpoint cursor at init instead of waiting for the parent's first round trip. - The header toggle is a PEN icon: the old bubble sat next to the annotations-panel bubble and the two were indistinguishable. Same box geometry (armed = accent + visible border, idle = transparent border of identical width), aria-pressed, Mod+Shift+A, and the armed ring over the viewer are all unchanged. - Text drag-selection commenting is ALWAYS live on HTML/live surfaces, in BOTH states: the selection pass is ungated from annotateModeActive and from the pinpoint input method. In armed pinpoint, click = pin an element and drag = select text, simultaneously; the >4px drag arming decides which one a gesture was, a completed drag's trailing click never re-pins (one-shot dragEndedClick), and a plain click is never swallowed (the pass only acts on a real selection and never preventDefaults). Esc in Interact still closes an open drag draft before yielding to the page. - HTML/live surfaces are COMMENT-ONLY: useHtmlAnnotation clamps redline/quickLabel (both the host mode and a bridge-posted modeOverride, so a hostile page cannot force a DELETION), the selection toolbar drops Delete and quick labels behind a new commentOnly seam on AnnotationToolbar, and the quick-label picker portal is gone from HtmlViewer. Markdown surfaces keep the full toolbar, and persisted DELETION annotations still restore. - The "Show tools"/"Hide tools" header button is removed. It hid the floating toolstrip (now gone from HTML surfaces entirely: with comment-only plus both input paths live there is nothing left to switch), the collapsed sidebar tab flags, and the viewer's floating action cluster (attachments + global comment + version-diff toggle), all of which are now always visible. htmlChrome persistence keeps only the sidebar/panel state; an old cookie's toolsHidden flag is read tolerantly and ignored, so a stale record cannot strand a user with hidden chrome and no way back. - HTML surfaces pin the viewer input method to pinpoint (the drag/ pinpoint switch is meaningless when both are live); the Alt input switch no-ops there. Vim stays armed-only, as built. No server, proxy, or protocol-security changes; the armed flag stays session-only. Tests: the live-bridge harness is reworked around the armed default (forged-DISARM posture, drag-selection passes in armed and Interact, the trailing-click guard), the pinpoint suite covers the comment-only toolbar and the redline/quickLabel clamp at the trust boundary, a new AnnotationToolbar.commentOnly seam test guards both surfaces' toolbars, App.htmlChrome.test.tsx replaces App.htmlHideTools.test.tsx (no tools button, stale-cookie tolerance, pen armed default), and the htmlChrome tests cover the narrowed persisted shape. * feat(live-annotate): collapsible floating controls cluster The simplification removed the Hide tools toggle, which left the floating comment/attachments cluster permanently over the page. Restore a hide affordance on the cluster itself: a collapse chevron shrinks it to a small expand pill in the same corner, so the page is never obstructed without a way back. Collapsed state persists with the rest of the HTML chrome cookie (sidebar/panel), tolerantly read. Hosts that do not wire the toggle (readOnly viewers, review-editor panels) are unchanged. * feat(live-annotate): header Show/Hide tools replaces the collapse pill The collapse pill was a half measure: it left its own artifact over the page and the sidebar tongue tabs stayed. Revert it and restore the real thing as a header control: an eye toggle immediately left of the pen that removes ALL floating chrome over the page from the DOM (sidebar tongue tabs + the comment/attachments cluster), leaving nothing behind. The toggle lives in the header, so a hidden state always has a way back, which also makes honoring a persisted (or pre-existing) toolsHidden cookie safe again.
254 lines
8.6 KiB
TypeScript
254 lines
8.6 KiB
TypeScript
/**
|
|
* End-to-end exit-code and stream contract for `plannotator annotate`
|
|
* argument handling (#1182), exercised through a real process spawn of the
|
|
* CLI entry so the strict-gate bypass and the tolerant tiers are covered as
|
|
* wired, not as helpers.
|
|
*
|
|
* The CLI entry imports the built single-file HTML from ../dist at module
|
|
* load. Every case here fails or hands off before a server would start, so
|
|
* placeholder dist files are enough; they are only created when a real build
|
|
* is absent (CI) and are removed afterwards.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from "fs";
|
|
import { tmpdir } from "os";
|
|
import { join } from "path";
|
|
|
|
const serverDir = import.meta.dir;
|
|
const cliEntry = join(serverDir, "index.ts");
|
|
const distDir = join(serverDir, "..", "dist");
|
|
const distFiles = ["index.html", "review.html"];
|
|
|
|
let fixtureDir: string;
|
|
let dataDir: string;
|
|
const createdDistFiles: string[] = [];
|
|
let createdDistDir = false;
|
|
|
|
function runAnnotate(args: string[], envOverrides: Record<string, string> = {}): {
|
|
exitCode: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
} {
|
|
const result = Bun.spawnSync(
|
|
[process.execPath, cliEntry, "annotate", ...args],
|
|
{
|
|
cwd: fixtureDir,
|
|
env: {
|
|
...process.env,
|
|
PLANNOTATOR_CWD: fixtureDir,
|
|
PLANNOTATOR_DATA_DIR: dataDir,
|
|
...envOverrides,
|
|
},
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
timeout: 30_000,
|
|
},
|
|
);
|
|
return {
|
|
exitCode: result.exitCode,
|
|
stdout: result.stdout.toString(),
|
|
stderr: result.stderr.toString(),
|
|
};
|
|
}
|
|
|
|
beforeAll(() => {
|
|
if (!existsSync(distDir)) {
|
|
mkdirSync(distDir, { recursive: true });
|
|
createdDistDir = true;
|
|
}
|
|
for (const file of distFiles) {
|
|
const path = join(distDir, file);
|
|
if (!existsSync(path)) {
|
|
writeFileSync(path, "<!-- test placeholder -->");
|
|
createdDistFiles.push(path);
|
|
}
|
|
}
|
|
|
|
fixtureDir = mkdtempSync(join(tmpdir(), "plannotator-annotate-cli-"));
|
|
dataDir = join(fixtureDir, ".plannotator-data");
|
|
mkdirSync(dataDir, { recursive: true });
|
|
mkdirSync(join(fixtureDir, "out"));
|
|
writeFileSync(join(fixtureDir, "notes.md"), "# Notes");
|
|
|
|
// Failing `tailscale` shim for the --tailscale publish-failure exit-code
|
|
// tests: shadows any real CLI on PATH so no tailnet state is ever touched.
|
|
mkdirSync(join(fixtureDir, "bin"));
|
|
writeFileSync(
|
|
join(fixtureDir, "bin", "tailscale"),
|
|
"#!/bin/sh\necho 'Log in to Tailscale first' >&2\nexit 1\n",
|
|
{ mode: 0o755 },
|
|
);
|
|
});
|
|
|
|
afterAll(() => {
|
|
rmSync(fixtureDir, { recursive: true, force: true });
|
|
for (const path of createdDistFiles) {
|
|
rmSync(path, { force: true });
|
|
}
|
|
if (createdDistDir) {
|
|
rmSync(distDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("annotate CLI single-token failures stay legacy", () => {
|
|
test("a lone typo'd path exits 1 with File not found on stderr", () => {
|
|
const result = runAnnotate(["nope.md"]);
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stderr).toContain("File not found: nope.md");
|
|
expect(result.stdout).toBe("");
|
|
});
|
|
|
|
test("same with --gate (non-strict): still exit 1, no handoff prose", () => {
|
|
const result = runAnnotate(["nope.md", "--gate"]);
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stderr).toContain("File not found: nope.md");
|
|
expect(result.stdout).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("annotate CLI strict gate bypasses tolerance", () => {
|
|
test("--require-approval with natural-language args exits 2, stdout empty", () => {
|
|
const result = runAnnotate([
|
|
"the",
|
|
"aim",
|
|
"doc",
|
|
"--gate",
|
|
"--json",
|
|
"--require-approval",
|
|
]);
|
|
expect(result.exitCode).toBe(2);
|
|
expect(result.stderr).toContain("File not found: the");
|
|
expect(result.stdout).toBe("");
|
|
});
|
|
|
|
test("--result-file with natural-language args exits 2, stdout empty", () => {
|
|
const result = runAnnotate([
|
|
"the",
|
|
"aim",
|
|
"doc",
|
|
"--gate",
|
|
"--json",
|
|
"--result-file",
|
|
join("out", "result.json"),
|
|
]);
|
|
expect(result.exitCode).toBe(2);
|
|
expect(result.stderr).toContain("File not found: the");
|
|
expect(result.stdout).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("annotate CLI --tailscale publish failure exit codes", () => {
|
|
// The tailnet publish happens in onReady, after the loopback server is up,
|
|
// through the failing shim above. Under a strict gate exit 1 is reserved
|
|
// for "the reviewer did not approve, decision record published" — a
|
|
// publish failure must present as a startup failure (exit 2, no record
|
|
// file), never as a rejection. POSIX shim, so skipped on Windows.
|
|
const testUnix = test.skipIf(process.platform === "win32");
|
|
const tailscaleEnv = () => ({
|
|
PATH: `${join(fixtureDir, "bin")}:${process.env.PATH ?? ""}`,
|
|
PLANNOTATOR_AI: "disabled",
|
|
});
|
|
|
|
testUnix("strict gate: exits 2 with no result file", () => {
|
|
const resultFile = join("out", "ts-result.json");
|
|
const result = runAnnotate(
|
|
["notes.md", "--tailscale", "--gate", "--json", "--result-file", resultFile],
|
|
tailscaleEnv(),
|
|
);
|
|
expect(result.exitCode).toBe(2);
|
|
expect(result.stderr).toContain("--tailscale");
|
|
expect(result.stdout).toBe("");
|
|
expect(existsSync(join(fixtureDir, resultFile))).toBe(false);
|
|
});
|
|
|
|
testUnix("non-strict: keeps the documented exit 1", () => {
|
|
const result = runAnnotate(["notes.md", "--tailscale"], tailscaleEnv());
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stderr).toContain("--tailscale");
|
|
expect(result.stdout).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("annotate CLI tolerant tiers", () => {
|
|
test("multiple unresolvable words hand off on stdout with exit 0", () => {
|
|
const result = runAnnotate(["the", "aim", "doc"]);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stdout).toContain("Could not resolve the arguments below");
|
|
expect(result.stdout).toContain("the aim doc");
|
|
expect(result.stdout).toContain("If you are an agent reading this");
|
|
});
|
|
|
|
test("an unrecognized flag disables tolerance and errors like base", () => {
|
|
// --no-jna is a typo'd --no-jina; skipping it would silently fetch the
|
|
// URL via Jina, exactly what the flag exists to prevent.
|
|
const result = runAnnotate(["--no-jna", "https://example.invalid/doc"]);
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stderr).toContain("File not found: --no-jna");
|
|
expect(result.stdout).toBe("");
|
|
});
|
|
|
|
test("a quoted missing path is never re-split into a resolving token", () => {
|
|
// notes.md exists; "my notes.md" (one argv token) does not. Token
|
|
// boundaries must be preserved so this hands off instead of silently
|
|
// opening notes.md.
|
|
const result = runAnnotate(["my notes.md", "runme"]);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stdout).toContain("Could not resolve the arguments below");
|
|
expect(result.stdout).toContain("my notes.md runme");
|
|
});
|
|
|
|
test("a stray word matching a directory cannot hijack the fast path", () => {
|
|
// "." is a real directory; in multi-token mode bare directory names are
|
|
// not candidates, so this hands off instead of opening folder mode.
|
|
const result = runAnnotate(["please", "annotate", "."]);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stdout).toContain("Could not resolve the arguments below");
|
|
});
|
|
});
|
|
|
|
describe("plannotator annotate: live app remote hard-off (CLI layer)", () => {
|
|
test("a live-resolving loopback URL under PLANNOTATOR_REMOTE exits as a startup failure", async () => {
|
|
// The fake app lives in THIS process, so the CLI must be spawned
|
|
// asynchronously (a sync spawn would block the event loop and deadlock
|
|
// the probe request against our own server).
|
|
const app = Bun.serve({
|
|
hostname: "127.0.0.1",
|
|
port: 0,
|
|
fetch: () =>
|
|
new Response("<html><head></head><body>app</body></html>", {
|
|
headers: { "Content-Type": "text/html" },
|
|
}),
|
|
});
|
|
try {
|
|
const child = Bun.spawn(
|
|
[process.execPath, cliEntry, "annotate", `http://127.0.0.1:${app.port}/`],
|
|
{
|
|
cwd: fixtureDir,
|
|
env: {
|
|
...process.env,
|
|
PLANNOTATOR_CWD: fixtureDir,
|
|
PLANNOTATOR_DATA_DIR: dataDir,
|
|
PLANNOTATOR_REMOTE: "1",
|
|
},
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
},
|
|
);
|
|
const exitCode = await child.exited;
|
|
const stderr = await new Response(child.stderr).text();
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr).toContain("Live app annotation is unavailable in remote mode");
|
|
expect(stderr).toContain("--static");
|
|
} finally {
|
|
app.stop(true);
|
|
}
|
|
});
|
|
});
|