From 761bff8c2c7c5d81746f7a08cfb5aa5f80260376 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Sun, 27 Sep 2026 01:54:56 -0400 Subject: [PATCH] feat(pc): add approved controlled browser draft --- docs/generated/commands.md | 4 +- docs/pc-capability-plane.md | 8 +- src/commands/command_manifest_data.ts | 8 +- src/commands/pc.ts | 84 ++++++++++++- src/core/pc/browser_inspect.ts | 164 +++++++++++++++++++++++--- src/core/pc/doctor.ts | 12 +- test/pc_browser_inspect.test.ts | 132 +++++++++++++++++++++ test/pc_capability.test.ts | 4 +- 8 files changed, 388 insertions(+), 28 deletions(-) diff --git a/docs/generated/commands.md b/docs/generated/commands.md index 33174ce..6861a2f 100644 --- a/docs/generated/commands.md +++ b/docs/generated/commands.md @@ -1,5 +1,5 @@ - + # Generated command reference This reference is generated from the validated, versioned command manifest. Availability is evaluated at runtime; a listed command may still require authentication, a hosted capability, or local tooling. @@ -233,7 +233,7 @@ Permission: `local-write` · Availability: `runtime-dependent` · Telemetry: `sh ### System -#### `aether pc [map|doctor|verify-browser|open|inspect-browser] [target]` +#### `aether pc [map|doctor|verify-browser|open|inspect-browser|draft-browser] [target]` inspect PC capabilities and diagnose app performance with scoped actions diff --git a/docs/pc-capability-plane.md b/docs/pc-capability-plane.md index 45f26a9..c43a29d 100644 --- a/docs/pc-capability-plane.md +++ b/docs/pc-capability-plane.md @@ -14,6 +14,7 @@ or hosted-service entitlement claim. | `aether pc verify-browser` | Opens a loopback readiness page | Interactive approval and a one-use callback prove that a browser rendered the page. The listener closes after the result. | | `aether pc open [aether-cloud\|claude\|chatgpt]` | Opens one fixed site | One-use interactive approval bound to target and detected browser state. `--yes` and headless sessions cannot approve. Launcher start is reported as dispatch, not as verified page rendering. | | `aether pc inspect-browser [aether-cloud\|claude\|chatgpt] [--json]` | Opens a fixed HTTPS site in a disposable Edge profile | After fresh interactive approval, observes a real top-level document, checks its origin, loader identity, and bounded main-document HTTP status class, then reports only fixed structural booleans for a successful response. HTTP 4xx/5xx return `http-error` without inspecting structure. `rendered` does not mean authenticated; `login-required` means a password field or login route was observed. The profile is closed and removed afterward. | +| `aether pc draft-browser [aether-cloud\|claude\|chatgpt] [--json]` | Inserts one locally typed line into one observed empty composer in a disposable Edge profile | Requires approval to open the page and a second approval bound to its document and element identity before insertion. It rechecks origin, tab count, document loader, element identity, positive layout box, and empty state before focus and again before insertion; then checks that the DOM contains the entered text without returning it. The site may save or send data in response to typing or focus. It never clicks a send button. Missing or changed composers refuse text insertion. | `pc doctor` reports recommendations from observed resource pressure. CPU and memory now use three timed samples and show their range. The optional fixed @@ -68,7 +69,12 @@ inspection. It watches for extra page targets during navigation and fails proof if one appears, even if that page closes before the final count. Its `browser.inspect` map entry remains **unverified** merely from driver presence; the receipt from a particular run carries that run's proof. -Browser clicking, typing, and authenticated-session claims remain unavailable. +Generic browser clicking, sending, and authenticated-session claims remain unavailable. +The v2 map lists `browser.draft` separately from generic `browser.act` and does +not infer runtime, installed, or hosted qualification from source presence. +Draft text is entered at the terminal, is not accepted in command arguments, +and is omitted from receipts and the redacted audit journal. A controlled +page can react to typing, so the second approval explicitly covers that risk. The temporary DevTools endpoint is local to this user's session, not an OS isolation boundary against another same-user process. A failed profile cleanup turns inspection into failure and is reported instead of hidden. diff --git a/src/commands/command_manifest_data.ts b/src/commands/command_manifest_data.ts index a4be6ee..aa9ca85 100644 --- a/src/commands/command_manifest_data.ts +++ b/src/commands/command_manifest_data.ts @@ -1644,9 +1644,9 @@ export const COMMAND_MANIFEST_SOURCE: readonly CommandManifestEntry[] = [ "aliases": [], "compatibilityAliases": [], "deprecatedAliases": [], - "args": "[map|doctor|verify-browser|open|inspect-browser] [target]", + "args": "[map|doctor|verify-browser|open|inspect-browser|draft-browser] [target]", "summary": "inspect PC capabilities and diagnose app performance with scoped actions", - "detailedHelp": "aether pc map [v1|v2] | doctor [aether-cloud|claude|chatgpt|ollama] [--probe-network] | verify-browser | open [aether-cloud|claude|chatgpt] | inspect-browser [aether-cloud|claude|chatgpt]\nMap defaults to the v2 axes; map v1 preserves the legacy JSON view for one transition release. Network probes require --probe-network. Browser verification opens a loopback page. Browser inspection opens a disposable Edge profile and reports only origin, document readiness and structural presence. Browser actions require fresh interactive approval; --yes cannot approve them.", + "detailedHelp": "aether pc map [v1|v2] | doctor [aether-cloud|claude|chatgpt|ollama] [--probe-network] | verify-browser | open [aether-cloud|claude|chatgpt] | inspect-browser [aether-cloud|claude|chatgpt] | draft-browser [aether-cloud|claude|chatgpt]\nMap defaults to the v2 axes; map v1 preserves the legacy JSON view for one transition release. Network probes require --probe-network. Browser verification opens a loopback page. Browser inspection opens a disposable Edge profile and reports only origin, document readiness and structural presence. Browser draft opens that profile, finds one empty composer, and requires a second approval before inserting one line of text. The site may save or send data when focused or typed into. Browser actions require fresh interactive approval; --yes cannot approve them.", "section": "System", "hidden": false, "permissionClass": "local-write", @@ -1705,13 +1705,13 @@ export const COMMAND_MANIFEST_SOURCE: readonly CommandManifestEntry[] = [ "module": "src/commands/command_manifest_data.ts", "symbol": "COMMAND_MANIFEST_SOURCE", "target": "pc", - "usage": "aether pc [map|doctor|verify-browser|open|inspect-browser] [target]", + "usage": "aether pc [map|doctor|verify-browser|open|inspect-browser|draft-browser] [target]", "visible": true, "disposition": "generated" }, "release": { "disposition": "new", - "note": "Read-only PC map and doctor, plus approved browser readiness and disposable Edge structural inspection. Desktop and unsandboxed command actions remain unavailable." + "note": "Read-only PC map and doctor, approved browser readiness and inspection, and separately approved insertion into one observed empty composer in disposable Edge. Desktop and unsandboxed command actions remain unavailable." } }, { diff --git a/src/commands/pc.ts b/src/commands/pc.ts index 85abff6..fb9ce83 100644 --- a/src/commands/pc.ts +++ b/src/commands/pc.ts @@ -5,7 +5,7 @@ import type { AppContext } from "../core/context.js"; import type { CommandFlags } from "../core/command_dispatch.js"; import { detectBrowserRuntime, verifyBrowserLaunch, type VerifyResult } from "../core/browser_runtime.js"; import { openTargetChecked } from "../core/opener.js"; -import { PcActionBroker } from "../core/pc/broker.js"; +import { PcActionBroker, type PcActionReceipt } from "../core/pc/broker.js"; import { controlledEdgeExecutable, inspectControlledPage, type BrowserInspection } from "../core/pc/browser_inspect.js"; import { PcFileAudit, PcHostGateway } from "../core/pc/gateway.js"; import { PC_TARGETS, isPcTarget, pcDoctor, pcMap, pcMapV2, pcTargetUrl, type PcDoctorReport } from "../core/pc/doctor.js"; @@ -28,17 +28,34 @@ function renderDoctor(report: PcDoctorReport): string { return lines.join("\n") + "\n"; } -async function explicitApproval(message: string): Promise { +async function explicitApproval(message: string, signal?: AbortSignal): Promise { if (!process.stdin.isTTY) return false; const rl = createInterface({ input: process.stdin, output: process.stderr }); try { - const answer = await new Promise((resolve) => rl.question(`${message}\nApprove this one action? [y/N] `, resolve)); + const answer = await new Promise((resolve) => { + const done = (value: string) => { signal?.removeEventListener("abort", abort); resolve(value); }; + const abort = () => { rl.close(); done(""); }; + if (signal?.aborted) { done(""); return; } + signal?.addEventListener("abort", abort, { once: true }); + rl.question(`${message}\nApprove this one action? [y/N] `, done); + }); return /^y(es)?$/i.test(answer.trim()); } finally { rl.close(); } } +async function draftLine(): Promise { + if (!process.stdin.isTTY) return null; + const rl = createInterface({ input: process.stdin, output: process.stderr }); + try { + const value = await new Promise((resolve) => rl.question("Draft text (one line, at most 2000 characters): ", resolve)); + return value.length > 0 && value.length <= 2000 && !/[\r\n\0]/.test(value) ? value : null; + } finally { + rl.close(); + } +} + export async function cmdPc(ctx: AppContext, argv: string[], flags: CommandFlags): Promise { const sub = argv[0] ?? "map"; if (sub === "map" && (argv.length === 1 || (argv.length === 2 && (argv[1] === "v1" || argv[1] === "v2")))) { @@ -168,6 +185,65 @@ export async function cmdPc(ctx: AppContext, argv: string[], flags: CommandFlags `Browser inspection: ${proof?.state ?? receipt.status}\n${proof?.reason ?? receipt.reason}\n`); return receipt.status === "succeeded" ? 0 : 3; } - process.stderr.write("usage: aether pc map [v1|v2] | doctor [aether-cloud|claude|chatgpt|ollama] [--probe-network] | verify-browser | open [aether-cloud|claude|chatgpt] | inspect-browser [aether-cloud|claude|chatgpt]\n"); + if (sub === "draft-browser" && argv.length === 2) { + const target = argv[1]!; + if (!isPcTarget(target) || target === "ollama") { + process.stderr.write("PC browser draft supports aether-cloud, claude and chatgpt.\n"); + return 2; + } + if (ctx.flags.yes || !process.stdin.isTTY) { + process.stderr.write("PC browser draft requires fresh interactive approval; --yes and headless execution do not grant it.\n"); + return 3; + } + const executable = controlledEdgeExecutable(); + if (!executable) { + process.stderr.write("Controlled Edge browser unavailable on this Windows installation.\n"); + return 3; + } + const text = await draftLine(); + if (text === null) { + process.stderr.write("Draft text must be one nonempty line of at most 2000 characters.\n"); + return 2; + } + const url = pcTargetUrl(target); + let draftAbort: AbortController | null = null; + const broker = new PcActionBroker(randomUUID(), userInfo().username, { + interactive: true, + approve: (plan) => plan.adapter === "browser.inspect" + ? explicitApproval(`Open and inspect ${new URL(url).origin} in a disposable Edge profile?`) + : explicitApproval(`Insert ${text.length} characters into the observed empty ${plan.operation} at ${new URL(url).origin}? Element ${plan.target.split(":").at(-1)}. The site may save or send data when focused or typed into.`, draftAbort?.signal), + }); + const gateway = new PcHostGateway(broker, new PcFileAudit()); + const plan = broker.plan({ adapter: "browser.inspect", operation: "inspect", target, expectedState: executable }); + const observation: { inspection?: BrowserInspection; draftReceipt?: PcActionReceipt } = {}; + const openReceipt = await gateway.execute(plan, () => controlledEdgeExecutable() ?? "unavailable", async () => { + draftAbort = new AbortController(); + const onInterrupt = () => draftAbort?.abort(); + process.once("SIGINT", onInterrupt); + try { + observation.inspection = await inspectControlledPage(url, { signal: draftAbort.signal }, async (composer) => { + const action = broker.plan({ + adapter: "browser.draft", operation: composer.kind, + target: `${target}:${composer.identity}`, expectedState: composer.identity, + }, 60_000); + observation.draftReceipt = await gateway.execute(action, () => composer.observe(), () => composer.insert(text)); + }); + } finally { + process.removeListener("SIGINT", onInterrupt); + } + return { + dispatched: observation.inspection.browserLaunched, + verified: observation.inspection.profileCleaned && (observation.inspection.state === "rendered" || observation.inspection.state === "login-required"), + }; + }); + const finalReceipt = observation.draftReceipt?.status === "succeeded" && openReceipt.status !== "succeeded" + ? { ...observation.draftReceipt, status: "unknown" as const, reason: "draft insertion succeeded but browser inspection or cleanup failed; verify before retry" } + : observation.draftReceipt; + process.stdout.write(ctx.flags.json ? JSON.stringify({ openReceipt, draftReceipt: finalReceipt ?? null, proof: observation.inspection ?? null }) + "\n" + : `Browser draft: ${finalReceipt?.status ?? "unavailable"}\n${finalReceipt?.reason ?? + (observation.inspection?.state === "rendered" ? "No single empty editable composer was observed." : observation.inspection?.reason) ?? openReceipt.reason}\n`); + return openReceipt.status === "succeeded" && finalReceipt?.status === "succeeded" ? 0 : 3; + } + process.stderr.write("usage: aether pc map [v1|v2] | doctor [aether-cloud|claude|chatgpt|ollama] [--probe-network] | verify-browser | open [aether-cloud|claude|chatgpt] | inspect-browser [aether-cloud|claude|chatgpt] | draft-browser [aether-cloud|claude|chatgpt]\n"); return 2; } diff --git a/src/core/pc/browser_inspect.ts b/src/core/pc/browser_inspect.ts index b1601c1..70a7a48 100644 --- a/src/core/pc/browser_inspect.ts +++ b/src/core/pc/browser_inspect.ts @@ -1,7 +1,7 @@ -// Read-only inspection of a real page in an Aether-owned, disposable Edge -// profile. The only page code evaluated is the fixed document.readyState probe; -// no page text, DOM dump, cookies, storage, screenshots, or user profile data -// enters the result. The CLI supplies fixed HTTPS targets after local approval. +// Inspection of a real page in an Aether-owned, disposable Edge profile is +// read-only by default. The optional draft port requires a separate host grant +// before it inserts text. No page text, DOM dump, cookies, storage, screenshots, +// or user profile data enters the result. The CLI supplies fixed HTTPS targets. import { spawn, type ChildProcess } from "node:child_process"; import { createHash } from "node:crypto"; @@ -42,6 +42,18 @@ export interface BrowserInspectOptions { /** Headless is for qualification tests; the CLI opens a visible browser. */ headless?: boolean; timeoutMs?: number; + signal?: AbortSignal; +} + +/** A single observed composer in the controlled page. No page text is exposed. */ +export interface BrowserDraftPort { + readonly identity: string; + readonly kind: "textarea" | "contenteditable"; + readonly origin: string; + /** Returns "stale" if the document, tab, element, or empty state changed. */ + observe(): Promise; + /** Call only inside the approved gateway effect. */ + insert(text: string): Promise<{ dispatched: boolean; verified: boolean }>; } interface DevtoolsPort { port: number; browserPath: string } @@ -95,8 +107,9 @@ async function pause(): Promise { await new Promise((resolve) => setTimeout(resolve, POLL_MS)); } -async function readDevtoolsPort(dir: string, child: ChildProcess, deadline: number): Promise { +async function readDevtoolsPort(dir: string, child: ChildProcess, deadline: number, signal?: AbortSignal): Promise { while (Date.now() < deadline) { + if (signal?.aborted) throw new Error("cancelled"); if (child.exitCode !== null) throw new Error("controlled browser exited before inspection"); try { const lines = (await readFile(join(dir, "DevToolsActivePort"), "utf8")).trim().split(/\r?\n/); @@ -110,8 +123,9 @@ async function readDevtoolsPort(dir: string, child: ChildProcess, deadline: numb throw new Error("controlled browser did not start in time"); } -async function pageWebSocket(port: number, deadline: number): Promise { +async function pageWebSocket(port: number, deadline: number, signal?: AbortSignal): Promise { while (Date.now() < deadline) { + if (signal?.aborted) throw new Error("cancelled"); try { const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(remaining(deadline, 1_000)) }); if (!response.ok) throw new Error("DevTools target list unavailable"); @@ -141,7 +155,7 @@ async function pageCount(port: number, deadline: number): Promise { class CdpConnection { private sequence = 0; private eventHandler: ((method: string, params: unknown) => void) | null = null; - private readonly pending = new Map void; reject: (error: Error) => void; timer: NodeJS.Timeout }>(); + private readonly pending = new Map void; reject: (error: Error) => void; timer: NodeJS.Timeout }>(); private constructor(private readonly socket: WebSocket) { socket.addEventListener("message", (event) => { @@ -157,7 +171,7 @@ class CdpConnection { if (!waiting) return; clearTimeout(waiting.timer); this.pending.delete(message.id!); - if (message.error) waiting.reject(new Error("browser protocol refused a read-only command")); + if (message.error) waiting.reject(new Error(`browser protocol refused ${waiting.method}`)); else waiting.resolve(message.result); }); socket.addEventListener("close", () => { @@ -189,7 +203,7 @@ class CdpConnection { this.pending.delete(id); reject(new Error("browser inspection timed out")); }, remaining(deadline, maxMs)); - this.pending.set(id, { resolve, reject, timer }); + this.pending.set(id, { method, resolve, reject, timer }); this.socket.send(JSON.stringify({ id, method, params })); }); } @@ -231,6 +245,102 @@ async function structure(cdp: CdpConnection, deadline: number): Promise boolean, isCancelled: () => boolean, +): Promise { + const response = await cdp.command("DOM.getDocument", { depth: 0, pierce: false }, deadline) as { root?: { nodeId?: unknown } }; + const root = response?.root?.nodeId; + if (typeof root !== "number" || root < 1) return null; + const matches = await cdp.command("DOM.querySelectorAll", { + nodeId: root, selector: 'textarea, [contenteditable="true"]', + }, deadline) as { nodeIds?: unknown }; + if (!Array.isArray(matches?.nodeIds) || matches.nodeIds.length !== 1 || + typeof matches.nodeIds[0] !== "number") return null; + const nodeId = matches.nodeIds[0] as number; + const described = await cdp.command("DOM.describeNode", { nodeId, depth: 0 }, deadline) as { + node?: { backendNodeId?: unknown; nodeName?: unknown; attributes?: unknown }; + }; + const backendId = described?.node?.backendNodeId; + const name = described?.node?.nodeName; + const attributes = described?.node?.attributes; + if (typeof backendId !== "number" || !Number.isSafeInteger(backendId) || backendId < 1 || + typeof name !== "string" || !Array.isArray(attributes)) return null; + const kind = name.toUpperCase() === "TEXTAREA" ? "textarea" : + attributes.some((value, index) => index % 2 === 0 && value === "contenteditable" && + attributes[index + 1] === "true") ? "contenteditable" : null; + if (!kind) return null; + const identity = createHash("sha256") + .update(`${frame.id}:${frame.loaderId}:${frame.url}:${backendId}:${kind}`) + .digest("hex").slice(0, 24); + const world = await cdp.command("Page.createIsolatedWorld", { + frameId: frame.id, worldName: "aether-pc-draft", + }, deadline) as { executionContextId?: unknown }; + const contextId = world?.executionContextId; + if (typeof contextId !== "number" || !Number.isSafeInteger(contextId) || contextId < 1) return null; + + const elementMatches = async (expectedText?: string): Promise => { + const current = await frameIdentity(cdp, deadline); + if (isCancelled() || current.id !== frame.id || current.loaderId !== frame.loaderId || current.url !== frame.url || + new URL(current.url).origin !== origin || hasUnexpectedPage() || + await pageCount(port.port, deadline) !== 1) return false; + const fresh = await cdp.command("DOM.getDocument", { depth: 0, pierce: false }, deadline) as { root?: { nodeId?: unknown } }; + const freshRoot = fresh?.root?.nodeId; + if (typeof freshRoot !== "number" || freshRoot < 1) return false; + const found = await cdp.command("DOM.querySelectorAll", { + nodeId: freshRoot, selector: 'textarea, [contenteditable="true"]', + }, deadline) as { nodeIds?: unknown }; + if (!Array.isArray(found?.nodeIds) || found.nodeIds.length !== 1 || typeof found.nodeIds[0] !== "number") return false; + const freshId = found.nodeIds[0] as number; + const detail = await cdp.command("DOM.describeNode", { nodeId: freshId, depth: 0 }, deadline) as { + node?: { backendNodeId?: unknown }; + }; + if (detail?.node?.backendNodeId !== backendId) return false; + try { + const box = await cdp.command("DOM.getBoxModel", { nodeId: freshId }, deadline) as { + model?: { width?: unknown; height?: unknown }; + }; + if (typeof box?.model?.width !== "number" || box.model.width <= 0 || + typeof box.model.height !== "number" || box.model.height <= 0) return false; + } + catch { return false; } + const resolved = await cdp.command("DOM.resolveNode", { + nodeId: freshId, executionContextId: contextId, + }, deadline) as { object?: { objectId?: unknown } }; + const objectId = resolved?.object?.objectId; + if (typeof objectId !== "string") return false; + // The fixed probe runs in an isolated world and returns a boolean only. + // Existing composer content never reaches the host or the audit journal. + const expression = kind === "textarea" + ? "function(expected) { return this.isConnected && !this.disabled && !this.readOnly && this.value === expected; }" + : "function(expected) { return this.isConnected && this.isContentEditable && this.textContent === expected; }"; + const probe = await cdp.command("Runtime.callFunctionOn", { + objectId, functionDeclaration: expression, arguments: [{ value: expectedText ?? "" }], + returnByValue: true, silent: true, + }, deadline) as { result?: { value?: unknown } }; + if (probe?.result?.value !== true) return false; + const confirmed = await frameIdentity(cdp, deadline); + return confirmed.id === frame.id && confirmed.loaderId === frame.loaderId && + confirmed.url === frame.url && !hasUnexpectedPage(); + }; + + if (!await elementMatches()) return null; + return { + identity, kind, origin, + observe: async () => await elementMatches() ? identity : "stale", + insert: async (text) => { + if (text.length < 1 || text.length > 2000 || /[\r\n\0]/.test(text)) { + return { dispatched: false, verified: false }; + } + if (!await elementMatches()) return { dispatched: false, verified: false }; + await cdp.command("DOM.focus", { backendNodeId: backendId }, deadline); + if (!await elementMatches()) return { dispatched: true, verified: false }; + await cdp.command("Input.insertText", { text }, deadline); + return { dispatched: true, verified: await elementMatches(text) }; + }, + }; +} + function newResult(origin: string): BrowserInspection { return { schema: SCHEMA, state: "failed", browserLaunched: false, navigationAttempted: false, @@ -267,12 +377,16 @@ async function closeBrowser(child: ChildProcess | null, profileDir: string, port } /** Inspect a selected page without taking browser actions or reading content. */ -export async function inspectControlledPage(rawUrl: string, options: BrowserInspectOptions = {}): Promise { +export async function inspectControlledPage( + rawUrl: string, options: BrowserInspectOptions = {}, + onDraftReady?: (port: BrowserDraftPort) => Promise, +): Promise { const requested = inspectedUrl(rawUrl, options.allowHttpLoopback ?? false); const result = newResult(requested.origin); + if (options.signal?.aborted) return { ...result, reason: "controlled browser inspection cancelled", profileCleaned: true }; const executable = controlledEdgeExecutable(); if (!executable) return { ...result, reason: "controlled Edge driver unavailable", profileCleaned: true }; - const deadline = Date.now() + boundedTimeout(options.timeoutMs); + let deadline = Date.now() + boundedTimeout(options.timeoutMs); const profileDir = await mkdtemp(join(tmpdir(), "aether-pc-browser-")); const args = [ `--user-data-dir=${profileDir}`, "--remote-debugging-address=127.0.0.1", "--remote-debugging-port=0", @@ -287,13 +401,20 @@ export async function inspectControlledPage(rawUrl: string, options: BrowserInsp let unexpectedPage = false; let monitorArmed = false; let phase = "launch"; + const onAbort = () => { + cdp?.close(); + browserControl?.close(); + if (child?.exitCode === null) child.kill(); + }; + options.signal?.addEventListener("abort", onAbort, { once: true }); try { + if (options.signal?.aborted) throw new Error("cancelled"); child = spawn(executable, args, { shell: false, windowsHide: options.headless ?? false, stdio: "ignore", env: childEnv() }); result.browserLaunched = child.pid !== undefined; // A policy or filesystem race can make spawn fail after the executable check. // Observe the error so it never becomes an unhandled process exception. child.on("error", () => {}); - port = await readDevtoolsPort(profileDir, child, deadline); + port = await readDevtoolsPort(profileDir, child, deadline, options.signal); phase = "download denial"; browserControl = await CdpConnection.connect(`ws://127.0.0.1:${port.port}${port.browserPath}`, deadline); // A site can initiate a download while being visited. Refuse it before @@ -311,7 +432,7 @@ export async function inspectControlledPage(rawUrl: string, options: BrowserInsp throw new Error("controlled browser did not start with exactly one page"); } phase = "target discovery"; - const ws = await pageWebSocket(port.port, deadline); + const ws = await pageWebSocket(port.port, deadline, options.signal); phase = "protocol connection"; cdp = await CdpConnection.connect(ws, deadline); phase = "Page.enable"; @@ -337,6 +458,7 @@ export async function inspectControlledPage(rawUrl: string, options: BrowserInsp phase = "document readiness"; let frame: FrameIdentity | null = null; while (Date.now() < deadline) { + if (options.signal?.aborted) throw new Error("cancelled"); try { frame = await frameIdentity(cdp, deadline); if (frame.url !== "about:blank" && await readyState(cdp, deadline) === "complete") break; @@ -380,12 +502,26 @@ export async function inspectControlledPage(rawUrl: string, options: BrowserInsp result.structure = flags; result.reason = result.state === "login-required" ? "login form or route observed; authentication not verified" : "page rendered; authentication not verified"; + if (onDraftReady && result.state === "rendered") { + phase = "draft composer observation"; + // The first deadline covers browser startup/navigation. Give the + // separate human approval its own bounded window after observation. + deadline = Date.now() + 60_000; + const composer = await draftPort(cdp, port, after, final.origin, deadline, + () => unexpectedPage, () => options.signal?.aborted ?? false); + if (composer) await onDraftReady(composer); + } + if (options.signal?.aborted) throw new Error("cancelled"); return result; } catch (error) { - result.reason = error instanceof Error && /timed out/i.test(error.message) + result.state = "failed"; + result.structure = null; + result.documentDigest = null; + result.reason = options.signal?.aborted ? "controlled browser inspection cancelled" : error instanceof Error && /timed out/i.test(error.message) ? `controlled browser inspection timed out during ${phase}` : `controlled browser inspection failed during ${phase}`; return result; } finally { + options.signal?.removeEventListener("abort", onAbort); cdp?.close(); browserControl?.close(); result.profileCleaned = await closeBrowser(child, profileDir, port); diff --git a/src/core/pc/doctor.ts b/src/core/pc/doctor.ts index 34a5a14..0c70e48 100644 --- a/src/core/pc/doctor.ts +++ b/src/core/pc/doctor.ts @@ -147,7 +147,7 @@ export function pcMapV2( platform, observedAt: legacy.observedAt, session: { active: false, interactive }, - capabilities: legacy.capabilities.map((row) => { + capabilities: [...legacy.capabilities.map((row): PcCapabilityV2 => { const platformSupport = (row.id === "process.inspect" || row.id === "browser.inspect") && platform !== "win32" ? "unsupported" : implemented.has(row.id) ? "supported" : "unqualified"; const runtimeReadiness = row.state === "available" ? "ready" @@ -169,7 +169,15 @@ export function pcMapV2( }, detail: row.detail, }; - }), + }), { + id: "browser.draft", + platformSupport: platform === "win32" ? "supported" : "unsupported", + runtimeReadiness: controlledEdgeExecutable(process.env, platform) ? "unverified" : "unavailable", + permission: "fresh-local-approval", + lastProof: { observedAt: null, ageMs: null, scope: null }, + qualification: { source: "implemented", installed: "unverified", hosted: "unverified" }, + detail: "single empty composer text insertion in controlled Edge after separate page and element approval; site behavior may send data", + } satisfies PcCapabilityV2], }; } diff --git a/test/pc_browser_inspect.test.ts b/test/pc_browser_inspect.test.ts index 48cf182..7435d22 100644 --- a/test/pc_browser_inspect.test.ts +++ b/test/pc_browser_inspect.test.ts @@ -3,6 +3,8 @@ import assert from "node:assert/strict"; import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import { controlledEdgeExecutable, inspectControlledPage } from "../src/core/pc/browser_inspect.js"; +import { PcActionBroker } from "../src/core/pc/broker.js"; +import { PcHostGateway, type PcAuditEntry } from "../src/core/pc/gateway.js"; test("controlled Edge inspects a real loopback page without returning page text", { skip: !controlledEdgeExecutable() ? "Edge Stable is not installed on this runner" : false, @@ -41,6 +43,33 @@ test("controlled Edge refuses non-HTTPS remote URLs before launching", async () await assert.rejects(() => inspectControlledPage("https://user:pass@example.com/"), /clean HTTPS target/); }); +test("a pre-cancelled inspection launches no browser", async () => { + const controller = new AbortController(); + controller.abort(); + const result = await inspectControlledPage("https://app.aethersystems.net/", { signal: controller.signal }); + assert.equal(result.state, "failed"); + assert.equal(result.browserLaunched, false); + assert.equal(result.navigationAttempted, false); + assert.equal(result.profileCleaned, true); + assert.match(result.reason, /cancelled/); +}); + +test("cancellation closes an owned Edge profile before a draft action", { + skip: !controlledEdgeExecutable() ? "Edge Stable is not installed on this runner" : false, +}, async () => { + const controller = new AbortController(); + let draftReady = false; + const pending = inspectControlledPage("http://127.0.0.1:9/", { + allowHttpLoopback: true, headless: true, timeoutMs: 45_000, signal: controller.signal, + }, async () => { draftReady = true; }); + setTimeout(() => controller.abort(), 300); + const result = await pending; + assert.equal(result.state, "failed"); + assert.match(result.reason, /cancelled/); + assert.equal(result.profileCleaned, true); + assert.equal(draftReady, false); +}); + test("controlled Edge treats main-document HTTP 4xx and 5xx as errors without inspecting content", { skip: !controlledEdgeExecutable() ? "Edge Stable is not installed on this runner" : false, }, async () => { @@ -101,3 +130,106 @@ test("controlled Edge stops on a cross-origin redirect without inspecting its st await new Promise((resolve) => destination.close(() => resolve())); } }); + +test("controlled Edge inserts only into one observed empty composer and returns no draft text", { + skip: !controlledEdgeExecutable() ? "Edge Stable is not installed on this runner" : false, +}, async () => { + const secret = "DRAFT-SECRET-MUST-NOT-LEAK"; + const server = createServer((_req, response) => { + response.writeHead(200, { "content-type": "text/html" }); + response.end("

