diff --git a/apps/cli/package.json b/apps/cli/package.json index 994710b0..214d2cb2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -13,6 +13,7 @@ "scripts": { "build": "esbuild src/index.ts --bundle --platform=node --format=cjs --external:esbuild --external:@babel/core --external:@babel/preset-typescript --external:babel-preset-solid --external:bufferutil --external:utf-8-validate --outfile=dist/index.js && chmod +x dist/index.js", "check": "tsc --noEmit", + "test:browser-companion": "node --test --experimental-strip-types tests/browser-companion-protocol.test.ts", "symlink:remove": "rm -f /opt/homebrew/bin/dapi", "symlink:create": "npm run build && ln -sf \"$PWD/dist/index.js\" /opt/homebrew/bin/dapi" }, diff --git a/apps/cli/src/browser-companion-protocol.ts b/apps/cli/src/browser-companion-protocol.ts new file mode 100644 index 00000000..cb98c736 --- /dev/null +++ b/apps/cli/src/browser-companion-protocol.ts @@ -0,0 +1,81 @@ +export type BrowserCompanionCapabilities = { + readOnly: true; + browserDapi: false; + cloudAi: false; + persistentEdits: false; + htmlPaint: false; + media: "unsupported-phase-a"; + webgpu: "browser-dependent"; + fonts: "browser-dependent"; + trustedProjectCode: true; +}; + +export type BrowserCompanionCommand = + | { kind: "browser-companion"; action: "prepare"; projectDir: string } + | { kind: "browser-companion"; action: "start"; projectDir: string; projectId?: string } + | { kind: "browser-companion"; action: "status" } + | { kind: "browser-companion"; action: "logs" } + | { kind: "browser-companion"; action: "stop" }; + +export type BrowserCompanionLog = { + seq: number; + ts: number; + level: "debug" | "info" | "warning" | "error"; + event: string; + data?: Record; +}; + +export type BrowserCompanionRevisionIdentity = { + sessionId: string; + revision: number; + bundleHash: string; +}; + +export type BrowserCompanionStatus = { + active: boolean; + sessionId?: string; + origin?: string; + appVersion?: string; + buildHash?: string; + protocol?: number; + project?: { id: string; name: string; displayName: string }; + rendererConnected?: boolean; + hostWindowVisible?: boolean; + hostWindowMode?: "hidden" | "minimized-fallback"; + hostLocalOnly?: boolean; + revision?: number; + bundleHash?: string; + canonicalCompiled?: BrowserCompanionRevisionIdentity; + hostApplied?: BrowserCompanionRevisionIdentity; + browserApplied?: BrowserCompanionRevisionIdentity; + lifecycle?: "awaiting-renderer" | "awaiting-host-apply" | "awaiting-browser-apply" | "ready" | "disconnected-fresh-session-required" | "failed"; + mountError?: string; + egressAttempts?: number; +}; + +export type BrowserCompanionStart = BrowserCompanionStatus & { + active: true; + url: string; + capabilities: BrowserCompanionCapabilities; + humanStep: string; +}; + +export type BrowserCompanionReply = + | { ok: true; data: BrowserCompanionStart | BrowserCompanionStatus | BrowserCompanionLog[] } + | { ok: false; error: string }; + +export function isBrowserCompanionCommand(value: unknown): value is BrowserCompanionCommand { + if (!value || typeof value !== "object") return false; + const command = value as { kind?: unknown; action?: unknown; projectDir?: unknown; projectId?: unknown }; + if (command.kind !== "browser-companion") return false; + if (command.action !== "prepare" && command.action !== "start" && command.action !== "status" && command.action !== "logs" && command.action !== "stop") return false; + const keys = Object.keys(value).sort(); + if (command.action !== "prepare" && command.action !== "start") { + return keys.length === 2 && keys[0] === "action" && keys[1] === "kind"; + } + const exactKeys = command.action === "prepare" + ? keys.join(",") === "action,kind,projectDir" + : keys.join(",") === "action,kind,projectDir" || keys.join(",") === "action,kind,projectDir,projectId"; + return exactKeys && typeof command.projectDir === "string" && command.projectDir.length > 0 && command.projectDir.length <= 32_768 && !command.projectDir.includes("\0") && + (command.action === "prepare" || command.projectId === undefined || (typeof command.projectId === "string" && command.projectId.length > 0 && command.projectId.length <= 256 && !command.projectId.includes("\0"))); +} diff --git a/apps/cli/src/cli-client.ts b/apps/cli/src/cli-client.ts index fc125a02..9f83de9c 100644 --- a/apps/cli/src/cli-client.ts +++ b/apps/cli/src/cli-client.ts @@ -12,6 +12,7 @@ import { observable } from "@trpc/server/observable"; import { SOCKET_PATH } from "./protocol"; import type { CliHandshake, CliHandshakeReply, CliReply, CliRequest } from "./protocol"; import type { AppRouter } from "../../web/src/context/dapi"; +import type { BrowserCompanionCommand, BrowserCompanionReply } from "./browser-companion-protocol"; const DEFAULT_TIMEOUT_MS = 60000; export const GENERATE_TIMEOUT_MS = 600000; @@ -138,7 +139,7 @@ export const editor = createTRPCClient({ links: [cliLink] }); // Transport failures surface as TRPCClientError wrapping the socket error; // unwrap to reach errno codes like ENOENT/ECONNREFUSED. export function errnoCode(e: unknown): string | undefined { - if (!(e instanceof TRPCClientError)) return undefined; + if (!(e instanceof TRPCClientError)) return (e as NodeJS.ErrnoException | undefined)?.code; return (e.cause as NodeJS.ErrnoException | undefined)?.code; } @@ -165,3 +166,23 @@ export async function waitForCliSocket(timeoutMs = 30000): Promise { ? lastError : new Error("Timed out waiting for the app to start"); } + +/** Main-owned companion management; no request is forwarded to either renderer. */ +export function browserCompanion(command: BrowserCompanionCommand, timeoutMs = 60_000): Promise { + return new Promise((resolve, reject) => { + const socket = connect(SOCKET_PATH); + let buffer = ""; + socket.setEncoding("utf8"); + socket.setTimeout(timeoutMs, () => socket.destroy(new Error("Timed out waiting for browser companion host"))); + socket.on("connect", () => socket.end(JSON.stringify(command))); + socket.on("data", (chunk) => { buffer += chunk; }); + socket.on("error", reject); + socket.on("end", () => { + try { + const reply = JSON.parse(buffer) as BrowserCompanionReply; + if (!reply.ok) throw new Error(reply.error); + resolve(reply.data); + } catch (error) { reject(error); } + }); + }); +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 184dac9b..d2ca117b 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -11,7 +11,7 @@ import { dirname, isAbsolute, join, resolve } from "node:path"; import { Command } from "commander"; import { version } from "../../../package.json"; import { parseTime, TIME_FPS } from "@diffusionstudio/jsx"; -import { editor, errnoCode, EXPORT_TIMEOUT_MS, GENERATE_TIMEOUT_MS, waitForCliSocket } from "./cli-client"; +import { browserCompanion, editor, errnoCode, EXPORT_TIMEOUT_MS, GENERATE_TIMEOUT_MS, waitForCliSocket } from "./cli-client"; import { listLocalFonts } from "./fonts"; import { buildIssueBody, createIssue } from "./report"; import { fetchVideo } from "./ytdlp"; @@ -331,10 +331,13 @@ async function checkNode(id: string): Promise { } type OpenOptions = { background?: boolean }; +type BrowserOptions = { status?: boolean; logs?: boolean; stop?: boolean }; /** `open -a` on a running app only activates it, so this is safe to always run. */ -function launchApp(background: boolean): Promise { - const args = background ? ["-g", "-a", APP_NAME, "--args", "--hidden"] : ["-a", APP_NAME]; +function launchApp(background: boolean, companionHost = false): Promise { + const args = background + ? ["-g", "-a", APP_NAME, "--args", "--hidden", ...(companionHost ? ["--browser-companion-host"] : [])] + : ["-a", APP_NAME]; return new Promise((res) => execFile("open", args, (err) => res(!err))); } @@ -359,6 +362,58 @@ async function openProject(path: string | undefined, opts: OpenOptions): Promise } } +async function browserProject(path: string | undefined, opts: BrowserOptions): Promise { + const actions = [opts.status, opts.logs, opts.stop].filter(Boolean).length; + if (actions > 1 || (actions && path)) { + console.error("Pass one of --status, --logs, or --stop without a project path."); + process.exit(1); + } + try { + if (opts.status) console.log(JSON.stringify(await browserCompanion({ kind: "browser-companion", action: "status" }))); + else if (opts.logs) console.log(JSON.stringify(await browserCompanion({ kind: "browser-companion", action: "logs" }))); + else if (opts.stop) console.log(JSON.stringify(await browserCompanion({ kind: "browser-companion", action: "stop" }))); + else { + if (!path) { + console.error("A project folder is required: dapi browser "); + process.exit(1); + } + let running = false; + try { + await editor.ping.query(); + running = true; + } catch (error) { + const code = errnoCode(error); + if (code !== "ENOENT" && code !== "ECONNREFUSED") throw error; + } + if (!running) { + const launched = process.platform === "darwin" && (await launchApp(true, true)); + if (launched) await waitForCliSocket(); + else await editor.ping.query(); + } + const projectDir = resolve(path); + await browserCompanion({ kind: "browser-companion", action: "prepare", projectDir }); + await waitForCliSocket(); + const opened = await editor.open.mutate({ dir: projectDir }); + const readyBy = Date.now() + 30_000; + while (true) { + const context = await editor.context.query(); + if (context.projectDir === projectDir) break; + if (Date.now() >= readyBy) throw new Error("The hidden Electron renderer did not finish opening the project"); + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + const result = await browserCompanion({ + kind: "browser-companion", + action: "start", + projectDir, + ...(opened.id ? { projectId: opened.id } : {}), + }); + console.log(JSON.stringify(result)); + } + } catch (error) { + handleSocketError(error); + } +} + async function context(): Promise { try { const result = await editor.context.query(); @@ -621,6 +676,15 @@ program .option("-b, --background", "launch or keep the app in the background, without raising a window") .action((path: string | undefined, opts: OpenOptions) => openProject(path, opts)); +program + .command("browser") + .description("Open a read-only local browser companion backed by the hidden Electron host. Prints JSON; never opens an OS browser.") + .argument("[path]", "project folder for a new companion session") + .option("--status", "print companion and hidden-host status") + .option("--logs", "print structured companion logs") + .option("--stop", "stop the companion listener and release its resources") + .action((path: string | undefined, opts: BrowserOptions) => browserProject(path, opts)); + program .command("context") .alias("ctx") diff --git a/apps/cli/src/protocol.ts b/apps/cli/src/protocol.ts index d4d60007..474cc9e3 100644 --- a/apps/cli/src/protocol.ts +++ b/apps/cli/src/protocol.ts @@ -9,3 +9,4 @@ // own repo. export * from "./cli-channels"; export * from "./cli-socket-path"; +export * from "./browser-companion-protocol"; diff --git a/apps/cli/tests/browser-companion-protocol.test.ts b/apps/cli/tests/browser-companion-protocol.test.ts new file mode 100644 index 00000000..fa2a9d25 --- /dev/null +++ b/apps/cli/tests/browser-companion-protocol.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isBrowserCompanionCommand } from "../src/browser-companion-protocol.ts"; + +test("accepts the narrow companion lifecycle commands", () => { + assert.equal(isBrowserCompanionCommand({ kind: "browser-companion", action: "status" }), true); + assert.equal(isBrowserCompanionCommand({ kind: "browser-companion", action: "prepare", projectDir: "/trusted/project" }), true); + assert.equal(isBrowserCompanionCommand({ kind: "browser-companion", action: "logs" }), true); + assert.equal(isBrowserCompanionCommand({ kind: "browser-companion", action: "stop" }), true); + assert.equal(isBrowserCompanionCommand({ + kind: "browser-companion", + action: "start", + projectDir: "/trusted/project", + projectId: "project-123", + }), true); + assert.equal(isBrowserCompanionCommand({ + kind: "browser-companion", + action: "start", + projectDir: "/new/project", + }), true); +}); + +test("rejects malformed, overbroad, and authority-bearing messages", () => { + const rejected = [ + null, + {}, + { kind: "browser-companion", action: "prepare" }, + { kind: "browser-companion", action: "prepare", projectDir: "/trusted/project", projectId: "unexpected" }, + { kind: "browser-companion", action: "start", projectDir: "" }, + { kind: "browser-companion", action: "start", projectDir: "/trusted/project", projectId: "" }, + { kind: "browser-companion", action: "start", projectDir: "/trusted/project\0evil", projectId: "project-123" }, + { kind: "browser-companion", action: "status", path: "/etc/passwd" }, + { kind: "browser-companion", action: "write", projectDir: "/trusted/project", projectId: "project-123" }, + { kind: "browser-companion", action: "dapi", procedure: "export" }, + ]; + for (const value of rejected) assert.equal(isBrowserCompanionCommand(value), false, JSON.stringify(value)); +}); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 20c0d2ba..99667394 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,6 +18,8 @@ "stage:docs": "node scripts/stage-docs.mjs", "stage:skills": "node scripts/stage-skills.mjs", "check": "tsc --noEmit", + "test:browser-companion": "node --test --experimental-strip-types tests/browser-companion-*.test.ts", + "test:browser-companion:integration": "node --test tests/browser-companion-lifecycle.integration.mjs", "dev": "npm run build && electron-forge start", "package": "npm run build && npm run build:web && npm run stage:cli && npm run stage:docs && npm run stage:skills && electron-forge package", "make": "npm run build && npm run build:web && npm run stage:cli && npm run stage:docs && npm run stage:skills && electron-forge make", @@ -31,6 +33,7 @@ "@electron-forge/publisher-github": "^7.11.1", "@types/babel__core": "^7.20.5", "@types/node": "^24.10.1", + "@types/ws": "^8.18.1", "electron": "^43.1.1", "typescript": "~5.9.3" }, @@ -44,6 +47,7 @@ "nanoid": "^6.0.1", "ts-morph": "^28.0.0", "update-electron-app": "^3.0.0", + "ws": "^8.18.3", "yaml": "^2.9.0" } } diff --git a/apps/desktop/src/browser-companion-capture.ts b/apps/desktop/src/browser-companion-capture.ts new file mode 100644 index 00000000..8b828301 --- /dev/null +++ b/apps/desktop/src/browser-companion-capture.ts @@ -0,0 +1,88 @@ +/** + * One explicitly armed, root-scoped capture. A published value is retained + * only until its consumer takes it (or the deadline expires); publications + * while no capture is armed are ignored and retain nothing. + */ +export class OneShotCapture { + private pending: { + key: string; + promise: Promise; + resolve: (value: T) => void; + reject: (error: Error) => void; + timer: ReturnType; + published: boolean; + taken: boolean; + } | null = null; + + arm(key: string, timeoutMs: number, timeoutMessage: string): void { + this.cancel("Companion bundle capture was superseded"); + + let resolveCapture!: (value: T) => void; + let rejectCapture!: (error: Error) => void; + const promise = new Promise((resolve, reject) => { + resolveCapture = resolve; + rejectCapture = reject; + }); + // A prepare/start client can disappear before consuming the capture. + // Keep that bounded rejection from becoming an unhandled process error. + void promise.catch(() => {}); + + const capture = { + key, + promise, + resolve: resolveCapture, + reject: rejectCapture, + timer: undefined as unknown as ReturnType, + published: false, + taken: false, + }; + capture.timer = setTimeout(() => { + if (this.pending !== capture) return; + this.pending = null; + capture.reject(new Error(timeoutMessage)); + }, timeoutMs); + this.pending = capture; + } + + isArmedFor(key: string): boolean { + return this.pending?.key === key && !this.pending.published; + } + + publish(key: string, value: T): boolean { + const capture = this.pending; + if (!capture || capture.key !== key || capture.published) return false; + capture.published = true; + capture.resolve(value); + if (capture.taken) this.release(capture); + return true; + } + + take(key: string): Promise { + const capture = this.pending; + if (!capture || capture.key !== key || capture.taken) { + return Promise.reject(new Error("No canonical renderer bundle capture is armed for this project")); + } + capture.taken = true; + if (capture.published) this.release(capture); + return capture.promise; + } + + cancel(message = "Companion bundle capture was cancelled"): void { + const capture = this.pending; + if (!capture) return; + this.release(capture); + capture.reject(new Error(message)); + } + + inspect(): { armed: boolean; retainedValues: number } { + return { + armed: !!this.pending && !this.pending.published, + retainedValues: this.pending?.published ? 1 : 0, + }; + } + + private release(capture: NonNullable["pending"]>): void { + if (this.pending === capture) this.pending = null; + clearTimeout(capture.timer); + } +} diff --git a/apps/desktop/src/browser-companion-security.ts b/apps/desktop/src/browser-companion-security.ts new file mode 100644 index 00000000..907be3e6 --- /dev/null +++ b/apps/desktop/src/browser-companion-security.ts @@ -0,0 +1,58 @@ +import { isAbsolute, relative } from "node:path"; +import { timingSafeEqual } from "node:crypto"; + +export type CompanionAuthenticationExpectation = { + capability: string; + buildHash: string; + protocol: number; + schemaHash: string; + appVersion: string; + capabilityConsumed: boolean; + rendererConnected: boolean; +}; + +export function exactCompanionOrigin(actual: string | undefined, expected: string): boolean { + return actual === expected; +} + +export function isLoopbackCompanionUrl(value: string): boolean { + try { + const host = new URL(value).hostname; + return host === "127.0.0.1" || host === "localhost" || host === "[::1]"; + } catch { + return false; + } +} + +export function containedWebPath(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +export function redactCompanionLog(text: string, sensitive: readonly string[]): string { + let result = text; + for (const value of sensitive) if (value) result = result.split(value).join(""); + return result + .replace(/\b(Bearer|Capability)\s+[A-Za-z0-9._~-]+/gi, "$1 ") + .replace(/(?:\/Users|\/home|\/private|\/tmp|[A-Za-z]:\\)[^\s"']+/g, ""); +} + +function sameSecret(actual: unknown, expected: string): boolean { + if (typeof actual !== "string") return false; + const left = Buffer.from(actual); + const right = Buffer.from(expected); + return left.length === right.length && timingSafeEqual(left, right); +} + +export function isCompanionAuthentication(value: unknown, expected: CompanionAuthenticationExpectation): boolean { + if (!value || typeof value !== "object" || expected.capabilityConsumed || expected.rendererConnected) return false; + const message = value as { type?: unknown; capability?: unknown; buildHash?: unknown; client?: unknown }; + if (message.type !== "authenticate" || !sameSecret(message.capability, expected.capability) || message.buildHash !== expected.buildHash) return false; + if (!message.client || typeof message.client !== "object") return false; + const client = message.client as { protocol?: unknown; schemaHash?: unknown; appVersion?: unknown }; + return client.protocol === expected.protocol && client.schemaHash === expected.schemaHash && client.appVersion === expected.appVersion; +} + +export function isCompanionSemantic(value: unknown): value is "playback.play" | "playback.pause" | "playback.scrub" { + return value === "playback.play" || value === "playback.pause" || value === "playback.scrub"; +} diff --git a/apps/desktop/src/browser-companion.ts b/apps/desktop/src/browser-companion.ts new file mode 100644 index 00000000..1bc5ad49 --- /dev/null +++ b/apps/desktop/src/browser-companion.ts @@ -0,0 +1,728 @@ +import { app, type BrowserWindow } from "electron"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { createReadStream, watch, type FSWatcher } from "node:fs"; +import { readFile, readdir, stat } from "node:fs/promises"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { extname, join, normalize, resolve } from "node:path"; +import { WebSocketServer, WebSocket } from "ws"; + +import { + COMPANION_PROTOCOL_VERSION, + COMPANION_SCHEMA_HASH, + type CompanionCapabilities, + type CompanionSnapshot, +} from "../../web/src/lib/companion-protocol"; +import { + getProject, +} from "./projects"; +import { + containedWebPath, + exactCompanionOrigin, + isCompanionAuthentication, + isCompanionSemantic, + redactCompanionLog, +} from "./browser-companion-security"; +import { OneShotCapture } from "./browser-companion-capture"; + +import type { + BrowserCompanionCommand, + BrowserCompanionLog, + BrowserCompanionReply, + BrowserCompanionStart, + BrowserCompanionStatus, +} from "@diffusionstudio/cli/protocol"; +import type { CompileResult, ProjectInfo } from "./main-channels"; + +const MAX_LOGS = 2000; +const MAX_RENDERER_MESSAGE = 1024 * 1024; +const CAPABILITIES: CompanionCapabilities = { + readOnly: true, + browserDapi: false, + cloudAi: false, + persistentEdits: false, + htmlPaint: false, + media: "unsupported-phase-a", + webgpu: "browser-dependent", + fonts: "browser-dependent", +}; + +type RevisionIdentity = { sessionId: string; revision: number; bundleHash: string }; +type HostApplication = { root: string; sessionId: string; revision: number; bundleHash: string; ok: boolean; error?: string }; +type PendingUpdate = { identity: RevisionIdentity; snapshot: CompanionSnapshot }; + +type Session = { + id: string; + root: string; + project: ProjectInfo; + capability: string; + buildHash: string; + origin: string; + revision: number; + canonicalCompiled: RevisionIdentity; + hostApplied: RevisionIdentity; + browserApplied: RevisionIdentity | null; + pendingUpdate: PendingUpdate | null; + mountError: string | null; + snapshot: CompanionSnapshot; + http: Server; + websocket: WebSocketServer; + renderer: WebSocket | null; + rendererEverConnected: boolean; + capabilityConsumed: boolean; + watcher: FSWatcher | null; + stopped: boolean; + canonicalGeneration: number; + updateChain: Promise; + egressAttempts: number; + logs: BrowserCompanionLog[]; + logSequence: number; + dockWasVisible: boolean; + hostWindowMode: "hidden" | "minimized-fallback"; +}; + +let current: Session | null = null; +let getHostWindow: () => BrowserWindow | null = () => null; +let prepareHost: () => Promise = async () => {}; +let releaseHost: () => Promise = async () => {}; +let hostPrepared = false; +let pendingHostEgress: string[] = []; +let canonicalGeneration = 0; +let preparedSession: { root: string; sessionId: string } | null = null; +type CanonicalBundle = { + bundle: CompileResult; + bundleHash: string; + generation: number; + durationMs: number; + identity: RevisionIdentity; +}; +const canonicalCapture = new OneShotCapture(); +const hostApplicationCapture = new OneShotCapture(); + +function bundleHash(bundle: CompileResult): string { + return createHash("sha256").update(bundle.ok ? bundle.code : `compile-error:\n${bundle.error}`).digest("hex"); +} + +export async function publishBrowserCompanionBundle( + root: string, + bundle: CompileResult, + durationMs: number, +): Promise { + const session = current; + const relayToSession = !!session && session.root === root && !session.stopped; + // Normal desktop compilation is unchanged, but companion code retains no + // result unless a start explicitly armed this root or an active companion + // needs the watch update relayed. + const armedForStart = canonicalCapture.isArmedFor(root); + if (!armedForStart && !relayToSession) return null; + + const compiledBundleHash = bundleHash(bundle); + const generation = ++canonicalGeneration; + + if (!relayToSession || !session) { + if (!preparedSession || preparedSession.root !== root) { + throw new Error("Companion surface capture has no prepared session identity"); + } + const identity = { sessionId: preparedSession.sessionId, revision: 1, bundleHash: compiledBundleHash }; + canonicalCapture.publish(root, { + bundle, + bundleHash: compiledBundleHash, + generation, + durationMs, + identity, + }); + return identity; + } + + const publication = session.updateChain.then(async (): Promise => { + if (session.stopped || generation <= session.canonicalGeneration) return null; + session.canonicalGeneration = generation; + session.revision++; + const identity = { sessionId: session.id, revision: session.revision, bundleHash: compiledBundleHash }; + session.canonicalCompiled = identity; + session.pendingUpdate = { + identity, + snapshot: await makeSnapshot(session, bundle, compiledBundleHash, session.revision), + }; + session.mountError = bundle.ok ? null : "Canonical project compile failed"; + log(session, bundle.ok ? "canonicalCompiled" : "canonicalCompileFailed", bundle.ok ? "info" : "error", { + ...identity, + durationMs, + }); + if (!bundle.ok && session.renderer?.readyState === WebSocket.OPEN) { + session.renderer.send(JSON.stringify({ type: "fatal", error: "Canonical project compile failed; companion stopped applying updates" })); + session.renderer.close(1011, "Canonical compile failed"); + } + return identity; + }); + session.updateChain = publication.then( + () => undefined, + (error) => log(session, "project.bundle.relay.failed", "error", { + message: error instanceof Error ? error.message : String(error), + }), + ); + return publication; +} + +/** Exact hidden-renderer mount acknowledgement for the compiled bundle. */ +export function acknowledgeBrowserCompanionHostBundle( + root: string, + acknowledgement: { sessionId: string; revision: number; bundleHash: string; ok: boolean; error?: string }, +): void { + const application: HostApplication = { root, ...acknowledgement }; + hostApplicationCapture.publish(root, application); + + const session = current; + if (!session || session.stopped || session.root !== root) return; + session.updateChain = session.updateChain.then(() => { + const pending = session.pendingUpdate; + if ( + !pending || + pending.identity.sessionId !== acknowledgement.sessionId || + pending.identity.revision !== acknowledgement.revision || + pending.identity.bundleHash !== acknowledgement.bundleHash + ) { + log(session, "hostApplyRefused", "warning", { + sessionId: acknowledgement.sessionId, + revision: acknowledgement.revision, + bundleHash: acknowledgement.bundleHash, + }); + return; + } + if (!acknowledgement.ok) { + session.mountError = redactCompanionLog(acknowledgement.error ?? "Hidden host mount failed", [session.root, session.capability]); + log(session, "hostApplyFailed", "error", { + revision: pending.identity.revision, + bundleHash: pending.identity.bundleHash, + message: session.mountError, + }); + if (session.renderer?.readyState === WebSocket.OPEN) { + session.renderer.send(JSON.stringify({ type: "fatal", error: "Hidden host failed to mount the canonical bundle" })); + session.renderer.close(1011, "Hidden host mount failed"); + } + return; + } + session.hostApplied = pending.identity; + session.snapshot = pending.snapshot; + session.pendingUpdate = null; + session.mountError = null; + log(session, "hostApplied", "info", pending.identity); + if (session.renderer?.readyState === WebSocket.OPEN) { + session.renderer.send(JSON.stringify({ type: "bundle", snapshot: session.snapshot })); + } + }).catch((error) => log(session, "hostApplyRelayFailed", "error", { + message: error instanceof Error ? error.message : String(error), + })); +} + +export function resetBrowserCompanionHostEgressAudit(): void { + pendingHostEgress = []; +} + +export function recordBrowserCompanionHostEgress(url: string): void { + let target = "invalid-url"; + try { target = new URL(url).origin; } catch { /* redacted semantic target only */ } + if (current) { + current.egressAttempts++; + log(current, "host.network.egress.blocked", "error", { target }); + } else if (pendingHostEgress.length < 100) pendingHostEgress.push(target); +} + +export function configureBrowserCompanion( + window: () => BrowserWindow | null, + prepare: () => Promise, + release: () => Promise, +): void { + getHostWindow = window; + prepareHost = prepare; + releaseHost = release; +} + +function log(session: Session, event: string, level: BrowserCompanionLog["level"] = "info", data?: Record): void { + const safe = data + ? JSON.parse(redactCompanionLog(JSON.stringify(data), [session.root, session.capability])) as Record + : undefined; + session.logs.push({ seq: ++session.logSequence, ts: Date.now(), level, event, ...(safe ? { data: safe } : {}) }); + if (session.logs.length > MAX_LOGS) session.logs.shift(); +} + +function status(session: Session | null): BrowserCompanionStatus { + if (!session) return { active: false, hostLocalOnly: hostPrepared }; + const connected = session.renderer?.readyState === WebSocket.OPEN; + const canonicalReady = + session.hostApplied.sessionId === session.canonicalCompiled.sessionId && + session.hostApplied.revision === session.canonicalCompiled.revision && + session.hostApplied.bundleHash === session.canonicalCompiled.bundleHash; + const browserReady = + !!session.browserApplied && + session.browserApplied.sessionId === session.canonicalCompiled.sessionId && + session.browserApplied.revision === session.canonicalCompiled.revision && + session.browserApplied.bundleHash === session.canonicalCompiled.bundleHash; + const lifecycle = session.mountError + ? "failed" as const + : !connected + ? session.rendererEverConnected || session.capabilityConsumed + ? "disconnected-fresh-session-required" as const + : "awaiting-renderer" as const + : !canonicalReady + ? "awaiting-host-apply" as const + : !browserReady + ? "awaiting-browser-apply" as const + : "ready" as const; + return { + active: true, + sessionId: session.id, + origin: session.origin, + appVersion: app.getVersion(), + buildHash: session.buildHash, + protocol: COMPANION_PROTOCOL_VERSION, + project: { + id: session.project.id, + name: session.project.name, + displayName: session.project.displayName, + }, + rendererConnected: connected, + hostWindowVisible: getHostWindow()?.isVisible() ?? false, + hostWindowMode: session.hostWindowMode, + hostLocalOnly: true, + revision: session.revision, + bundleHash: session.canonicalCompiled.bundleHash, + canonicalCompiled: session.canonicalCompiled, + hostApplied: session.hostApplied, + ...(session.browserApplied ? { browserApplied: session.browserApplied } : {}), + lifecycle, + ...(session.mountError ? { mountError: session.mountError } : {}), + egressAttempts: session.egressAttempts, + }; +} + +function reply(data: BrowserCompanionStart | BrowserCompanionStatus | BrowserCompanionLog[]): BrowserCompanionReply { + return { ok: true, data }; +} + +export async function handleBrowserCompanionCommand(command: BrowserCompanionCommand): Promise { + try { + if (command.action === "prepare") { + await stopBrowserCompanion(); + try { + await prepareHost(); + hostPrepared = true; + } catch (error) { + await releaseHost(); + throw error; + } + preparedSession = { root: command.projectDir, sessionId: randomUUID() }; + canonicalCapture.arm( + command.projectDir, + 30_000, + "The hidden Electron renderer did not publish a canonical project bundle in time", + ); + hostApplicationCapture.arm( + command.projectDir, + 30_000, + "The hidden Electron renderer did not acknowledge the canonical project mount in time", + ); + return reply(status(current)); + } + if (command.action === "status") return reply(status(current)); + if (command.action === "logs") return reply(current?.logs ?? []); + if (command.action === "stop") { + await stopBrowserCompanion(); + return reply(status(current)); + } + try { + return reply(await startBrowserCompanion(command.projectDir, command.projectId)); + } catch (error) { + await stopBrowserCompanion(); + throw error; + } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } +} + +async function startBrowserCompanion(root: string, expectedProjectId?: string): Promise { + await stopBrowserCompanion(true); + + const hostWindow = getHostWindow(); + if (!hostWindow || hostWindow.isDestroyed()) { + canonicalCapture.cancel(); + throw new Error("The Electron renderer host is not available"); + } + + // The hidden renderer opens and compiles first through the unchanged + // PROJECTS_COMPILE path. Main records that exact result and only relays it; + // the companion never owns a second compiler or project-write loop. + let canonical: CanonicalBundle; + let hostApplication: HostApplication; + try { + [canonical, hostApplication] = await Promise.all([ + canonicalCapture.take(root), + hostApplicationCapture.take(root), + ]); + } catch (error) { + canonicalCapture.cancel(); + hostApplicationCapture.cancel(); + throw error; + } + const bundle = canonical.bundle; + if (!bundle.ok) throw new Error("The hidden Electron renderer could not compile the selected project"); + if ( + !hostApplication.ok || + hostApplication.sessionId !== canonical.identity.sessionId || + hostApplication.revision !== canonical.identity.revision || + hostApplication.bundleHash !== canonical.bundleHash + ) { + throw new Error("The hidden Electron renderer did not mount the exact canonical project bundle"); + } + const project = await getProject(root); + if (!project?.id) throw new Error("The Electron host could not establish a project identity"); + if (expectedProjectId && project.id !== expectedProjectId) throw new Error("The requested project does not match the Electron-owned project identity"); + + const webRoot = join(app.getAppPath(), "web"); + const buildHash = await hashWebBuild(webRoot); + const capability = randomBytes(32).toString("base64url"); + const id = canonical.identity.sessionId; + + let expectedOrigin = ""; + const http = createServer((request, response) => serveWeb(webRoot, () => expectedOrigin, request, response)); + const websocket = new WebSocketServer({ noServer: true, maxPayload: MAX_RENDERER_MESSAGE }); + const provisional: Session = { + id, root, project, capability, buildHash, origin: "", revision: 1, + canonicalCompiled: canonical.identity, + hostApplied: canonical.identity, + browserApplied: null, pendingUpdate: null, mountError: null, + snapshot: null as unknown as CompanionSnapshot, + http, websocket, renderer: null, rendererEverConnected: false, capabilityConsumed: false, watcher: null, + stopped: false, canonicalGeneration: canonical.generation, updateChain: Promise.resolve(), + egressAttempts: pendingHostEgress.length, logs: [], logSequence: 0, + dockWasVisible: app.dock?.isVisible() ?? false, + hostWindowMode: "hidden", + }; + + provisional.snapshot = await makeSnapshot(provisional, bundle, canonical.bundleHash, 1); + bindWebSocket(provisional); + http.on("upgrade", (request, socket, head) => { + const url = new URL(request.url ?? "/", expectedOrigin || "http://127.0.0.1"); + if ( + url.pathname !== "/__companion/session" || + request.headers.host !== new URL(expectedOrigin).host || + !exactCompanionOrigin(request.headers.origin, expectedOrigin) + ) { + socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + websocket.handleUpgrade(request, socket, head, (client) => websocket.emit("connection", client)); + }); + + await new Promise((resolveListen, reject) => { + http.once("error", reject); + http.listen(0, "127.0.0.1", () => resolveListen()); + }); + const address = http.address(); + if (!address || typeof address === "string") throw new Error("Companion did not receive a TCP port"); + expectedOrigin = `http://127.0.0.1:${address.port}`; + provisional.origin = expectedOrigin; + provisional.snapshot = await makeSnapshot(provisional, bundle, canonical.bundleHash, 1); + try { + provisional.watcher = watch(root, { recursive: true }, (_event, filename) => { + if (!filename) return; + const path = filename.replace(/\\/g, "/"); + if (path === "node_modules" || path.startsWith("node_modules/") || path === ".diffusion" || path.startsWith(".diffusion/")) return; + log(provisional, "source.changed", "info", { kind: extname(path).slice(1) || "unknown" }); + }); + } catch (error) { + await new Promise((resolveClose) => http.close(() => resolveClose())); + websocket.close(); + throw error; + } + provisional.watcher?.on("error", () => log(provisional, "source.watch.failed", "error")); + current = provisional; + preparedSession = null; + log(provisional, "canonicalCompiled", "info", { ...canonical.identity, durationMs: canonical.durationMs }); + log(provisional, "hostApplied", "info", canonical.identity); + log(provisional, "companion.started", "info", { projectId: project.id, buildHash, protocol: COMPANION_PROTOCOL_VERSION }); + for (const target of pendingHostEgress) log(provisional, "host.network.egress.blocked", "error", { target }); + pendingHostEgress = []; + + hostWindow.hide(); + if (hostWindow.isVisible()) { + hostWindow.minimize(); + provisional.hostWindowMode = "minimized-fallback"; + } + app.dock?.hide(); + log(provisional, provisional.hostWindowMode === "hidden" ? "host.hidden" : "host.minimized-fallback", "info", { + visible: hostWindow.isVisible(), + }); + + const url = `${expectedOrigin}/projects/${encodeURIComponent(project.id)}?companion-shell=1#companion=${capability}&build=${buildHash}`; + return { + ...status(provisional), + active: true, + url, + capabilities: { ...CAPABILITIES, trustedProjectCode: true }, + humanStep: + "Open url in the Codex built-in browser. Phase A supports code-native projects only; local media is fail-closed and unavailable.", + }; +} + +async function hashWebBuild(root: string): Promise { + const hash = createHash("sha256"); + const visit = async (dir: string): Promise => { + const entries = await readdir(dir, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) await visit(path); + else if (entry.isFile()) { + hash.update(path.slice(root.length).replace(/\\/g, "/")); + hash.update(await readFile(path)); + } + } + }; + await visit(root); + return hash.digest("hex").slice(0, 24); +} + +async function makeSnapshot( + session: Session, + bundle: CompileResult, + compiledBundleHash: string, + revision: number, +): Promise { + const safeBundle = bundle.ok + ? bundle + : { ok: false as const, error: redactCompanionLog(bundle.error, [session.root, session.capability]) }; + return { + protocol: COMPANION_PROTOCOL_VERSION, + schemaHash: COMPANION_SCHEMA_HASH, + appVersion: app.getVersion(), + buildHash: session.buildHash, + sessionId: session.id, + revision, + bundleHash: compiledBundleHash, + project: { id: session.project.id, name: session.project.name, displayName: session.project.displayName }, + bundle: safeBundle, + capabilities: CAPABILITIES, + }; +} + +function bindWebSocket(session: Session): void { + session.websocket.on("connection", (client) => { + let authenticated = false; + const timer = setTimeout(() => client.close(1008, "Authentication timeout"), 10_000); + client.on("message", (raw) => { + let message: Record; + try { message = JSON.parse(raw.toString()) as Record; } + catch { client.close(1007, "Malformed message"); return; } + + if (!authenticated) { + const valid = isCompanionAuthentication(message, { + capability: session.capability, + buildHash: session.buildHash, + protocol: COMPANION_PROTOCOL_VERSION, + schemaHash: COMPANION_SCHEMA_HASH, + appVersion: app.getVersion(), + capabilityConsumed: session.capabilityConsumed, + rendererConnected: session.renderer?.readyState === WebSocket.OPEN, + }); + if (!valid) { + log(session, "renderer.authentication.refused", "warning"); + client.send(JSON.stringify({ type: "fatal", error: "Companion protocol/build authentication mismatch" })); + client.close(1008, "Authentication refused"); + return; + } + clearTimeout(timer); + authenticated = true; + session.capabilityConsumed = true; + session.renderer = client; + session.rendererEverConnected = true; + client.send(JSON.stringify({ type: "authenticated", snapshot: session.snapshot })); + log(session, "renderer.authenticated"); + return; + } + + if (client !== session.renderer) { client.close(1008, "Not active renderer"); return; } + if (message.type === "applied") { + const acknowledgement = message.acknowledgement as Partial<{ + sessionId: string; + revision: number; + bundleHash: string; + ok: boolean; + error: string; + }> | undefined; + const exact = + acknowledgement?.sessionId === session.id && + Number.isInteger(acknowledgement.revision) && + acknowledgement?.revision === session.hostApplied.revision && + acknowledgement?.bundleHash === session.hostApplied.bundleHash && + typeof acknowledgement?.ok === "boolean"; + if (!exact) { + log(session, "browserApplyRefused", "warning"); + return; + } + if (!acknowledgement.ok) { + session.mountError = redactCompanionLog( + typeof acknowledgement.error === "string" ? acknowledgement.error : "Browser mount failed", + [session.root, session.capability], + ); + log(session, "browserApplyFailed", "error", { + revision: acknowledgement.revision, + bundleHash: acknowledgement.bundleHash, + message: session.mountError, + }); + client.send(JSON.stringify({ type: "fatal", error: "Browser failed to mount the canonical bundle" })); + client.close(1011, "Browser mount failed"); + return; + } + const alreadyApplied = session.browserApplied; + if ( + alreadyApplied?.sessionId === acknowledgement.sessionId && + alreadyApplied?.revision === acknowledgement.revision && + alreadyApplied?.bundleHash === acknowledgement.bundleHash + ) return; + session.browserApplied = { + sessionId: acknowledgement.sessionId!, + revision: acknowledgement.revision!, + bundleHash: acknowledgement.bundleHash!, + }; + session.mountError = null; + log(session, "browserApplied", "info", session.browserApplied); + } else if (message.type === "semantic" && isCompanionSemantic(message.event)) { + const data = message.data as { time?: unknown } | undefined; + const time = data?.time; + if (typeof time !== "number" || !Number.isFinite(time) || time < 0) { + log(session, "renderer.semantic.refused", "warning"); + return; + } + log(session, message.event, "info", { time: Math.round(time * 1000) / 1000 }); + } else if (message.type === "renderer-log") { + const level = ["debug", "info", "warning", "error"].includes(String(message.level)) + ? message.level as BrowserCompanionLog["level"] : "info"; + log(session, "renderer.log", level, { + source: String(message.source ?? "renderer").slice(0, 80), + message: redactCompanionLog(String(message.message ?? "").slice(0, 4000), [session.root, session.capability]), + }); + } else if (message.type === "outbound-attempt") { + session.egressAttempts++; + log(session, "network.egress.blocked", "error", { + kind: String(message.kind ?? "unknown").slice(0, 80), + target: String(message.target ?? "unknown").slice(0, 256), + }); + } else { + log(session, "renderer.message.refused", "warning", { type: String(message.type) }); + } + }); + client.on("close", () => { + clearTimeout(timer); + if (session.renderer === client) session.renderer = null; + log(session, "renderer.disconnected"); + }); + }); +} + +function headers(response: ServerResponse, origin: string): void { + response.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + response.setHeader("Cross-Origin-Embedder-Policy", "credentialless"); + response.setHeader("Cross-Origin-Resource-Policy", "same-origin"); + response.setHeader("Referrer-Policy", "no-referrer"); + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("Cache-Control", "no-store"); + response.setHeader( + "Content-Security-Policy", + // Reconciler/TypeGPU and the explicitly trusted compiled project use + // new Function. This renderer is capability-limited, but project code is + // not a sandbox; unsafe-eval is therefore an explicit trust declaration. + "default-src 'self' blob: data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; " + + "img-src 'self' blob: data:; media-src 'self' blob: data:; font-src 'self' blob: data:; " + + `connect-src 'self' ${origin.replace(/^http:/, "ws:")}; worker-src 'self' blob:; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'`, + ); +} + +const MIME: Record = { + ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", + ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".woff": "font/woff", ".woff2": "font/woff2", ".wasm": "application/wasm", ".map": "application/json", +}; + +function serveWeb(webRoot: string, origin: () => string, request: IncomingMessage, response: ServerResponse): void { + const expected = origin(); + headers(response, expected); + if (!expected || request.headers.host !== new URL(expected).host || (request.headers.origin !== undefined && request.headers.origin !== expected)) { + response.writeHead(421).end("Misdirected request"); + return; + } + if (request.method !== "GET" && request.method !== "HEAD") { + response.setHeader("Allow", "GET, HEAD"); + response.writeHead(405).end(); + return; + } + const url = new URL(request.url ?? "/", expected); + if (url.pathname === "/__companion/health") { + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify({ ok: true, active: true })); + return; + } + let decoded: string; + try { decoded = decodeURIComponent(url.pathname); } catch { response.writeHead(400).end(); return; } + const requested = normalize(decoded).replace(/^[/\\]+/, ""); + void (async () => { + const candidates = [requested || "index.html"]; + // The packaged desktop build uses base=./. At /projects/:id its relative + // assets resolve below /projects/, so map that route-relative prefix back + // to the exact same build root instead of producing a second web build. + if (requested.startsWith("projects/")) candidates.push(requested.slice("projects/".length)); + for (const candidate of candidates) { + if (await sendStatic(webRoot, candidate, request, response)) return; + } + if (extname(requested)) { response.writeHead(404).end(); return; } + if (!(await sendStatic(webRoot, "index.html", request, response))) response.writeHead(404).end(); + })().catch(() => { + if (!response.headersSent) response.writeHead(500).end(); + else response.destroy(); + }); +} + +async function sendStatic(root: string, requested: string, request: IncomingMessage, response: ServerResponse): Promise { + const path = resolve(root, requested); + if (!containedWebPath(root, path)) return false; + let info; + try { info = await stat(path); } catch { return false; } + if (!info.isFile()) return false; + response.setHeader("Content-Type", MIME[extname(path).toLowerCase()] ?? "application/octet-stream"); + response.setHeader("Content-Length", info.size); + if (request.method === "HEAD") response.end(); + else createReadStream(path).pipe(response); + return true; +} + +export async function stopBrowserCompanion(preservePreparedCapture = false): Promise { + if (!preservePreparedCapture) { + canonicalCapture.cancel(); + hostApplicationCapture.cancel(); + preparedSession = null; + } + const session = current; + if (!session) { + if (!preservePreparedCapture && hostPrepared) { + hostPrepared = false; + await releaseHost(); + } + return; + } + current = null; + session.stopped = true; + session.watcher?.close(); + if (session.renderer?.readyState === WebSocket.OPEN) { + session.renderer.send(JSON.stringify({ type: "stopped", sessionId: session.id })); + session.renderer.close(1001, "Companion stopped"); + } + for (const client of session.websocket.clients) client.terminate(); + await new Promise((resolveClose) => session.http.close(() => resolveClose())); + session.websocket.close(); + if (session.dockWasVisible) await app.dock?.show(); + if (!preservePreparedCapture && hostPrepared) { + hostPrepared = false; + await releaseHost(); + } +} diff --git a/apps/desktop/src/cli-server.ts b/apps/desktop/src/cli-server.ts index c78cf301..c2325430 100644 --- a/apps/desktop/src/cli-server.ts +++ b/apps/desktop/src/cli-server.ts @@ -6,10 +6,13 @@ import { existsSync, unlinkSync } from "node:fs"; import { createServer } from "node:net"; import type { Server, Socket } from "node:net"; import { app, BrowserWindow } from "electron"; -import { CLI_WIRE, SOCKET_PATH } from "@diffusionstudio/cli/protocol"; +import { CLI_WIRE, SOCKET_PATH, isBrowserCompanionCommand } from "@diffusionstudio/cli/protocol"; import type { CliHandshake, CliHandshakeReply } from "@diffusionstudio/cli/protocol"; import { mainBridge } from "./main-manager"; import { MAIN_CHANNELS } from "./main-channels"; +import { handleBrowserCompanionCommand, stopBrowserCompanion } from "./browser-companion"; + +const MAX_CONTROL_FRAME_BYTES = 64 * 1024; let cliServer: Server | null = null; let currentWindow: BrowserWindow | null = null; @@ -105,26 +108,49 @@ export function startCliServer() { bindWindowLifecycle(); cliServer = createServer({ allowHalfOpen: true }, (sock: Socket) => { - enableHeadless(); let buf = ""; + let oversized = false; sock.setEncoding("utf8"); sock.setTimeout(60000, () => sock.destroy()); sock.on("data", (chunk) => { + if (oversized) return; buf += chunk; + if (Buffer.byteLength(buf, "utf8") > MAX_CONTROL_FRAME_BYTES) { + oversized = true; + buf = ""; + } }); sock.on("end", async () => { sock.setTimeout(0); - let handshake: CliHandshake; + if (oversized) { + sock.end(JSON.stringify({ ok: false, error: "Control frame exceeds 64 KiB" })); + return; + } + let value: unknown; try { - handshake = JSON.parse(buf) as CliHandshake; - if (typeof handshake.port !== "number" || typeof handshake.token !== "string") { - throw new Error("Malformed handshake"); - } + value = JSON.parse(buf) as unknown; } catch { + sock.end(JSON.stringify({ ok: false, error: "Invalid control message" })); + return; + } + + if (isBrowserCompanionCommand(value)) { + const response = await handleBrowserCompanionCommand(value); + if (!sock.destroyed) sock.end(JSON.stringify(response)); + return; + } + + const handshake = value as Partial; + if ( + typeof handshake.port !== "number" || !Number.isInteger(handshake.port) || + handshake.port < 1 || handshake.port > 65535 || + typeof handshake.token !== "string" || handshake.token.length < 16 || handshake.token.length > 256 + ) { sock.end(JSON.stringify({ ok: false, error: "Invalid handshake" })); return; } - await deliverHandshake(handshake, sock); + enableHeadless(); + await deliverHandshake(handshake as CliHandshake, sock); }); sock.on("error", () => { // Client hung up; nothing to do. @@ -139,6 +165,7 @@ export function startCliServer() { } export function stopCliServer() { + void stopBrowserCompanion(); if (!cliServer) return; cliServer.close(); cliServer = null; diff --git a/apps/desktop/src/main-channels.ts b/apps/desktop/src/main-channels.ts index 5df8bb83..bc6b1d36 100644 --- a/apps/desktop/src/main-channels.ts +++ b/apps/desktop/src/main-channels.ts @@ -55,6 +55,7 @@ export const MAIN_CHANNELS = { PROJECTS_DUPLICATE: "projects:duplicate", PROJECTS_DELETE: "projects:delete", PROJECTS_COMPILE: "projects:compile", + PROJECTS_BUNDLE_APPLIED: "projects:bundle-applied", PROJECTS_WRITE: "projects:write", PROJECTS_WATCH: "projects:watch", PROJECTS_UNWATCH: "projects:unwatch", @@ -105,6 +106,17 @@ export type CompileResult = | { ok: true; code: string } | { ok: false; error: string }; +/** Exact identity assigned by main to a companion editor-surface mount. */ +export type CompanionMountIdentity = { + sessionId: string; + revision: number; + bundleHash: string; +}; + +export type CompileResponse = CompileResult & { + companionMount?: CompanionMountIdentity; +}; + // Outcome of linking the bundled dapi CLI into PATH. "cancelled" means the // user dismissed the macOS admin prompt — not an error, not installed. export type CliInstallResult = @@ -181,7 +193,14 @@ export type MainRequestMap = { }; [MAIN_CHANNELS.PROJECTS_DUPLICATE]: { request: { dir: string }; response: ProjectInfo }; [MAIN_CHANNELS.PROJECTS_DELETE]: { request: { dir: string }; response: void }; - [MAIN_CHANNELS.PROJECTS_COMPILE]: { request: { dir: string }; response: CompileResult }; + [MAIN_CHANNELS.PROJECTS_COMPILE]: { + request: { dir: string; companionSurfaceMount?: boolean }; + response: CompileResponse; + }; + [MAIN_CHANNELS.PROJECTS_BUNDLE_APPLIED]: { + request: { dir: string; sessionId: string; revision: number; bundleHash: string; ok: boolean; error?: string }; + response: void; + }; [MAIN_CHANNELS.PROJECTS_WRITE]: { request: { dir: string; edits: SourceEdit[] }; response: WriteResult; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b965b054..33855900 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -43,6 +43,15 @@ import { } from "./projects"; import type { DeepLinkChannel } from "./main-channels"; import type { LogEntry } from "@diffusionstudio/cli/protocol"; +import { + acknowledgeBrowserCompanionHostBundle, + configureBrowserCompanion, + publishBrowserCompanionBundle, + recordBrowserCompanionHostEgress, + resetBrowserCompanionHostEgressAudit, + stopBrowserCompanion, +} from "./browser-companion"; +import { isLoopbackCompanionUrl } from "./browser-companion-security"; const DEV_URL = "http://localhost:5173"; const AUTH_PROTOCOL = "diffusion"; @@ -86,6 +95,65 @@ if (app.isPackaged && !process.argv.includes("--hidden")) { const openWrites = new Map(); let mainWindow: BrowserWindow | null = null; +let browserCompanionHostMode = isBrowserCompanionHostLaunch(process.argv); +let browserCompanionRestoreUrl: string | null = null; +configureBrowserCompanion( + () => mainWindow, + async () => { + resetBrowserCompanionHostEgressAudit(); + installBrowserCompanionHostNetworkGuard(); + if (browserCompanionHostMode && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.hide(); + await loadMainWindow(true); + return; + } + browserCompanionRestoreUrl = mainWindow && !mainWindow.isDestroyed() + ? mainWindow.webContents.getURL() || null + : null; + browserCompanionHostMode = true; + if (!mainWindow || mainWindow.isDestroyed()) { + createWindow(false); + return; + } + mainWindow.hide(); + await loadMainWindow(true); + }, + async () => { + const currentHash = mainWindow && !mainWindow.isDestroyed() + ? new URL(mainWindow.webContents.getURL()).hash || "#/" + : "#/"; + browserCompanionHostMode = false; + uninstallBrowserCompanionHostNetworkGuard(); + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.hide(); + const restoreUrl = browserCompanionRestoreUrl; + browserCompanionRestoreUrl = null; + if (restoreUrl) await mainWindow.loadURL(restoreUrl); + // A process launched directly as a hidden companion has no pre-companion + // URL to restore. Keep its currently open project route while reloading + // without the local-only host identity so DAPI stays on that project. + else await loadMainWindow(false, currentHash); + mainWindow.hide(); + }, +); + +let companionNetworkGuardInstalled = false; + +function installBrowserCompanionHostNetworkGuard(): void { + if (companionNetworkGuardInstalled) return; + companionNetworkGuardInstalled = true; + session.defaultSession.webRequest.onBeforeRequest({ urls: ["*://*/*"] }, (details, callback) => { + const loopback = isLoopbackCompanionUrl(details.url); + if (!loopback) recordBrowserCompanionHostEgress(details.url); + callback({ cancel: !loopback }); + }); +} + +function uninstallBrowserCompanionHostNetworkGuard(): void { + if (!companionNetworkGuardInstalled) return; + session.defaultSession.webRequest.onBeforeRequest(null); + companionNetworkGuardInstalled = false; +} // Deep links that arrived before the renderer could take them, keyed by the // channel they belong to so auth and checkout never drain each other's link. @@ -122,6 +190,10 @@ function isHiddenLaunch(argv: string[]): boolean { return argv.includes("--hidden"); } +function isBrowserCompanionHostLaunch(argv: string[]): boolean { + return argv.includes("--browser-companion-host"); +} + // diffusion://auth/callback → auth, diffusion://checkout/callback → checkout. function deepLinkChannel(url: string): DeepLinkChannel | null { let host: string; @@ -188,6 +260,7 @@ function createWindow(show = true) { : { backgroundColor: "#1c1c1c" }), webPreferences: { preload: join(app.getAppPath(), "dist", "preload.js"), + additionalArguments: browserCompanionHostMode ? ["--browser-companion-host"] : [], }, }); @@ -222,11 +295,21 @@ function createWindow(show = true) { mainWindow = null; }); - if (!app.isPackaged) { - mainWindow.loadURL(DEV_URL); - } else { - mainWindow.loadFile(join(app.getAppPath(), "web", "index.html")); - } + void loadMainWindow(); +} + +async function loadMainWindow(resetRoute = false, routeHash?: string): Promise { + if (!mainWindow || mainWindow.isDestroyed()) return; + const query = browserCompanionHostMode ? "?browser-companion-host=1" : ""; + const hash = routeHash?.replace(/^#/, "") || (resetRoute ? "/" : undefined); + // Companion mode uses the packaged apps/web output even in development so + // the hidden host and ordinary browser execute the exact same files whose + // complete tree hash is exchanged during authentication. + if (!app.isPackaged && !browserCompanionHostMode) await mainWindow.loadURL(`${DEV_URL}${query}${hash ? `#${hash}` : ""}`); + else await mainWindow.loadFile(join(app.getAppPath(), "web", "index.html"), { + query: browserCompanionHostMode ? { "browser-companion-host": "1" } : {}, + ...(hash ? { hash } : {}), + }); } if (process.defaultApp && process.argv.length >= 2) { @@ -243,8 +326,17 @@ if (app.requestSingleInstanceLock()) { if (url) deliverDeepLink(url); const hidden = isHiddenLaunch(argv); + if (isBrowserCompanionHostLaunch(argv) && !browserCompanionHostMode) { + browserCompanionHostMode = true; + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.hide(); + void loadMainWindow(); + } else createWindow(false); + return; + } if (mainWindow && !mainWindow.isDestroyed()) { if (hidden) return; + app.dock?.show(); if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); mainWindow.focus(); @@ -291,7 +383,33 @@ if (app.requestSingleInstanceLock()) { mainBridge.handle(MAIN_CHANNELS.PROJECTS_RENAME, ({ dir, displayName }) => renameProject(dir, displayName)); mainBridge.handle(MAIN_CHANNELS.PROJECTS_DUPLICATE, ({ dir }) => duplicateProject(dir)); mainBridge.handle(MAIN_CHANNELS.PROJECTS_DELETE, ({ dir }) => deleteProject(dir)); - mainBridge.handle(MAIN_CHANNELS.PROJECTS_COMPILE, ({ dir }) => compileProject(dir)); + mainBridge.handle(MAIN_CHANNELS.PROJECTS_COMPILE, async ({ dir, companionSurfaceMount }) => { + const started = performance.now(); + const bundle = await compileProject(dir); + if (!companionSurfaceMount) return bundle; + const companionMount = await publishBrowserCompanionBundle( + dir, + bundle, + Math.round(performance.now() - started), + ); + return companionMount ? { ...bundle, companionMount } : bundle; + }); + mainBridge.handle(MAIN_CHANNELS.PROJECTS_BUNDLE_APPLIED, ({ dir, sessionId, revision, bundleHash, ok, error }) => { + if ( + !browserCompanionHostMode || + !/^[0-9a-f-]{36}$/.test(sessionId) || + !Number.isInteger(revision) || + revision < 1 || + !/^[a-f0-9]{64}$/.test(bundleHash) + ) return; + acknowledgeBrowserCompanionHostBundle(dir, { + sessionId, + revision, + bundleHash, + ok, + ...(error ? { error: error.slice(0, 4000) } : {}), + }); + }); mainBridge.handle(MAIN_CHANNELS.PROJECTS_WRITE, ({ dir, edits }) => writeProject(dir, edits)); mainBridge.handle(MAIN_CHANNELS.PROJECTS_WATCH, ({ dir }, event) => watchProject(BrowserWindow.fromWebContents(event.sender), dir), @@ -346,6 +464,7 @@ if (app.requestSingleInstanceLock()) { }); app.whenReady().then(() => { + if (browserCompanionHostMode) installBrowserCompanionHostNetworkGuard(); if (!app.isPackaged && process.platform === "darwin") { const devIcon = nativeImage.createFromPath(join(app.getAppPath(), "assets", "icon-dev.png")); if (!devIcon.isEmpty()) app.dock?.setIcon(devIcon); @@ -361,12 +480,13 @@ if (app.requestSingleInstanceLock()) { startCliServer(); healSkillsLinks(); - trackInstall(); + if (!browserCompanionHostMode) trackInstall(); createWindow(!isHiddenLaunch(process.argv)); }); app.on("before-quit", () => { unwatchAll(); + void stopBrowserCompanion(); stopCliServer(); }); @@ -377,6 +497,7 @@ if (app.requestSingleInstanceLock()) { }); app.on("activate", () => { + app.dock?.show(); if (!mainWindow || mainWindow.isDestroyed()) { createWindow(); return; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 688361ec..7fadc36d 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -17,6 +17,7 @@ const ALLOWED_MAIN_TO_RENDERER: ReadonlySet = new Set([ ]); contextBridge.exposeInMainWorld("desktop", { + browserCompanionHost: process.argv.includes("--browser-companion-host"), getPathForFile, platform: process.platform, send, diff --git a/apps/desktop/tests/browser-companion-capture.test.ts b/apps/desktop/tests/browser-companion-capture.test.ts new file mode 100644 index 00000000..8763fcb8 --- /dev/null +++ b/apps/desktop/tests/browser-companion-capture.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { OneShotCapture } from "../src/browser-companion-capture.ts"; + +test("inactive ordinary desktop publications retain no bundle", () => { + const capture = new OneShotCapture<{ code: string }>(); + assert.equal(capture.publish("/project", { code: "ordinary desktop compile" }), false); + assert.deepEqual(capture.inspect(), { armed: false, retainedValues: 0 }); +}); + +test("start, stop, and same-project restart each consume a fresh bounded capture", async () => { + const capture = new OneShotCapture<{ code: string }>(); + + capture.arm("/project", 1_000, "timed out"); + assert.equal(capture.publish("/project", { code: "first compile" }), true); + assert.deepEqual(await capture.take("/project"), { code: "first compile" }); + assert.deepEqual(capture.inspect(), { armed: false, retainedValues: 0 }); + + // A stopped companion leaves no reusable result. The identical project + // must be explicitly armed and publish again for the next start. + assert.equal(capture.publish("/project", { code: "compile while stopped" }), false); + capture.arm("/project", 1_000, "timed out"); + const restarted = capture.take("/project"); + assert.equal(capture.publish("/other", { code: "wrong project" }), false); + assert.equal(capture.publish("/project", { code: "fresh restart compile" }), true); + assert.deepEqual(await restarted, { code: "fresh restart compile" }); + assert.deepEqual(capture.inspect(), { armed: false, retainedValues: 0 }); +}); + +test("an abandoned capture expires and releases its retained value", async () => { + const capture = new OneShotCapture<{ code: string }>(); + capture.arm("/project", 20, "capture expired"); + assert.equal(capture.publish("/project", { code: "unclaimed" }), true); + assert.deepEqual(capture.inspect(), { armed: false, retainedValues: 1 }); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.deepEqual(capture.inspect(), { armed: false, retainedValues: 0 }); +}); diff --git a/apps/desktop/tests/browser-companion-lifecycle.integration.mjs b/apps/desktop/tests/browser-companion-lifecycle.integration.mjs new file mode 100644 index 00000000..8bb3fa23 --- /dev/null +++ b/apps/desktop/tests/browser-companion-lifecycle.integration.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { resolve } from "node:path"; +import test from "node:test"; + +const projectDir = process.env.DIFFUSION_COMPANION_TEST_PROJECT; +const cli = resolve(import.meta.dirname, "../../cli/dist/index.js"); + +function command(...args) { + const output = execFileSync(process.execPath, [cli, ...args], { + encoding: "utf8", + timeout: 60_000, + }).trim(); + return JSON.parse(output); +} + +test("live stop then same-project start gets a fresh session while DAPI survives", { + skip: projectDir ? false : "Set DIFFUSION_COMPANION_TEST_PROJECT and run a built local Electron host", +}, () => { + const expected = resolve(projectDir); + command("browser", "--stop"); + try { + const first = command("browser", expected); + assert.equal(first.active, true); + assert.equal(first.hostWindowVisible, false); + assert.equal(first.hostLocalOnly, true); + assert.equal(first.lifecycle, "awaiting-renderer"); + assert.deepEqual(first.canonicalCompiled, first.hostApplied); + assert.equal(first.canonicalCompiled.sessionId, first.sessionId); + assert.equal(first.canonicalCompiled.revision, 1); + + const firstStopped = command("browser", "--stop"); + assert.deepEqual(firstStopped, { active: false, hostLocalOnly: false }); + assert.equal(command("context").projectDir, expected); + + const second = command("browser", expected); + assert.equal(second.active, true); + assert.equal(second.hostWindowVisible, false); + assert.equal(second.hostLocalOnly, true); + assert.equal(second.lifecycle, "awaiting-renderer"); + assert.deepEqual(second.canonicalCompiled, second.hostApplied); + assert.equal(second.canonicalCompiled.sessionId, second.sessionId); + assert.equal(second.canonicalCompiled.revision, 1); + assert.equal(second.project.id, first.project.id); + assert.notEqual(second.sessionId, first.sessionId); + assert.notEqual(second.origin, first.origin); + + const secondStopped = command("browser", "--stop"); + assert.deepEqual(secondStopped, { active: false, hostLocalOnly: false }); + assert.equal(command("context").projectDir, expected); + } finally { + command("browser", "--stop"); + } +}); diff --git a/apps/desktop/tests/browser-companion-security.test.ts b/apps/desktop/tests/browser-companion-security.test.ts new file mode 100644 index 00000000..36451dde --- /dev/null +++ b/apps/desktop/tests/browser-companion-security.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { resolve } from "node:path"; + +import { + containedWebPath, + exactCompanionOrigin, + isCompanionAuthentication, + isCompanionSemantic, + isLoopbackCompanionUrl, + redactCompanionLog, +} from "../src/browser-companion-security.ts"; + +test("origin matching is exact, including host spelling and port", () => { + const expected = "http://127.0.0.1:43127"; + assert.equal(exactCompanionOrigin(expected, expected), true); + assert.equal(exactCompanionOrigin(undefined, expected), false); + assert.equal(exactCompanionOrigin("http://localhost:43127", expected), false); + assert.equal(exactCompanionOrigin("http://127.0.0.1:43128", expected), false); + assert.equal(exactCompanionOrigin(`${expected}.example.test`, expected), false); + assert.equal(exactCompanionOrigin("null", expected), false); +}); + +test("zero-egress host guard allows loopback only", () => { + assert.equal(isLoopbackCompanionUrl("http://127.0.0.1:5173/index.js"), true); + assert.equal(isLoopbackCompanionUrl("ws://localhost:5173/socket"), true); + assert.equal(isLoopbackCompanionUrl("http://[::1]:5173/"), true); + assert.equal(isLoopbackCompanionUrl("https://api.diffusion.studio/"), false); + assert.equal(isLoopbackCompanionUrl("https://localhost.example.test/"), false); + assert.equal(isLoopbackCompanionUrl("not a URL"), false); +}); + +test("static paths cannot escape into traversal or prefix siblings", () => { + const root = resolve("/srv/diffusion/web"); + assert.equal(containedWebPath(root, resolve(root, "index.html")), true); + assert.equal(containedWebPath(root, root), true); + assert.equal(containedWebPath(root, resolve(root, "../web-evil/index.html")), false); + assert.equal(containedWebPath(root, resolve(root, "../../etc/passwd")), false); +}); + +test("semantic log allowlist rejects arbitrary renderer authority", () => { + assert.equal(isCompanionSemantic("playback.play"), true); + assert.equal(isCompanionSemantic("playback.pause"), true); + assert.equal(isCompanionSemantic("playback.scrub"), true); + assert.equal(isCompanionSemantic("project.write"), false); + assert.equal(isCompanionSemantic("dapi.call"), false); + assert.equal(isCompanionSemantic({ event: "playback.play" }), false); +}); + +test("logs redact the project root, capability, bearer values, and absolute paths", () => { + const root = "/Users/tester/Secret Project"; + const capability = "one-time-capability-secret"; + const redacted = redactCompanionLog( + `${root}/index.tsx ${capability} Bearer abc.def.ghi /tmp/private.mov C:\\Users\\tester\\private.mov`, + [root, capability], + ); + assert.doesNotMatch(redacted, /Secret Project|one-time-capability-secret|abc\.def\.ghi|private\.mov/); + assert.match(redacted, //); +}); + +test("renderer authentication fails closed on every linkage mismatch and reuse", () => { + const expected = { + capability: "capability", + buildHash: "web-build", + protocol: 1, + schemaHash: "schema-v1", + appVersion: "0.204.0", + capabilityConsumed: false, + rendererConnected: false, + }; + const message = { + type: "authenticate", + capability: "capability", + buildHash: "web-build", + client: { protocol: 1, schemaHash: "schema-v1", appVersion: "0.204.0" }, + }; + assert.equal(isCompanionAuthentication(message, expected), true); + assert.equal(isCompanionAuthentication({ ...message, capability: "wrong" }, expected), false); + assert.equal(isCompanionAuthentication({ ...message, buildHash: "wrong" }, expected), false); + assert.equal(isCompanionAuthentication({ ...message, client: { ...message.client, protocol: 2 } }, expected), false); + assert.equal(isCompanionAuthentication({ ...message, client: { ...message.client, schemaHash: "wrong" } }, expected), false); + assert.equal(isCompanionAuthentication({ ...message, client: { ...message.client, appVersion: "wrong" } }, expected), false); + assert.equal(isCompanionAuthentication(message, { ...expected, capabilityConsumed: true }), false); + assert.equal(isCompanionAuthentication(message, { ...expected, rendererConnected: true }), false); +}); diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js index cffe3e68..e1c20687 100644 --- a/apps/web/eslint.config.js +++ b/apps/web/eslint.config.js @@ -62,6 +62,7 @@ export default [ "node_modules/**", "dist/**", "dist-ssr/**", + "tests/companion-*.test.ts", "*.config.js", "*.config.ts", ], diff --git a/apps/web/index.html b/apps/web/index.html index fb4cb4f7..af0cc76d 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -5,6 +5,261 @@