Draft

"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const port = (server.address() as AddressInfo).port; + const entries: PcAuditEntry[] = []; + const broker = new PcActionBroker("session", "user", { interactive: true, approve: async () => true }); + const gateway = new PcHostGateway(broker, { append: (entry) => { entries.push(entry); } }); + let action: { dispatched: boolean; verified: boolean } | null = null; + const result = await inspectControlledPage(`http://127.0.0.1:${port}/`, { + allowHttpLoopback: true, headless: true, timeoutMs: 45_000, + }, async (composer) => { + assert.equal(composer.kind, "textarea"); + assert.equal(await composer.observe(), composer.identity); + const plan = broker.plan({ adapter: "browser.draft", operation: composer.kind, + target: composer.identity, expectedState: composer.identity }); + const receipt = await gateway.execute(plan, () => composer.observe(), async () => { + action = await composer.insert(secret); + return action; + }); + assert.equal(receipt.status, "succeeded"); + }); + assert.deepEqual(action, { dispatched: true, verified: true }, JSON.stringify(result)); + assert.equal(result.state, "rendered", result.reason); + assert.equal(result.profileCleaned, true); + assert.deepEqual(entries.map((entry) => entry.phase), ["intent", "outcome"]); + assert.equal(entries[1]?.verified, true); + assert.doesNotMatch(JSON.stringify(entries), new RegExp(secret)); + assert.doesNotMatch(JSON.stringify(result), new RegExp(secret)); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +test("controlled Edge refuses a replaced composer before text insertion", { + skip: !controlledEdgeExecutable() ? "Edge Stable is not installed on this runner" : false, +}, async () => { + const server = createServer((_req, response) => { + response.writeHead(200, { "content-type": "text/html" }); + response.end('
'); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const port = (server.address() as AddressInfo).port; + let action: { dispatched: boolean; verified: boolean } | null = null; + const result = await inspectControlledPage(`http://127.0.0.1:${port}/`, { + allowHttpLoopback: true, headless: true, timeoutMs: 45_000, + }, async (composer) => { + await new Promise((resolve) => setTimeout(resolve, 350)); + assert.equal(await composer.observe(), "stale"); + action = await composer.insert("MUST-NOT-INSERT"); + }); + assert.deepEqual(action, { dispatched: false, verified: false }); + assert.equal(result.profileCleaned, true); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +test("declined draft grant has zero input effect and no draft text in the audit", { + skip: !controlledEdgeExecutable() ? "Edge Stable is not installed on this runner" : false, +}, async () => { + let inputEffects = 0; + const server = createServer((request, response) => { + if (request.url === "/effect") { + inputEffects++; + response.writeHead(204).end(); + return; + } + response.writeHead(200, { "content-type": "text/html" }); + response.end('
'); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const port = (server.address() as AddressInfo).port; + const entries: PcAuditEntry[] = []; + const broker = new PcActionBroker("session", "user", { interactive: true, approve: async () => false }); + const gateway = new PcHostGateway(broker, { append: (entry) => { entries.push(entry); } }); + let status: string | null = null; + const result = await inspectControlledPage(`http://127.0.0.1:${port}/`, { + allowHttpLoopback: true, headless: true, timeoutMs: 45_000, + }, async (composer) => { + const plan = broker.plan({ adapter: "browser.draft", operation: composer.kind, + target: composer.identity, expectedState: composer.identity }); + const receipt = await gateway.execute(plan, () => composer.observe(), () => composer.insert("SECRET-DRAFT")); + status = receipt.status; + }); + assert.equal(status, "denied"); + assert.equal(inputEffects, 0); + assert.equal(entries.length, 0); + assert.equal(result.profileCleaned, true); + assert.doesNotMatch(JSON.stringify(result), /SECRET-DRAFT/); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +}); diff --git a/test/pc_capability.test.ts b/test/pc_capability.test.ts index e8bfa28..b6394c1 100644 --- a/test/pc_capability.test.ts +++ b/test/pc_capability.test.ts @@ -85,6 +85,8 @@ test("PC map v2 separates readiness, permission, proof, and release qualificatio assert.equal(inspection.qualification.installed, "unverified"); assert.equal(inspection.qualification.hosted, "unverified"); assert.equal(pcMapV2("linux").capabilities.find((row) => row.id === "browser.inspect")?.platformSupport, "unsupported"); + assert.equal(pcMapV2("linux").capabilities.find((row) => row.id === "browser.draft")?.platformSupport, "unsupported"); + assert.equal(pcMapV2("win32").capabilities.find((row) => row.id === "browser.draft")?.permission, "fresh-local-approval"); const command = map.capabilities.find((row) => row.id === "command.execute")!; assert.equal(command.permission, "denied"); assert.equal(command.runtimeReadiness, "not-applicable"); @@ -172,7 +174,7 @@ test("target set and command manifest stay closed and visible", () => { test("CLI cannot turn --yes into PC browser approval", () => { const entry = fileURLToPath(new URL("../src/main.js", import.meta.url)); - for (const args of [["verify-browser"], ["open", "claude"], ["inspect-browser", "aether-cloud"]]) { + for (const args of [["verify-browser"], ["open", "claude"], ["inspect-browser", "aether-cloud"], ["draft-browser", "aether-cloud"]]) { const result = spawnSync(process.execPath, [entry, "pc", ...args, "--yes", "--json"], { encoding: "utf8", timeout: 10_000, windowsHide: true, });