From 24c4e0f4f21fc72bcc44c7b38d11b11532f93ace Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Fri, 9 Oct 2026 16:37:01 +0200 Subject: [PATCH 01/10] feat(runtime): inspect and try a desktop app before it's added - inspectDesktopApp: static checks of a picked .app (where it is, Info.plist via plutil, Electron's framework, its executable, App Sandbox, codesign); its signer kept: a developer's team, or a digest pinning an unsigned build - trialDesktopApp: a confined launch without the network beyond this machine; read as ok, a wrapper's site (named even unreached), a quit - apps allowing one copy (Slack, VS Code, Discord) launch confined: their lock's socket goes to the sandbox's temp (MAC_CHROMIUM_TMPDIR) - launch errors say why structurally; the tests' launch hooks leave the package's launch options - a macOS CI job runs the desktop-app suite --- .github/workflows/ci.yml | 15 ++ docs/BACKLOG.md | 1 - docs/OBJECT-MODEL.md | 1 + packages/runtime/src/electron-inspect.ts | 219 ++++++++++++++++++ packages/runtime/src/electron-trial.ts | 50 ++++ packages/runtime/src/electron.ts | 98 ++++++-- packages/runtime/src/index.ts | 10 + .../runtime/test/electron-inspect.test.ts | 201 ++++++++++++++++ packages/runtime/test/electron.test.ts | 27 ++- .../runtime/test/fixtures/crash-launch.ts | 20 +- .../test/fixtures/electron-app/main.cjs | 11 +- vitest.config.ts | 2 +- 12 files changed, 616 insertions(+), 39 deletions(-) create mode 100644 packages/runtime/src/electron-inspect.ts create mode 100644 packages/runtime/src/electron-trial.ts create mode 100644 packages/runtime/test/electron-inspect.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e72c221..7eea6bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,3 +49,18 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage/lcov.info fail_ci_if_error: false + + # Desktop apps run on macOS only (Seatbelt, codesign, plutil): their suite, where it holds. + electron-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm exec vitest run --project electron + env: + KIFRAME_ELECTRON_TIMING: "1" diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index b1611b1..00aef78 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -246,7 +246,6 @@ Non-severe review findings deferred on purpose (see the review-round rule: only - **Copy and paste in a desktop app's demo:** the confinement denies the pasteboard (the user's clipboard may hold a password), so an app's own copy and paste does nothing in a run. A demo that needs it: a run-only pasteboard (a private named pasteboard can't be forced on an app) or typing the pasted text. - **What a confined app opens through LaunchServices:** an app can't start without `launchservicesd`, and through it it can open a file or a URL that then runs unconfined (a script it wrote, the user's browser). Stated in §0.9 (accident, not a hostile app); an Endpoint Security watcher or a per-run user account would close it. - **Desktop-app sweeps read processes more than once:** the startup sweep runs one `ps` per leftover sandbox (one `ps -axo pid=,lstart=` table would answer every owner), and a run's close reads the process table twice (`orphanedGroup` and `sweepProcesses`). Startup and close time only (#58 review). -- **Test hooks on the launch API (PR 4):** `appArgs`, `readable` and `allowUnconfined` are fields of `ElectronLaunch`, and `appArgs` skips the files/-only rule. Move them out of the public options when the desktop app first calls `launchElectron` (#58 review, agreed 2026-10-09). - **One launch helper:** browsers are launched by each entry point; nothing enforces an off-the-record context. A runtime-owned `launchBrowser()` / context factory would make it hard to bypass (the desktop app's main process). - **Secrets into contenteditable:** v0 writes secrets to inputs and textareas only (the value set on the approved element); a rich-text field is refused. - **Network exfiltration by the page itself:** a hostile page receives the typed password in its own field and can send it anywhere; that's the target app's own data. Out of scope for the vault (it only guarantees the value goes to the bound field on the allowed origins). diff --git a/docs/OBJECT-MODEL.md b/docs/OBJECT-MODEL.md index 453dd36..e472236 100644 --- a/docs/OBJECT-MODEL.md +++ b/docs/OBJECT-MODEL.md @@ -230,6 +230,7 @@ One scene, three phases. The agent's own instructions say the same (`packages/st - **Confinement: a guard, sealed at launch (PR 2, redesigned after three review rounds, design reviewed).** Once the app settles (its windows unchanged 1.5 s), its main window is the last opened still open; its own scheme or dev server is learned from that window alone, then sealed (nothing a run does adds to it). A main window that isn't the app's own (a wrapper's unlisted site) refuses the launch ("list it in its origins"). From then a **guard** checks every navigation of every frame of every window as it happens: a page that isn't the app's own is stopped at once (a popup closed, any other frame blanked: never left for a read or a frame) and recorded; the step fails `off-app`. The app's own: a local `file:` in its bundle or sandbox (decoded, normalized), its sealed scheme or dev server, a listed https site (`sameApp`, never plain http), a blob of those. Stated (as a web run): the guard acts as a page commits, so a page that isn't the app's own has loaded (its scripts ran, with the app's own rights) before it's stopped: following a link the app offers is what its user does too (blocking before the load needs request interception, which never sees `file:`); and the runner's own navigation events name it as they do for web runs (origin and path, known secret values scrubbed). What the main window embeds as it opens (a frame of another site) is the app as shipped: its origin is the app's own. A main window stopped goes back to the app's page (no goto there to bring it back); a failed load is said as one. **One app per run:** a desktop app's run never shows a site (no goto, URL condition or web preset; a web interrupt rule's goto skipped, said), is confined while grounding too, and keeps no sign-in between runs yet. - **Windows (PR 2, known limits, to settle on real apps in PR 3 and PR 5: decided by the user 2026-10-08):** the main window is the last opened still open once the app's windows were unchanged for 1.5 s (a splash that stays up longer is taken for the main window); windows the app's main process opens are followed as popups are (a hidden helper window opened during a step may be followed: CDP can't tell it's hidden); only the launch window is sent back when it goes off-app (another window is closed); a stop that lands after its step ended counts against the next step. A window that opens blank gets a moment to load (as a web page does); a frame of a window the run isn't driving is said, never the step's failure. - **Every launch confined (PR 3a, 2026-10-09, design reviewed twice, measured):** on macOS each launch runs under Seatbelt (`sandbox-exec`, `electron-confine.ts`): writes only into its own sandbox; no reads of /Users, /Volumes, the temp folders, the user's home and the work area wherever they are (a home outside /Users) but its sandbox and its bundle; nothing run from its sandbox (the one place it writes: a program it drops there, a copied launcher), no `open`, `osascript`, `launchctl` nor Apple Events; no unix sockets (an agent's: ssh, Docker) but mDNSResponder; no preferences (cfprefsd), keychain (SecurityServer: a mock keychain instead, `--use-mock-keychain`) nor clipboard (the user's may hold a password just copied: an app's own copy and paste doesn't work in a run); network for runs (the app's backend), loopback only for a trial (no updater). Chromium's own sandbox can't nest in it (`--no-sandbox`): the guard and this profile replace it. A canary before every launch, before the files are copied (a read the profile refuses must fail, one of its sandbox succeed: a confinement that runs nothing never passes); no `sandbox-exec`, or a canary failed: no launch (never unconfined). Off macOS: desktop apps don't launch (CI's tests only, unconfined). Measured with a probe app: every escape above `EPERM` (a hard link or a symlink to an outside file too), the app driven as usual. This replaces the trial's after-the-fact isolation check, rejected by review (an app that leaks does its harm as it starts). **What it doesn't stop (stated):** an app can still ask macOS to open a file or a URL (LaunchServices: no app starts without reaching it, measured), and what opens runs outside the confinement; it reaches services on this machine (localhost: a dev build's own server needs it). The app's own pages are its bundle and what it writes in its sandbox's home, profile and temp folders (a page it builds there from remote content counts as its own, as its cache already does); never the files/ copy. The confinement keeps an app from the user's data by accident (the minmux incident), not an app built to escape (one the user runs unconfined every day anyway). +- **Adding a desktop app (PR 3b, design reviewed 2026-10-09):** inspected first, statically (`electron-inspect.ts`: only `plutil` and `codesign` run): refused outside where an app can stay (a disk image, a temporary translocated copy, the Trash, inside another app), not Electron (its framework; no version gate: Figma reports 13.6.1), an executable that isn't its own file, a Mac App Store build (App Sandbox), a developer signature that doesn't hold (`codesign --verify --strict` against its team and signed identifier). Kept: its id, name, Electron's version, and its signer: a developer (team id) or, ad hoc and unsigned (the user's own builds), pinned to a digest of the code it runs (executable, Info.plist, Electron's framework, app.asar and what's unpacked, `Resources/app`). Then **tried confined** (`electron-trial.ts`): launched with no network beyond this machine (no updater, no backend), its main window attached, closed; a wrapper's site is named even unreached (the error page's unreachable URL, over CDP) and allowed by the user in a second trial with the network. Apps that allow one copy (Slack, VS Code, Discord) need Chromium's temp folder in the sandbox (`MAC_CHROMIUM_TMPDIR`): its lock's socket otherwise goes to the user's /var/folders, denied, and the app quits. - **The work area `~/.kiframe/`** (decided by the user 2026-10-09: deletable any time): `sandboxes/` (one per launch, 0700, never a link), each named after the Kiframe that made it (`kiframe-app---…`: owned from the moment it exists, no shared state file to race on); a crash sweep at start removes only sandboxes no running Kiframe owns (the processes pointing at them killed first), never another build's launch, and nothing when `ps` can't be read. Kept out of Time Machine and Spotlight. Approvals stay in the app's settings, takes in app data. - **A project's `files/`** (decided by the user 2026-10-09): what a desktop app opens. Copied into the sandbox at each launch by Kiframe's own walk (folders and regular files only: a link, a FIFO, a device refused; 500 MB, 20,000 files, depth 32; cloned where the disk can, times kept); `args` name paths in it only (`files/vault`, given as the copy's path); the app's edits stay in the copy (thrown away: every run from the same files). The copy is the project's content, never the app's own pages for the guard. Filled by the user in v0: the agent's file tools never reach `files/` (it may hold what runs inside the app: an editor's tasks, a vault's plugins). - **Measured on a fixture app (PR 2, 2026-10-08):** HOME, XDG and `--user-data-dir` hold; macOS's own places don't move: Electron's `appData` (`~/Library/Application Support`) and the app's preferences (`NSUserDefaults`, written by `cfprefsd` into the user's real `~/Library/Preferences/.plist`) stay the user's whatever `HOME` or `CFFIXED_USER_HOME` say. Hence the confinement (PR 3a, below): the preferences daemon is denied and writes outside the sandbox refused, so neither can be reached (a check after launch, the earlier plan, came too late). diff --git a/packages/runtime/src/electron-inspect.ts b/packages/runtime/src/electron-inspect.ts new file mode 100644 index 0000000..9f33ed9 --- /dev/null +++ b/packages/runtime/src/electron-inspect.ts @@ -0,0 +1,219 @@ +import { execFile } from "node:child_process" +import { createHash } from "node:crypto" +import { constants, createReadStream } from "node:fs" +import { access, lstat, readdir, readlink, realpath } from "node:fs/promises" +import { basename, join } from "node:path" + +// A desktop app the user picks, inspected before Kiframe ever runs it (PR 3b, design reviewed +// 2026-10-09): only plutil and codesign run, nothing of the app. What's refused is said as is; +// what's kept is what an approval pins (its developer, or the exact build of an unsigned one). + +/** + * Who stands behind the app: a developer (Apple team id; `identifier` the signing identifier, kept + * for the signature check) or no one (ad hoc or unsigned: pinned to a digest of its code). + */ +export type Signer = + { kind: "team"; team: string; identifier: string } | { kind: "pinned"; digest: string } + +export interface DesktopApp { + /** The bundle's real path. */ + path: string + bundleId: string + /** Its name as shown (Finder's). */ + name: string + version: string | undefined + /** Electron's version in it (shown, never a condition: the confined trial decides). */ + electron: string + /** The executable's real path, inside the bundle. */ + executable: string + signer: Signer +} + +/** An app Kiframe won't add: said to the user as is. */ +export class InspectError extends Error {} + +const BUNDLE_ID = /^[A-Za-z][A-Za-z0-9-]*(\.[A-Za-z0-9-]+)+$/ + +/** A command's exit code and output (the C locale: read the same in every language). */ +function command( + cmd: string, + args: string[], +): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolve) => { + execFile( + cmd, + args, + { timeout: 30_000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } }, + (error, stdout, stderr) => { + const code = error === null ? 0 : typeof error.code === "number" ? error.code : -1 + resolve({ code, stdout, stderr }) + }, + ) + }) +} + +/** Why an app can't be used from where it is (its real path), or undefined. */ +export function placeRefusal(real: string): string | undefined { + if (!real.endsWith(".app")) return "that isn't an app (a .app)" + if (real.slice(0, -4).includes(".app/")) + return "that app is inside another app: pick the app itself" + if (real.startsWith("/Volumes/")) { + return "that app is on a disk image or another disk: copy it to Applications first" + } + if (real.includes("/AppTranslocation/")) { + return "macOS runs that app from a temporary copy: move it to Applications, open it once, then add it" + } + if (real.includes("/.Trash/")) return "that app is in the Trash" + return undefined +} + +/** A value of an Info.plist (undefined when it has none), read by plutil. */ +async function plistValue(plist: string, key: string): Promise { + const out = await command("/usr/bin/plutil", ["-extract", key, "raw", "-o", "-", plist]) + return out.code === 0 ? out.stdout.trim() : undefined +} + +/** What `codesign -dv` says of a signature: unsigned, ad hoc, or a developer's. */ +export function parseSigning( + code: number, + stderr: string, +): { kind: "unsigned" } | { kind: "adhoc" } | { kind: "team"; team: string; identifier: string } { + if (code !== 0) return { kind: "unsigned" } + const field = (name: string) => new RegExp(`^${name}=(.*)$`, "m").exec(stderr)?.[1]?.trim() + const team = field("TeamIdentifier") + const identifier = field("Identifier") + if (field("Signature") === "adhoc" || team === undefined || team === "not set") { + return { kind: "adhoc" } + } + return { kind: "team", team, identifier: identifier ?? "" } +} + +/** The requirement a developer's signature must meet: their team, the signed identifier. */ +export function teamRequirement(team: string, identifier: string): string { + const q = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` + return `anchor apple generic and identifier ${q(identifier)} and certificate leaf[subject.OU] = ${q(team)}` +} + +/** Whether the bundle's signature is whole and its developer's (`codesign --verify --strict`). */ +export async function signatureHolds(path: string, signer: Signer): Promise { + if (signer.kind !== "team") return true + const out = await command("/usr/bin/codesign", [ + "--verify", + "--strict", + `-R=${teamRequirement(signer.team, signer.identifier)}`, + path, + ]) + return out.code === 0 +} + +/** The code an unsigned app runs, hashed: what pins the exact build (any change: a new digest). */ +export async function codeDigest(path: string, executable: string): Promise { + const hash = createHash("sha256") + const add = async (rel: string) => { + const full = join(path, rel) + let stat + try { + stat = await lstat(full) + } catch { + return + } + if (stat.isSymbolicLink()) { + hash.update(`link ${rel} ${await readlink(full)}\n`) + } else if (stat.isDirectory()) { + for (const name of (await readdir(full)).sort()) await add(join(rel, name)) + } else if (stat.isFile()) { + hash.update(`file ${rel} ${stat.size}\n`) + for await (const chunk of createReadStream(full)) hash.update(chunk as Buffer) + } + } + const framework = "Contents/Frameworks/Electron Framework.framework" + for (const rel of [ + executable.slice(path.length + 1), + "Contents/Info.plist", + // Electron itself, its helpers and libraries. + `${framework}/Versions/A`, + "Contents/Resources/app.asar", + "Contents/Resources/app.asar.unpacked", + "Contents/Resources/app", + ]) { + await add(rel) + } + return hash.digest("hex") +} + +/** The signer of an app at `path` (its executable's signature; pinned when it has no developer). */ +export async function signerOf(path: string, executable: string): Promise { + const signing = await command("/usr/bin/codesign", ["-dv", path]) + const parsed = parseSigning(signing.code, signing.stderr) + if (parsed.kind === "team") return parsed + return { kind: "pinned", digest: await codeDigest(path, executable) } +} + +/** + * The app at `picked`, inspected (static: nothing of it runs). Refused, said: not an app, where it + * can't stay, not Electron, an executable that isn't its own file, a Mac App Store build (its own + * sandbox), a developer signature that doesn't hold. + */ +export async function inspectDesktopApp(picked: string): Promise { + let path: string + try { + path = await realpath(picked) + if (!(await lstat(path)).isDirectory()) throw new Error() + } catch { + throw new InspectError("that app can't be read (moved or removed?)") + } + const place = placeRefusal(path) + if (place !== undefined) throw new InspectError(place) + const plist = join(path, "Contents", "Info.plist") + const bundleId = await plistValue(plist, "CFBundleIdentifier") + if (bundleId === undefined || !BUNDLE_ID.test(bundleId)) { + throw new InspectError("that app has no bundle id Kiframe can use") + } + const framework = join(path, "Contents/Frameworks/Electron Framework.framework") + const electron = await plistValue(join(framework, "Resources/Info.plist"), "CFBundleVersion") + if (electron === undefined) { + throw new InspectError("that isn't an Electron app: Kiframe drives Electron apps only") + } + const exe = await plistValue(plist, "CFBundleExecutable") + if (exe === undefined || exe === "" || exe === "." || exe === ".." || exe.includes("/")) { + throw new InspectError("that app names no executable Kiframe can run") + } + const executable = join(path, "Contents", "MacOS", exe) + try { + const stat = await lstat(executable) + if (!stat.isFile()) throw new Error() + if (!(await realpath(executable)).startsWith(`${path}/`)) throw new Error() + await access(executable, constants.X_OK) + } catch { + throw new InspectError("that app's executable isn't its own runnable file") + } + const entitlements = await command("/usr/bin/codesign", [ + "-d", + "--entitlements", + "-", + "--xml", + path, + ]) + if (/com\.apple\.security\.app-sandbox<\/key>\s*/.test(entitlements.stdout)) { + throw new InspectError( + "that's a Mac App Store build (it has its own sandbox, which can't run in Kiframe's)", + ) + } + const signer = await signerOf(path, executable) + if (!(await signatureHolds(path, signer))) { + throw new InspectError("that app's signature is broken: reinstall it") + } + const name = + (await plistValue(plist, "CFBundleDisplayName")) ?? + (await plistValue(plist, "CFBundleName")) ?? + basename(path, ".app") + return { + path, + bundleId, + name, + version: await plistValue(plist, "CFBundleShortVersionString"), + electron, + executable, + signer, + } +} diff --git a/packages/runtime/src/electron-trial.ts b/packages/runtime/src/electron-trial.ts new file mode 100644 index 0000000..d169f89 --- /dev/null +++ b/packages/runtime/src/electron-trial.ts @@ -0,0 +1,50 @@ +import type { Viewport } from "@kiframe/schema" +import { ElectronLaunchError, launchElectron } from "./electron.ts" +import type { DesktopApp } from "./electron-inspect.ts" + +/** + * A trial's outcome, read structurally: it runs confined and is driven (`ok`); its window is a site + * (`site`: a wrapper's, to allow as its own); it quit at once; or it failed (`failed`: said as is). + */ +export type TrialOutcome = { ok: true } | { site: string } | { quit: true } | { failed: string } + +export interface TrialOptions { + /** Sites it shows as its own (a second trial, after the first named one): the network then. */ + origins?: readonly string[] + workDir?: string + signal?: AbortSignal + viewport?: Pick + timeoutMs?: number +} + +/** + * An app tried before it's added (PR 3b): launched confined, without the network beyond this + * machine unless sites are listed (no updater, no backend: what it shows is its own), its main + * window attached, then closed. A stop is thrown as the stop. + */ +export async function trialDesktopApp( + app: Pick, + opts: TrialOptions = {}, +): Promise { + const origins = opts.origins ?? [] + try { + const target = await launchElectron({ + executable: app.executable, + bundle: app.path, + network: origins.length > 0 ? "all" : "loopback", + origins, + viewport: opts.viewport ?? { width: 1440, height: 900, deviceScaleFactor: 1 }, + ...(opts.workDir !== undefined && { workDir: opts.workDir }), + ...(opts.signal !== undefined && { signal: opts.signal }), + ...(opts.timeoutMs !== undefined && { timeoutMs: opts.timeoutMs }), + }) + await target.close() + return { ok: true } + } catch (error) { + if (opts.signal?.aborted === true) throw opts.signal.reason + if (!(error instanceof ElectronLaunchError)) throw error + if (error.why === "site" && error.site !== undefined) return { site: error.site } + if (error.why === "quit") return { quit: true } + return { failed: error.message } + } +} diff --git a/packages/runtime/src/electron.ts b/packages/runtime/src/electron.ts index b28e6fa..4f32df8 100644 --- a/packages/runtime/src/electron.ts +++ b/packages/runtime/src/electron.ts @@ -98,8 +98,23 @@ function timing(phase: string, since: number) { const QUIT_EARLY = "the app quit before Kiframe could attach (it may allow only one instance: quit it and try again; be a launcher for another program; or refuse automation)" -/** An app that couldn't be launched or attached: said to the user as is. */ -export class ElectronLaunchError extends Error {} +/** + * An app that couldn't be launched or attached: said to the user as is. `why` read structurally (a + * trial's outcome): it quit at once, its main window showed a site (`site`: the https origin it + * showed or failed to load: a wrapper's own, to allow), or anything else. + */ +export class ElectronLaunchError extends Error { + readonly why: "quit" | "site" | "other" + readonly site: string | undefined + constructor( + message: string, + detail: { why: "quit" | "other" } | { why: "site"; site: string } = { why: "other" }, + ) { + super(message) + this.why = detail.why + this.site = detail.why === "site" ? detail.site : undefined + } +} export interface ElectronLaunch { /** The app's executable (`X.app/Contents/MacOS/X`), resolved and approved by the host. */ @@ -114,18 +129,6 @@ export interface ElectronLaunch { workDir?: string /** The network: "all" (a run: the app's backend), "loopback" (a trial: nothing beyond). */ network?: "all" | "loopback" - /** - * Tests: arguments given as they are, before `args` (the fixture app's folder, its modes): never - * a project's (those name files/ only). - */ - appArgs?: readonly string[] - /** Tests: more it may read (the repo's Electron). */ - readable?: readonly string[] - /** - * Off macOS only (Linux CI): launched without the Seatbelt confinement (there's none). Never in - * the app: desktop apps are macOS-only. - */ - allowUnconfined?: boolean /** The size its windows are shown at (emulated: the window itself is never moved). */ viewport: Pick /** The https sites a wrapper app shows as its own (the project's `origins`). */ @@ -140,6 +143,34 @@ export interface ElectronLaunch { timeoutMs?: number } +/** Tests only (never the package's: its launches take none of these). */ +export interface LaunchHooks { + /** + * Arguments given as they are, before `args` (the fixture app's folder, its modes): never a + * project's (those name files/ only). + */ + appArgs?: readonly string[] + /** More it may read (the repo's Electron). */ + readable?: readonly string[] + /** Off macOS only (Linux CI): launched without the Seatbelt confinement (there's none). */ + allowUnconfined?: boolean +} + +/** Where a page that failed to load was going (Chromium's error page keeps it, over CDP). */ +async function unreachableUrl(page: Page): Promise { + try { + const cdp = await page.context().newCDPSession(page) + try { + const { frameTree } = await cdp.send("Page.getFrameTree") + return frameTree.frame.unreachableUrl + } finally { + await cdp.detach().catch(() => undefined) + } + } catch { + return undefined + } +} + /** A page the guard stopped: in which window, whether it was the window itself, and where. */ export interface GuardStop { page: Page @@ -233,6 +264,9 @@ async function makeSandbox(work: string): Promise<{ XDG_CACHE_HOME: dirs.cache, XDG_STATE_HOME: dirs.state, TMPDIR: `${dirs.tmp}${sep}`, + // Chromium on macOS: its temp folder (the single-instance lock's socket) in the sandbox too, + // never the user's /var/folders (denied by the confinement: such an app would quit). + MAC_CHROMIUM_TMPDIR: dirs.tmp, }) return { root, @@ -247,8 +281,16 @@ async function makeSandbox(work: string): Promise<{ /** How long the app's windows must stay as they are before its main window is chosen. */ const SETTLE_MS = 1500 -/** Launches `opts.executable` in a fresh sandbox and attaches to its main window. */ -export async function launchElectron(opts: ElectronLaunch): Promise { +/** Launches `opts.executable` in a fresh sandbox, confined, and attaches to its main window. */ +export function launchElectron(opts: ElectronLaunch): Promise { + return launchElectronWith(opts, {}) +} + +/** `launchElectron` with the tests' hooks (not exported by the package). */ +export async function launchElectronWith( + opts: ElectronLaunch, + hooks: LaunchHooks, +): Promise { opts.signal?.throwIfAborted() // Before anything runs: an app moved since it was approved is said, never half-launched. let bundles: string[] @@ -267,7 +309,7 @@ export async function launchElectron(opts: ElectronLaunch): Promise allowedPage(url, own) - if (!allows(page.url())) { + // A load that failed (no network in a trial) still names where it went (CDP's unreachable + // URL): judged by that, a wrapper's site said even unreached. + const shown = page.url().startsWith("chrome-error:") + ? ((await unreachableUrl(page)) ?? page.url()) + : page.url() + if (!allows(shown)) { + const url = URL.parse(shown) throw new ElectronLaunchError( - `the app shows ${placeOf(page.url())}: if that's the app's own, list it in its origins`, + `the app shows ${placeOf(shown)}: if that's the app's own, list it in its origins`, + url?.protocol === "https:" ? { why: "site", site: url.origin } : { why: "other" }, ) } // The guard: every frame of every window, every navigation, at once; one that isn't the app's @@ -487,7 +536,8 @@ export async function launchElectron(opts: ElectronLaunch): Promise finish(new ElectronLaunchError(QUIT_EARLY)) + const exited = () => finish(new ElectronLaunchError(QUIT_EARLY, { why: "quit" })) const aborted = () => finish(signal?.reason as Error) child.once("exit", exited) signal?.addEventListener("abort", aborted, { once: true }) @@ -585,7 +635,7 @@ async function mainWindow( for (;;) { signal?.throwIfAborted() // Quit (or dropped its port) before showing a window: said at once, never a wait to the end. - if (gone()) throw new ElectronLaunchError(QUIT_EARLY) + if (gone()) throw new ElectronLaunchError(QUIT_EARLY, { why: "quit" }) const now = windows() const key = now.map((p) => p.url()).join("\n") if (key !== seen) { diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index f762c97..1e670d4 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -16,6 +16,16 @@ export { launchElectron, } from "./electron.ts" export { defaultWorkDir, sweepWorkArea, WorkAreaError } from "./electron-workarea.ts" +export { + codeDigest, + type DesktopApp, + inspectDesktopApp, + InspectError, + type Signer, + signatureHolds, + signerOf, +} from "./electron-inspect.ts" +export { trialDesktopApp, type TrialOptions, type TrialOutcome } from "./electron-trial.ts" export { ConfinementError, FILES_LIMITS, seatbeltProfile } from "./electron-confine.ts" export { LookRefusal, maskedScreenshot, type MaskedShot } from "./look.ts" export { diff --git a/packages/runtime/test/electron-inspect.test.ts b/packages/runtime/test/electron-inspect.test.ts new file mode 100644 index 0000000..54b48fd --- /dev/null +++ b/packages/runtime/test/electron-inspect.test.ts @@ -0,0 +1,201 @@ +import { execFileSync } from "node:child_process" +import { + cpSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs" +import { createRequire } from "node:module" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { + codeDigest, + inspectDesktopApp, + parseSigning, + placeRefusal, + teamRequirement, +} from "../src/electron-inspect.ts" +import { trialDesktopApp } from "../src/electron-trial.ts" + +// Inspecting a desktop app the user picks (PR 3b): real bundles built from the repo's Electron (a +// clone, the fixture as its app, re-signed ad hoc: no hook in them), refused shapes beside. + +const electron = createRequire(join(import.meta.dirname, "../../../apps/desktop/package.json"))( + "electron", +) as string +const electronApp = electron.slice(0, electron.indexOf(".app/") + 4) +const fixture = join(import.meta.dirname, "fixtures/electron-app") + +describe("where an app can't stay", () => { + it("refuses what isn't an app, an app inside another, disk images, translocation, the Trash", () => { + expect(placeRefusal("/Applications/Slack.app")).toBeUndefined() + expect(placeRefusal("/Users/me/Apps/Tool.app")).toBeUndefined() + expect(placeRefusal("/Applications/Slack")).toMatch(/isn't an app/) + expect(placeRefusal("/Applications/Outer.app/Contents/Inner.app")).toMatch(/inside another/) + expect(placeRefusal("/Volumes/Slack/Slack.app")).toMatch(/disk image/) + expect(placeRefusal("/private/var/folders/x/y/T/AppTranslocation/ABC/d/Slack.app")).toMatch( + /temporary copy/, + ) + expect(placeRefusal("/Users/me/.Trash/Slack.app")).toMatch(/Trash/) + }) +}) + +describe("a signature", () => { + it("reads unsigned, ad hoc and a developer's from codesign", () => { + expect(parseSigning(1, "code object is not signed at all")).toEqual({ kind: "unsigned" }) + expect( + parseSigning(0, "Identifier=Electron\nSignature=adhoc\nTeamIdentifier=not set\n"), + ).toEqual({ + kind: "adhoc", + }) + expect( + parseSigning(0, "Identifier=com.tinyspeck.slackmacgap\nTeamIdentifier=BQR82RBBHL\n"), + ).toEqual({ kind: "team", team: "BQR82RBBHL", identifier: "com.tinyspeck.slackmacgap" }) + }) + + it("requires the developer's team and the signed identifier, quoted", () => { + expect(teamRequirement("BQR82RBBHL", 'a"b')).toBe( + 'anchor apple generic and identifier "a\\"b" and certificate leaf[subject.OU] = "BQR82RBBHL"', + ) + }) +}) + +describe.runIf(process.platform === "darwin")("an app's inspection", { timeout: 60_000 }, () => { + let root: string + let base: string + // A real bundle: Electron cloned, the fixture as its app, its id and name its own, re-signed. + const bundle = (name: string, change?: (app: string) => void, sign: string[] = []) => { + const app = join(mkdtempSync(join(root, "app-")), `${name}.app`) + execFileSync("cp", ["-cR", base, app]) + change?.(app) + execFileSync("codesign", ["-s", "-", "-f", "--deep", ...sign, app], { stdio: "ignore" }) + return app + } + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), "kiframe-el-inspect-")) + base = join(root, "Base.app") + execFileSync("cp", ["-cR", electronApp, base]) + cpSync(fixture, join(base, "Contents/Resources/app"), { recursive: true }) + const plist = join(base, "Contents/Info.plist") + execFileSync("plutil", [ + "-replace", + "CFBundleIdentifier", + "-string", + "com.kiframe.fixture", + plist, + ]) + for (const key of ["CFBundleName", "CFBundleDisplayName"]) { + execFileSync("plutil", ["-replace", key, "-string", "Kiframe Fixture", plist]) + } + }, 120_000) + + afterAll(() => { + if (root !== undefined) rmSync(root, { recursive: true, force: true }) + }) + + it("keeps an Electron app's id, name, versions and executable, pinned to its build", async () => { + const app = bundle("Fixture") + const seen = await inspectDesktopApp(app) + expect(seen).toMatchObject({ + bundleId: "com.kiframe.fixture", + name: "Kiframe Fixture", + electron: "44.4.5", + executable: join(seen.path, "Contents/MacOS/Electron"), + signer: { kind: "pinned" }, + }) + // Any change to the code it runs: another build (its app, its framework). + const edited = bundle("Edited", (a) => + writeFileSync(join(a, "Contents/Resources/app/index.html"), "

changed

"), + ) + const digest = (await inspectDesktopApp(edited)).signer + expect(digest).not.toEqual(seen.signer) + expect(await codeDigest(seen.path, seen.executable)).toBe( + seen.signer.kind === "pinned" ? seen.signer.digest : "", + ) + }) + + it("refuses an app that isn't Electron, a Mac App Store build, an executable that's a link", async () => { + const notElectron = bundle("Plain", (a) => + rmSync(join(a, "Contents/Frameworks/Electron Framework.framework"), { + recursive: true, + force: true, + }), + ) + await expect(inspectDesktopApp(notElectron)).rejects.toThrow(/Electron apps only/) + const entitlements = join(root, "sandbox.plist") + writeFileSync( + entitlements, + 'com.apple.security.app-sandbox', + ) + // Inspected only, never launched (its own sandbox would crash it). + const store = bundle("Store", undefined, ["--entitlements", entitlements]) + await expect(inspectDesktopApp(store)).rejects.toThrow(/Mac App Store build/) + // Swapped after signing (codesign won't sign it so). + const linked = bundle("Linked") + const exe = join(linked, "Contents/MacOS/Electron") + rmSync(exe) + symlinkSync("/bin/sh", exe) + await expect(inspectDesktopApp(linked)).rejects.toThrow(/executable isn't its own/) + }) + + it("refuses an app inside another app", async () => { + const outer = join(mkdtempSync(join(root, "outer-")), "Outer.app") + mkdirSync(join(outer, "Contents"), { recursive: true }) + const inner = join(outer, "Contents", "Inner.app") + execFileSync("cp", ["-cR", bundle("Inner"), inner]) + await expect(inspectDesktopApp(inner)).rejects.toThrow(/inside another app/) + await expect(inspectDesktopApp(join(root, "Gone.app"))).rejects.toThrow(/can't be read/) + }) + + // A bundle whose app runs the fixture in a mode (a shim as its main: no argument from Kiframe). + const withMode = (name: string, mode: string) => + bundle(name, (a) => { + const dir = join(a, "Contents/Resources/app") + writeFileSync( + join(dir, "mode.cjs"), + `process.argv.push(${JSON.stringify(mode)}, "hidden")\nrequire("./main.cjs")\n`, + ) + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name: "kiframe-fixture-electron", main: "mode.cjs" }), + ) + }) + + it("tries an app confined: driven, a wrapper's site named, one that quits said", async () => { + const work = mkdtempSync(join(tmpdir(), "kiframe-el-work-")) + const app = await inspectDesktopApp(withMode("Notes", "plain")) + expect(await trialDesktopApp(app, { workDir: work })).toEqual({ ok: true }) + const wrapper = await inspectDesktopApp(withMode("Wrapper", "wrapper")) + // No network in a trial: the site is named all the same (where its window failed to go). + expect(await trialDesktopApp(wrapper, { workDir: work })).toEqual({ + site: "https://kiframe-wrapper.invalid", + }) + expect( + await trialDesktopApp(wrapper, { + workDir: work, + origins: ["https://kiframe-wrapper.invalid"], + }), + ).toEqual({ ok: true }) + const quits = await inspectDesktopApp(withMode("Quits", "quit-at-once")) + expect(await trialDesktopApp(quits, { workDir: work })).toEqual({ quit: true }) + // Nothing left of any trial. + expect(readdirSync(join(work, "sandboxes"))).toEqual([]) + }) + + it("throws a stop as the stop", async () => { + const app = await inspectDesktopApp(withMode("Stopped", "plain")) + const stopping = new AbortController() + stopping.abort() + await expect( + trialDesktopApp(app, { + workDir: mkdtempSync(join(tmpdir(), "kiframe-el-work-")), + signal: stopping.signal, + }), + ).rejects.toMatchObject({ name: "AbortError" }) + }) +}) diff --git a/packages/runtime/test/electron.test.ts b/packages/runtime/test/electron.test.ts index fe631d2..65111f0 100644 --- a/packages/runtime/test/electron.test.ts +++ b/packages/runtime/test/electron.test.ts @@ -29,7 +29,6 @@ import { ElectronLaunchError, type ElectronTarget, FILES_LIMITS, - launchElectron, recordScenario, runScenario, StepError, @@ -37,6 +36,7 @@ import { sweepWorkArea, } from "../src/index.ts" import { checkConfinement, copyFiles } from "../src/electron-confine.ts" +import { type ElectronLaunch, launchElectronWith, type LaunchHooks } from "../src/electron.ts" import { sandboxesOf } from "../src/electron-workarea.ts" // The Electron target (OBJECT-MODEL §0.9 "Electron apps"): a fixture desktop app launched in a @@ -55,6 +55,10 @@ const sandboxCount = () => /** This file's work area (never the user's ~/.kiframe). */ const work = mkdtempSync(join(tmpdir(), "kiframe-el-work-")) /** How every launch here runs: the fixture on the repo's Electron, confined on macOS. */ +// The tests' launches: the hooks (the fixture's folder and modes, the repo's Electron readable, +// unconfined on Linux CI) beside a launch's own options. +const launchElectron = (opts: ElectronLaunch & LaunchHooks) => launchElectronWith(opts, opts) + const base = { executable: electron, bundle: fixture, @@ -152,6 +156,8 @@ const launch = async ( readable?: string[] timeoutMs?: number signal?: AbortSignal + network?: "all" | "loopback" + origins?: string[] } = {}, ) => { const target = await launchElectron({ @@ -478,10 +484,29 @@ steps: } }, 60_000) + it("launches an app that allows one copy only (its lock in the sandbox, never /var/folders)", async () => { + const target = await launch(["single"]) + expect(await target.page.title()).toBe("Fixture notes") + }) + + it("names the site a wrapper app shows, even when it can't load it (a trial's loopback)", async () => { + const error = await launch(["wrapper"], { network: "loopback" }).then( + () => undefined, + (e: unknown) => e as ElectronLaunchError, + ) + expect(error).toBeInstanceOf(ElectronLaunchError) + expect(error?.why).toBe("site") + expect(error?.site).toBe("https://kiframe-wrapper.invalid") + // Listed as its own: it's driven (its page the browser's error page: no network here). + const target = await launch(["wrapper"], { origins: ["https://kiframe-wrapper.invalid"] }) + expect(target.page).toBeDefined() + }) + it("says an app that quits at once, and leaves nothing behind", async () => { const before = sandboxCount() await expect(launch(["quit-at-once"])).rejects.toThrow(ElectronLaunchError) await expect(launch(["quit-at-once"])).rejects.toThrow(/quit before Kiframe could attach/) + await expect(launch(["quit-at-once"])).rejects.toMatchObject({ why: "quit" }) expect(sandboxCount()).toBe(before) }) diff --git a/packages/runtime/test/fixtures/crash-launch.ts b/packages/runtime/test/fixtures/crash-launch.ts index 46cd301..c0f2fd6 100644 --- a/packages/runtime/test/fixtures/crash-launch.ts +++ b/packages/runtime/test/fixtures/crash-launch.ts @@ -1,6 +1,6 @@ // A Kiframe that launches a desktop app and is then killed (the test's crash): it prints the // launch's sandbox and waits. Args: . -import { launchElectron } from "../../src/electron.ts" +import { launchElectronWith } from "../../src/electron.ts" const [workDir, executable, bundle, readable, app] = process.argv.slice(2) as [ string, @@ -9,14 +9,14 @@ const [workDir, executable, bundle, readable, app] = process.argv.slice(2) as [ string, string, ] -const target = await launchElectron({ - executable, - bundle, - readable: [readable], - workDir, - allowUnconfined: true, - appArgs: [app, "hidden"], - viewport: { width: 800, height: 600, deviceScaleFactor: 1 }, -}) +const target = await launchElectronWith( + { + executable, + bundle, + workDir, + viewport: { width: 800, height: 600, deviceScaleFactor: 1 }, + }, + { readable: [readable], allowUnconfined: true, appArgs: [app, "hidden"] }, +) process.stdout.write(`${target.sandbox}\n`) setInterval(() => undefined, 60_000) diff --git a/packages/runtime/test/fixtures/electron-app/main.cjs b/packages/runtime/test/fixtures/electron-app/main.cjs index ce4eea1..fd6105a 100644 --- a/packages/runtime/test/fixtures/electron-app/main.cjs +++ b/packages/runtime/test/fixtures/electron-app/main.cjs @@ -19,7 +19,12 @@ if (splash) if (process.argv.includes("trust-test-cert")) app.commandLine.appendSwitch("ignore-certificate-errors") -if (process.argv.includes("quit-at-once")) { +// "single": it allows one copy only (as Slack, VS Code, Discord): Chromium's lock, a unix socket +// in its temp folder. +if ( + process.argv.includes("quit-at-once") || + (process.argv.includes("single") && !app.requestSingleInstanceLock()) +) { app.quit() } else { app.whenReady().then(() => { @@ -84,7 +89,9 @@ if (process.argv.includes("quit-at-once")) { // "embed-at-start": it shows that site in a frame as it opens. const link = process.argv.find((a) => a.startsWith("link="))?.slice(5) ?? "https://example.com/" const query = { link, ...(process.argv.includes("embed-at-start") && { embed: "1" }) } - if (process.argv.includes("elsewhere")) win.loadURL("data:text/html,

Elsewhere

") + // "wrapper": its window is a site (as Slack's app.slack.com), one that never loads (.invalid). + if (process.argv.includes("wrapper")) win.loadURL("https://kiframe-wrapper.invalid/client") + else if (process.argv.includes("elsewhere")) win.loadURL("data:text/html,

Elsewhere

") else win.loadFile(join(__dirname, "index.html"), { query }) }) app.on("window-all-closed", () => app.quit()) diff --git a/vitest.config.ts b/vitest.config.ts index b2c4c7f..e623c69 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,7 @@ import { configDefaults, defineConfig } from "vitest/config" // The desktop-app target's tests launch a real Electron app each: they run alone, after every other // suite (a CPU busy with them made timing-sensitive tests elsewhere flaky in CI). -const ELECTRON = "packages/runtime/test/electron.test.ts" +const ELECTRON = "packages/runtime/test/electron*.test.ts" export default defineConfig({ test: { From 01ffb4553407a17f5e722674f203d57b9fec0d84 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Fri, 9 Oct 2026 17:15:57 +0200 Subject: [PATCH 02/10] fix(runtime): steady a desktop app's inspection and trial - a pinned build's digest covers the whole bundle (helpers, frameworks) - codesign that fails or times out refuses the app, never read as unsigned or unsandboxed; empty names fall back - a trial runs with the network (an updater can't touch the real app): a wrapper's offline page no longer hides its site - a main window that never loaded its own page is refused at launch, said as such (a dev server not running included); a hung attach is retried - one command helper; the macOS CI job installs ffmpeg --- .github/workflows/ci.yml | 2 + docs/OBJECT-MODEL.md | 2 +- packages/runtime/src/electron-inspect.ts | 125 +++++++++--------- packages/runtime/src/electron-trial.ts | 15 ++- packages/runtime/src/electron-workarea.ts | 25 +++- packages/runtime/src/electron.ts | 46 +++++-- .../runtime/test/electron-inspect.test.ts | 35 ++++- packages/runtime/test/electron.test.ts | 23 +++- .../test/fixtures/electron-app/main.cjs | 11 +- 9 files changed, 189 insertions(+), 95 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7eea6bb..2022033 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,8 @@ jobs: node-version-file: .nvmrc cache: pnpm - run: pnpm install --frozen-lockfile + # A desktop app's takes are encoded with ffmpeg (as on Linux). + - run: brew install ffmpeg - run: pnpm exec vitest run --project electron env: KIFRAME_ELECTRON_TIMING: "1" diff --git a/docs/OBJECT-MODEL.md b/docs/OBJECT-MODEL.md index e472236..4497c0e 100644 --- a/docs/OBJECT-MODEL.md +++ b/docs/OBJECT-MODEL.md @@ -230,7 +230,7 @@ One scene, three phases. The agent's own instructions say the same (`packages/st - **Confinement: a guard, sealed at launch (PR 2, redesigned after three review rounds, design reviewed).** Once the app settles (its windows unchanged 1.5 s), its main window is the last opened still open; its own scheme or dev server is learned from that window alone, then sealed (nothing a run does adds to it). A main window that isn't the app's own (a wrapper's unlisted site) refuses the launch ("list it in its origins"). From then a **guard** checks every navigation of every frame of every window as it happens: a page that isn't the app's own is stopped at once (a popup closed, any other frame blanked: never left for a read or a frame) and recorded; the step fails `off-app`. The app's own: a local `file:` in its bundle or sandbox (decoded, normalized), its sealed scheme or dev server, a listed https site (`sameApp`, never plain http), a blob of those. Stated (as a web run): the guard acts as a page commits, so a page that isn't the app's own has loaded (its scripts ran, with the app's own rights) before it's stopped: following a link the app offers is what its user does too (blocking before the load needs request interception, which never sees `file:`); and the runner's own navigation events name it as they do for web runs (origin and path, known secret values scrubbed). What the main window embeds as it opens (a frame of another site) is the app as shipped: its origin is the app's own. A main window stopped goes back to the app's page (no goto there to bring it back); a failed load is said as one. **One app per run:** a desktop app's run never shows a site (no goto, URL condition or web preset; a web interrupt rule's goto skipped, said), is confined while grounding too, and keeps no sign-in between runs yet. - **Windows (PR 2, known limits, to settle on real apps in PR 3 and PR 5: decided by the user 2026-10-08):** the main window is the last opened still open once the app's windows were unchanged for 1.5 s (a splash that stays up longer is taken for the main window); windows the app's main process opens are followed as popups are (a hidden helper window opened during a step may be followed: CDP can't tell it's hidden); only the launch window is sent back when it goes off-app (another window is closed); a stop that lands after its step ended counts against the next step. A window that opens blank gets a moment to load (as a web page does); a frame of a window the run isn't driving is said, never the step's failure. - **Every launch confined (PR 3a, 2026-10-09, design reviewed twice, measured):** on macOS each launch runs under Seatbelt (`sandbox-exec`, `electron-confine.ts`): writes only into its own sandbox; no reads of /Users, /Volumes, the temp folders, the user's home and the work area wherever they are (a home outside /Users) but its sandbox and its bundle; nothing run from its sandbox (the one place it writes: a program it drops there, a copied launcher), no `open`, `osascript`, `launchctl` nor Apple Events; no unix sockets (an agent's: ssh, Docker) but mDNSResponder; no preferences (cfprefsd), keychain (SecurityServer: a mock keychain instead, `--use-mock-keychain`) nor clipboard (the user's may hold a password just copied: an app's own copy and paste doesn't work in a run); network for runs (the app's backend), loopback only for a trial (no updater). Chromium's own sandbox can't nest in it (`--no-sandbox`): the guard and this profile replace it. A canary before every launch, before the files are copied (a read the profile refuses must fail, one of its sandbox succeed: a confinement that runs nothing never passes); no `sandbox-exec`, or a canary failed: no launch (never unconfined). Off macOS: desktop apps don't launch (CI's tests only, unconfined). Measured with a probe app: every escape above `EPERM` (a hard link or a symlink to an outside file too), the app driven as usual. This replaces the trial's after-the-fact isolation check, rejected by review (an app that leaks does its harm as it starts). **What it doesn't stop (stated):** an app can still ask macOS to open a file or a URL (LaunchServices: no app starts without reaching it, measured), and what opens runs outside the confinement; it reaches services on this machine (localhost: a dev build's own server needs it). The app's own pages are its bundle and what it writes in its sandbox's home, profile and temp folders (a page it builds there from remote content counts as its own, as its cache already does); never the files/ copy. The confinement keeps an app from the user's data by accident (the minmux incident), not an app built to escape (one the user runs unconfined every day anyway). -- **Adding a desktop app (PR 3b, design reviewed 2026-10-09):** inspected first, statically (`electron-inspect.ts`: only `plutil` and `codesign` run): refused outside where an app can stay (a disk image, a temporary translocated copy, the Trash, inside another app), not Electron (its framework; no version gate: Figma reports 13.6.1), an executable that isn't its own file, a Mac App Store build (App Sandbox), a developer signature that doesn't hold (`codesign --verify --strict` against its team and signed identifier). Kept: its id, name, Electron's version, and its signer: a developer (team id) or, ad hoc and unsigned (the user's own builds), pinned to a digest of the code it runs (executable, Info.plist, Electron's framework, app.asar and what's unpacked, `Resources/app`). Then **tried confined** (`electron-trial.ts`): launched with no network beyond this machine (no updater, no backend), its main window attached, closed; a wrapper's site is named even unreached (the error page's unreachable URL, over CDP) and allowed by the user in a second trial with the network. Apps that allow one copy (Slack, VS Code, Discord) need Chromium's temp folder in the sandbox (`MAC_CHROMIUM_TMPDIR`): its lock's socket otherwise goes to the user's /var/folders, denied, and the app quits. +- **Adding a desktop app (PR 3b, design reviewed 2026-10-09):** inspected first, statically (`electron-inspect.ts`: only `plutil` and `codesign` run): refused outside where an app can stay (a disk image, a temporary translocated copy, the Trash, inside another app), not Electron (its framework; no version gate: Figma reports 13.6.1), an executable that isn't its own file, a Mac App Store build (App Sandbox), a developer signature that doesn't hold (`codesign --verify --strict` against its team and signed identifier). Kept: its id, name, Electron's version, and its signer: a developer (team id) or, ad hoc and unsigned (the user's own builds), pinned to a digest of the code it runs (executable, Info.plist, Electron's framework, app.asar and what's unpacked, `Resources/app`). Then **tried confined** (`electron-trial.ts`): launched with the network as in a run (an updater can't touch the real app: nothing outside the sandbox is written; a wrapper's offline page can't hide its site), its main window attached, closed; a wrapper's site is named (even unreached: the error page's unreachable URL, over CDP) and allowed by the user in a second trial. A main window that never loaded its own page (offline, a dev server not running) is refused at launch, said as such: never a window driven on Chromium's error page. A pinned build's digest covers the whole bundle (helpers, frameworks, links as links). codesign that fails or times out refuses the app (never read as unsigned or unsandboxed). Apps that allow one copy (Slack, VS Code, Discord) need Chromium's temp folder in the sandbox (`MAC_CHROMIUM_TMPDIR`): its lock's socket otherwise goes to the user's /var/folders, denied, and the app quits. - **The work area `~/.kiframe/`** (decided by the user 2026-10-09: deletable any time): `sandboxes/` (one per launch, 0700, never a link), each named after the Kiframe that made it (`kiframe-app---…`: owned from the moment it exists, no shared state file to race on); a crash sweep at start removes only sandboxes no running Kiframe owns (the processes pointing at them killed first), never another build's launch, and nothing when `ps` can't be read. Kept out of Time Machine and Spotlight. Approvals stay in the app's settings, takes in app data. - **A project's `files/`** (decided by the user 2026-10-09): what a desktop app opens. Copied into the sandbox at each launch by Kiframe's own walk (folders and regular files only: a link, a FIFO, a device refused; 500 MB, 20,000 files, depth 32; cloned where the disk can, times kept); `args` name paths in it only (`files/vault`, given as the copy's path); the app's edits stay in the copy (thrown away: every run from the same files). The copy is the project's content, never the app's own pages for the guard. Filled by the user in v0: the agent's file tools never reach `files/` (it may hold what runs inside the app: an editor's tasks, a vault's plugins). - **Measured on a fixture app (PR 2, 2026-10-08):** HOME, XDG and `--user-data-dir` hold; macOS's own places don't move: Electron's `appData` (`~/Library/Application Support`) and the app's preferences (`NSUserDefaults`, written by `cfprefsd` into the user's real `~/Library/Preferences/.plist`) stay the user's whatever `HOME` or `CFFIXED_USER_HOME` say. Hence the confinement (PR 3a, below): the preferences daemon is denied and writes outside the sandbox refused, so neither can be reached (a check after launch, the earlier plan, came too late). diff --git a/packages/runtime/src/electron-inspect.ts b/packages/runtime/src/electron-inspect.ts index 9f33ed9..ae987ff 100644 --- a/packages/runtime/src/electron-inspect.ts +++ b/packages/runtime/src/electron-inspect.ts @@ -1,8 +1,8 @@ -import { execFile } from "node:child_process" import { createHash } from "node:crypto" import { constants, createReadStream } from "node:fs" import { access, lstat, readdir, readlink, realpath } from "node:fs/promises" import { basename, join } from "node:path" +import { command as run } from "./electron-workarea.ts" // A desktop app the user picks, inspected before Kiframe ever runs it (PR 3b, design reviewed // 2026-10-09): only plutil and codesign run, nothing of the app. What's refused is said as is; @@ -34,23 +34,8 @@ export class InspectError extends Error {} const BUNDLE_ID = /^[A-Za-z][A-Za-z0-9-]*(\.[A-Za-z0-9-]+)+$/ -/** A command's exit code and output (the C locale: read the same in every language). */ -function command( - cmd: string, - args: string[], -): Promise<{ code: number; stdout: string; stderr: string }> { - return new Promise((resolve) => { - execFile( - cmd, - args, - { timeout: 30_000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } }, - (error, stdout, stderr) => { - const code = error === null ? 0 : typeof error.code === "number" ? error.code : -1 - resolve({ code, stdout, stderr }) - }, - ) - }) -} +/** codesign on a large bundle (a verify reads it all) can take a while: never forever. */ +const TIMEOUT = 60_000 /** Why an app can't be used from where it is (its real path), or undefined. */ export function placeRefusal(real: string): string | undefined { @@ -69,16 +54,26 @@ export function placeRefusal(real: string): string | undefined { /** A value of an Info.plist (undefined when it has none), read by plutil. */ async function plistValue(plist: string, key: string): Promise { - const out = await command("/usr/bin/plutil", ["-extract", key, "raw", "-o", "-", plist]) - return out.code === 0 ? out.stdout.trim() : undefined + const out = await run("/usr/bin/plutil", ["-extract", key, "raw", "-o", "-", plist], TIMEOUT) + // An empty value is no value (a build with an empty name: its fallback instead). + const value = out.code === 0 ? out.stdout.trim() : "" + return value === "" ? undefined : value } -/** What `codesign -dv` says of a signature: unsigned, ad hoc, or a developer's. */ +/** + * What `codesign -dv` says of a signature: unsigned (it says so), ad hoc, a developer's, or unread + * (it failed otherwise, timed out: never taken for "unsigned"). + */ export function parseSigning( code: number, stderr: string, -): { kind: "unsigned" } | { kind: "adhoc" } | { kind: "team"; team: string; identifier: string } { - if (code !== 0) return { kind: "unsigned" } +): + | { kind: "unsigned" } + | { kind: "adhoc" } + | { kind: "team"; team: string; identifier: string } + | { kind: "unread" } { + if (code !== 0) + return /not signed at all/.test(stderr) ? { kind: "unsigned" } : { kind: "unread" } const field = (name: string) => new RegExp(`^${name}=(.*)$`, "m").exec(stderr)?.[1]?.trim() const team = field("TeamIdentifier") const identifier = field("Identifier") @@ -97,17 +92,16 @@ export function teamRequirement(team: string, identifier: string): string { /** Whether the bundle's signature is whole and its developer's (`codesign --verify --strict`). */ export async function signatureHolds(path: string, signer: Signer): Promise { if (signer.kind !== "team") return true - const out = await command("/usr/bin/codesign", [ - "--verify", - "--strict", - `-R=${teamRequirement(signer.team, signer.identifier)}`, - path, - ]) + const out = await run( + "/usr/bin/codesign", + ["--verify", "--strict", `-R=${teamRequirement(signer.team, signer.identifier)}`, path], + TIMEOUT, + ) return out.code === 0 } /** The code an unsigned app runs, hashed: what pins the exact build (any change: a new digest). */ -export async function codeDigest(path: string, executable: string): Promise { +export async function codeDigest(path: string): Promise { const hash = createHash("sha256") const add = async (rel: string) => { const full = join(path, rel) @@ -126,27 +120,25 @@ export async function codeDigest(path: string, executable: string): Promise { - const signing = await command("/usr/bin/codesign", ["-dv", path]) +/** + * The signer of an app at `path`, and whether it's signed at all (an unsigned one has no + * entitlements to read). Unread: refused (never taken for unsigned). + */ +export async function signerOf(path: string): Promise { + const signing = await run("/usr/bin/codesign", ["-dv", path], TIMEOUT) const parsed = parseSigning(signing.code, signing.stderr) - if (parsed.kind === "team") return parsed - return { kind: "pinned", digest: await codeDigest(path, executable) } + if (parsed.kind === "unread") throw new InspectError("that app's signature couldn't be read") + if (parsed.kind === "team") return { ...parsed, signed: true } + return { + kind: "pinned", + digest: await codeDigest(path), + signed: parsed.kind === "adhoc", + } } /** @@ -187,31 +179,38 @@ export async function inspectDesktopApp(picked: string): Promise { } catch { throw new InspectError("that app's executable isn't its own runnable file") } - const entitlements = await command("/usr/bin/codesign", [ - "-d", - "--entitlements", - "-", - "--xml", - path, - ]) - if (/com\.apple\.security\.app-sandbox<\/key>\s*/.test(entitlements.stdout)) { - throw new InspectError( - "that's a Mac App Store build (it has its own sandbox, which can't run in Kiframe's)", + const { signed, ...signer } = await signerOf(path) + // A signed app's entitlements, read (unread: refused, never taken for "not sandboxed"); an + // unsigned one has none. + if (signed) { + const entitlements = await run( + "/usr/bin/codesign", + ["-d", "--entitlements", "-", "--xml", path], + TIMEOUT, ) + if (entitlements.code !== 0) { + throw new InspectError("that app's entitlements couldn't be read") + } + if (/com\.apple\.security\.app-sandbox<\/key>\s*/.test(entitlements.stdout)) { + throw new InspectError( + "that's a Mac App Store build (it has its own sandbox, which can't run in Kiframe's)", + ) + } } - const signer = await signerOf(path, executable) if (!(await signatureHolds(path, signer))) { throw new InspectError("that app's signature is broken: reinstall it") } - const name = - (await plistValue(plist, "CFBundleDisplayName")) ?? - (await plistValue(plist, "CFBundleName")) ?? - basename(path, ".app") + const [display, short, version] = await Promise.all( + ["CFBundleDisplayName", "CFBundleName", "CFBundleShortVersionString"].map((key) => + plistValue(plist, key), + ), + ) + const name = display ?? short ?? basename(path, ".app") return { path, bundleId, name, - version: await plistValue(plist, "CFBundleShortVersionString"), + version, electron, executable, signer, diff --git a/packages/runtime/src/electron-trial.ts b/packages/runtime/src/electron-trial.ts index d169f89..ecaa9de 100644 --- a/packages/runtime/src/electron-trial.ts +++ b/packages/runtime/src/electron-trial.ts @@ -9,7 +9,7 @@ import type { DesktopApp } from "./electron-inspect.ts" export type TrialOutcome = { ok: true } | { site: string } | { quit: true } | { failed: string } export interface TrialOptions { - /** Sites it shows as its own (a second trial, after the first named one): the network then. */ + /** Sites it shows as its own (a second trial, after the first named one). */ origins?: readonly string[] workDir?: string signal?: AbortSignal @@ -18,9 +18,8 @@ export interface TrialOptions { } /** - * An app tried before it's added (PR 3b): launched confined, without the network beyond this - * machine unless sites are listed (no updater, no backend: what it shows is its own), its main - * window attached, then closed. A stop is thrown as the stop. + * An app tried before it's added (PR 3b): launched confined, its main window attached, then + * closed. A stop is thrown as the stop. */ export async function trialDesktopApp( app: Pick, @@ -31,7 +30,9 @@ export async function trialDesktopApp( const target = await launchElectron({ executable: app.executable, bundle: app.path, - network: origins.length > 0 ? "all" : "loopback", + // The network as in a run (its site shown even behind its own offline page): an updater + // can't touch the real app all the same (nothing outside the sandbox is written). + network: "all", origins, viewport: opts.viewport ?? { width: 1440, height: 900, deviceScaleFactor: 1 }, ...(opts.workDir !== undefined && { workDir: opts.workDir }), @@ -42,7 +43,9 @@ export async function trialDesktopApp( return { ok: true } } catch (error) { if (opts.signal?.aborted === true) throw opts.signal.reason - if (!(error instanceof ElectronLaunchError)) throw error + // Anything else (a timeout while attaching): said as is, never thrown at the caller. + if (!(error instanceof ElectronLaunchError)) + return { failed: String((error as Error).message ?? error) } if (error.why === "site" && error.site !== undefined) return { site: error.site } if (error.why === "quit") return { quit: true } return { failed: error.message } diff --git a/packages/runtime/src/electron-workarea.ts b/packages/runtime/src/electron-workarea.ts index b8e4354..c25aa1e 100644 --- a/packages/runtime/src/electron-workarea.ts +++ b/packages/runtime/src/electron-workarea.ts @@ -108,15 +108,28 @@ function ownerOf(name: string): Owner | undefined { } /** A command's output (undefined when it fails; lsof's "nothing found" is an answer). */ -export function run(command: string, args: string[]): Promise { +export async function run(cmd: string, args: string[]): Promise { + const out = await command(cmd, args, 5000) + return out.code === 0 || out.code === 1 ? out.stdout : undefined +} + +/** + * A command's exit code (-1: it couldn't run, or timed out) and output, in the C locale: what's + * parsed (a start time, codesign's fields) reads the same in every language. + */ +export function command( + cmd: string, + args: string[], + timeoutMs: number, +): Promise<{ code: number; stdout: string; stderr: string }> { return new Promise((resolve) => { execFile( - command, + cmd, args, - // The C locale: what's parsed (a start time) reads the same in every language. - { timeout: 5000, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } }, - (error, stdout) => { - resolve(error !== null && (error as { code?: unknown }).code !== 1 ? undefined : stdout) + { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } }, + (error, stdout, stderr) => { + const code = error === null ? 0 : typeof error.code === "number" ? error.code : -1 + resolve({ code: error?.killed === true ? -1 : code, stdout, stderr }) }, ) }) diff --git a/packages/runtime/src/electron.ts b/packages/runtime/src/electron.ts index 4f32df8..3bc7dd7 100644 --- a/packages/runtime/src/electron.ts +++ b/packages/runtime/src/electron.ts @@ -26,6 +26,7 @@ import { import { untilStopped } from "./look.ts" import { chromium, + errors, type Browser, type BrowserContext, type CDPSession, @@ -156,6 +157,27 @@ export interface LaunchHooks { allowUnconfined?: boolean } +/** How long one attach may take before it's tried again. */ +const ATTACH_TRY_MS = 5000 + +/** + * Attached over CDP, tried again while time is left: an attach as the app's first page turns into + * Chromium's error page (a dev server not started) can hang (measured: 1 in 5), a second never did. + */ +async function attach(endpoint: string, deadline: number, signal?: AbortSignal): Promise { + for (;;) { + const left = deadline - Date.now() + try { + return await untilStopped( + chromium.connectOverCDP(endpoint, { timeout: Math.max(1, Math.min(left, ATTACH_TRY_MS)) }), + signal, + ) + } catch (error) { + if (!(error instanceof errors.TimeoutError) || left <= ATTACH_TRY_MS) throw error + } + } +} + /** Where a page that failed to load was going (Chromium's error page keeps it, over CDP). */ async function unreachableUrl(page: Page): Promise { try { @@ -394,10 +416,7 @@ export async function launchElectronWith( opts.signal, () => spawnError, ) - browser = await untilStopped( - chromium.connectOverCDP(endpoint, { timeout: Math.max(1, deadline - Date.now()) }), - opts.signal, - ) + browser = await attach(endpoint, deadline, opts.signal) const context = browser.contexts()[0] if (context === undefined) throw new ElectronLaunchError("the app opened no window") const connected = browser @@ -408,13 +427,17 @@ export async function launchElectronWith( () => child.exitCode !== null || child.signalCode !== null || !connected.isConnected(), opts.settleMs ?? SETTLE_MS, ) + // Where the main window is, or went: a load that failed (offline, a dev server not started) + // still names it (CDP's unreachable URL), judged and said by that. + const failed = page.url().startsWith("chrome-error:") + const shown = failed ? ((await unreachableUrl(page)) ?? page.url()) : page.url() // Sealed now, from the main window alone: its own scheme (app:) or dev server (loopback). const launched = new Set() const schemes = new Set() const embedded = new Set() - const origin = loopbackOrigin(page.url()) + const origin = loopbackOrigin(shown) if (origin !== undefined) launched.add(origin) - const scheme = ownScheme(page.url()) + const scheme = ownScheme(shown) if (scheme !== undefined) schemes.add(scheme) // What the main window embeds as it opens (a video, a widget) is part of the app as shipped. for (const frame of page.frames()) { @@ -433,11 +456,6 @@ export async function launchElectronWith( origins: [...(opts.origins ?? []), ...embedded], } const allows = (url: string) => allowedPage(url, own) - // A load that failed (no network in a trial) still names where it went (CDP's unreachable - // URL): judged by that, a wrapper's site said even unreached. - const shown = page.url().startsWith("chrome-error:") - ? ((await unreachableUrl(page)) ?? page.url()) - : page.url() if (!allows(shown)) { const url = URL.parse(shown) throw new ElectronLaunchError( @@ -445,6 +463,12 @@ export async function launchElectronWith( url?.protocol === "https:" ? { why: "site", site: url.origin } : { why: "other" }, ) } + // Its own page, never loaded: said (never a window driven on Chromium's error page). + if (failed) { + throw new ElectronLaunchError( + `the app couldn't load ${placeOf(shown)} (offline? its server not running?)`, + ) + } // The guard: every frame of every window, every navigation, at once; one that isn't the app's // own is stopped (a popup closed, any other frame blanked) and said. const stops: GuardStop[] = [] diff --git a/packages/runtime/test/electron-inspect.test.ts b/packages/runtime/test/electron-inspect.test.ts index 54b48fd..a673b5a 100644 --- a/packages/runtime/test/electron-inspect.test.ts +++ b/packages/runtime/test/electron-inspect.test.ts @@ -47,6 +47,11 @@ describe("where an app can't stay", () => { describe("a signature", () => { it("reads unsigned, ad hoc and a developer's from codesign", () => { expect(parseSigning(1, "code object is not signed at all")).toEqual({ kind: "unsigned" }) + // Any other failure (a timeout, a codesign that can't read it): unread, never "unsigned". + expect(parseSigning(-1, "")).toEqual({ kind: "unread" }) + expect(parseSigning(1, "invalid signature (code or signature have been modified)")).toEqual({ + kind: "unread", + }) expect( parseSigning(0, "Identifier=Electron\nSignature=adhoc\nTeamIdentifier=not set\n"), ).toEqual({ @@ -114,7 +119,17 @@ describe.runIf(process.platform === "darwin")("an app's inspection", { timeout: ) const digest = (await inspectDesktopApp(edited)).signer expect(digest).not.toEqual(seen.signer) - expect(await codeDigest(seen.path, seen.executable)).toBe( + // A helper app (where its pages and GPU run) changed: another build too. + const helper = bundle("Helper", (a) => { + const resources = join( + a, + "Contents/Frameworks/Electron Helper (Renderer).app/Contents/Resources", + ) + mkdirSync(resources, { recursive: true }) + writeFileSync(join(resources, "added.js"), "changed") + }) + expect((await inspectDesktopApp(helper)).signer).not.toEqual(seen.signer) + expect(await codeDigest(seen.path)).toBe( seen.signer.kind === "pinned" ? seen.signer.digest : "", ) }) @@ -143,6 +158,19 @@ describe.runIf(process.platform === "darwin")("an app's inspection", { timeout: await expect(inspectDesktopApp(linked)).rejects.toThrow(/executable isn't its own/) }) + it("names an app with an empty display name by its bundle name", async () => { + const app = bundle("Unnamed", (a) => + execFileSync("plutil", [ + "-replace", + "CFBundleDisplayName", + "-string", + "", + join(a, "Contents/Info.plist"), + ]), + ) + expect((await inspectDesktopApp(app)).name).toBe("Kiframe Fixture") + }) + it("refuses an app inside another app", async () => { const outer = join(mkdtempSync(join(root, "outer-")), "Outer.app") mkdirSync(join(outer, "Contents"), { recursive: true }) @@ -171,16 +199,17 @@ describe.runIf(process.platform === "darwin")("an app's inspection", { timeout: const app = await inspectDesktopApp(withMode("Notes", "plain")) expect(await trialDesktopApp(app, { workDir: work })).toEqual({ ok: true }) const wrapper = await inspectDesktopApp(withMode("Wrapper", "wrapper")) - // No network in a trial: the site is named all the same (where its window failed to go). + // Its site named, even unreached (where its window failed to go). expect(await trialDesktopApp(wrapper, { workDir: work })).toEqual({ site: "https://kiframe-wrapper.invalid", }) + // Listed, unreachable: said as a failed load (a real one loads, in electron.test.ts). expect( await trialDesktopApp(wrapper, { workDir: work, origins: ["https://kiframe-wrapper.invalid"], }), - ).toEqual({ ok: true }) + ).toMatchObject({ failed: expect.stringMatching(/couldn't load/) as string }) const quits = await inspectDesktopApp(withMode("Quits", "quit-at-once")) expect(await trialDesktopApp(quits, { workDir: work })).toEqual({ quit: true }) // Nothing left of any trial. diff --git a/packages/runtime/test/electron.test.ts b/packages/runtime/test/electron.test.ts index 65111f0..a5476a1 100644 --- a/packages/runtime/test/electron.test.ts +++ b/packages/runtime/test/electron.test.ts @@ -497,9 +497,26 @@ steps: expect(error).toBeInstanceOf(ElectronLaunchError) expect(error?.why).toBe("site") expect(error?.site).toBe("https://kiframe-wrapper.invalid") - // Listed as its own: it's driven (its page the browser's error page: no network here). - const target = await launch(["wrapper"], { origins: ["https://kiframe-wrapper.invalid"] }) - expect(target.page).toBeDefined() + // Listed as its own but never loaded: said (never a window driven on an error page). + await expect( + launch(["wrapper"], { origins: ["https://kiframe-wrapper.invalid"] }), + ).rejects.toThrow(/couldn't load kiframe-wrapper\.invalid/) + // Listed and loaded: driven. + const site = new URL(secure).origin + const target = await launch([`wrapper=${secure}`, "trust-test-cert"], { origins: [site] }) + expect(target.page.url()).toBe(secure) + }) + + it("says a dev build whose server isn't running, as such (never an off-app site)", async () => { + const closed = createNetServer() + await new Promise((resolve) => closed.listen(0, "127.0.0.1", resolve)) + const port = (closed.address() as AddressInfo).port + await new Promise((resolve) => closed.close(resolve)) + await expect(launch([`dev-down=http://127.0.0.1:${port}/`])).rejects.toThrow( + new RegExp( + `couldn't load 127\\.0\\.0\\.1:${port} \\(offline\\? its server not running\\?\\)`, + ), + ) }) it("says an app that quits at once, and leaves nothing behind", async () => { diff --git a/packages/runtime/test/fixtures/electron-app/main.cjs b/packages/runtime/test/fixtures/electron-app/main.cjs index fd6105a..33fc518 100644 --- a/packages/runtime/test/fixtures/electron-app/main.cjs +++ b/packages/runtime/test/fixtures/electron-app/main.cjs @@ -89,8 +89,15 @@ if ( // "embed-at-start": it shows that site in a frame as it opens. const link = process.argv.find((a) => a.startsWith("link="))?.slice(5) ?? "https://example.com/" const query = { link, ...(process.argv.includes("embed-at-start") && { embed: "1" }) } - // "wrapper": its window is a site (as Slack's app.slack.com), one that never loads (.invalid). - if (process.argv.includes("wrapper")) win.loadURL("https://kiframe-wrapper.invalid/client") + // "wrapper[=]": its window is a site (as Slack's app.slack.com); by default one that never + // loads (.invalid). "dev-down=": its dev server, not running. + const wrapper = process.argv.find((a) => a === "wrapper" || a.startsWith("wrapper=")) + const devDown = process.argv.find((a) => a.startsWith("dev-down="))?.slice(9) + if (wrapper !== undefined) { + win.loadURL( + wrapper === "wrapper" ? "https://kiframe-wrapper.invalid/client" : wrapper.slice(8), + ) + } else if (devDown !== undefined) win.loadURL(devDown) else if (process.argv.includes("elsewhere")) win.loadURL("data:text/html,

Elsewhere

") else win.loadFile(join(__dirname, "index.html"), { query }) }) From 7b2d5cee87a282193bb72931d9def2f6408c153e Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Fri, 9 Oct 2026 20:51:07 +0200 Subject: [PATCH 03/10] fix(runtime): close a pinned build's links and say a stalled attach - a pinned build's digest covers the whole bundle and refuses a link that leads outside it; unreadable, too large or stopped: refused, said - an attach that never answers is tried again, then said as the launch's own error (runs and trials alike); a trial throws Kiframe's own faults - an error page whose address can't be read is said as an unloaded page - the docs say what a trial reaches (the network, as a run) --- docs/OBJECT-MODEL.md | 4 +- packages/runtime/src/electron-confine.ts | 2 +- packages/runtime/src/electron-inspect.ts | 56 +++++++++++++------ packages/runtime/src/electron-trial.ts | 5 +- packages/runtime/src/electron.ts | 20 ++++++- .../runtime/test/electron-inspect.test.ts | 16 ++++++ packages/runtime/test/electron.test.ts | 29 +++++++++- 7 files changed, 106 insertions(+), 26 deletions(-) diff --git a/docs/OBJECT-MODEL.md b/docs/OBJECT-MODEL.md index 4497c0e..082e0e4 100644 --- a/docs/OBJECT-MODEL.md +++ b/docs/OBJECT-MODEL.md @@ -229,8 +229,8 @@ One scene, three phases. The agent's own instructions say the same (`packages/st - **Each run launches it sandboxed** (its own HOME, `CFFIXED_USER_HOME`, XDG and temp folders, `--user-data-dir`; an allowlisted environment, never Kiframe's), attached with `--remote-debugging-port=0` + `connectOverCDP`; the viewport is emulated (a spike measured frames and clicks with the window covered, off-screen, minimized or hidden: a run never needs the user's screen; the window itself can't be moved over CDP). Isolation is enforced, never checked after the fact: every launch is confined (below). Its process group killed after each run, leftovers swept. - **Confinement: a guard, sealed at launch (PR 2, redesigned after three review rounds, design reviewed).** Once the app settles (its windows unchanged 1.5 s), its main window is the last opened still open; its own scheme or dev server is learned from that window alone, then sealed (nothing a run does adds to it). A main window that isn't the app's own (a wrapper's unlisted site) refuses the launch ("list it in its origins"). From then a **guard** checks every navigation of every frame of every window as it happens: a page that isn't the app's own is stopped at once (a popup closed, any other frame blanked: never left for a read or a frame) and recorded; the step fails `off-app`. The app's own: a local `file:` in its bundle or sandbox (decoded, normalized), its sealed scheme or dev server, a listed https site (`sameApp`, never plain http), a blob of those. Stated (as a web run): the guard acts as a page commits, so a page that isn't the app's own has loaded (its scripts ran, with the app's own rights) before it's stopped: following a link the app offers is what its user does too (blocking before the load needs request interception, which never sees `file:`); and the runner's own navigation events name it as they do for web runs (origin and path, known secret values scrubbed). What the main window embeds as it opens (a frame of another site) is the app as shipped: its origin is the app's own. A main window stopped goes back to the app's page (no goto there to bring it back); a failed load is said as one. **One app per run:** a desktop app's run never shows a site (no goto, URL condition or web preset; a web interrupt rule's goto skipped, said), is confined while grounding too, and keeps no sign-in between runs yet. - **Windows (PR 2, known limits, to settle on real apps in PR 3 and PR 5: decided by the user 2026-10-08):** the main window is the last opened still open once the app's windows were unchanged for 1.5 s (a splash that stays up longer is taken for the main window); windows the app's main process opens are followed as popups are (a hidden helper window opened during a step may be followed: CDP can't tell it's hidden); only the launch window is sent back when it goes off-app (another window is closed); a stop that lands after its step ended counts against the next step. A window that opens blank gets a moment to load (as a web page does); a frame of a window the run isn't driving is said, never the step's failure. -- **Every launch confined (PR 3a, 2026-10-09, design reviewed twice, measured):** on macOS each launch runs under Seatbelt (`sandbox-exec`, `electron-confine.ts`): writes only into its own sandbox; no reads of /Users, /Volumes, the temp folders, the user's home and the work area wherever they are (a home outside /Users) but its sandbox and its bundle; nothing run from its sandbox (the one place it writes: a program it drops there, a copied launcher), no `open`, `osascript`, `launchctl` nor Apple Events; no unix sockets (an agent's: ssh, Docker) but mDNSResponder; no preferences (cfprefsd), keychain (SecurityServer: a mock keychain instead, `--use-mock-keychain`) nor clipboard (the user's may hold a password just copied: an app's own copy and paste doesn't work in a run); network for runs (the app's backend), loopback only for a trial (no updater). Chromium's own sandbox can't nest in it (`--no-sandbox`): the guard and this profile replace it. A canary before every launch, before the files are copied (a read the profile refuses must fail, one of its sandbox succeed: a confinement that runs nothing never passes); no `sandbox-exec`, or a canary failed: no launch (never unconfined). Off macOS: desktop apps don't launch (CI's tests only, unconfined). Measured with a probe app: every escape above `EPERM` (a hard link or a symlink to an outside file too), the app driven as usual. This replaces the trial's after-the-fact isolation check, rejected by review (an app that leaks does its harm as it starts). **What it doesn't stop (stated):** an app can still ask macOS to open a file or a URL (LaunchServices: no app starts without reaching it, measured), and what opens runs outside the confinement; it reaches services on this machine (localhost: a dev build's own server needs it). The app's own pages are its bundle and what it writes in its sandbox's home, profile and temp folders (a page it builds there from remote content counts as its own, as its cache already does); never the files/ copy. The confinement keeps an app from the user's data by accident (the minmux incident), not an app built to escape (one the user runs unconfined every day anyway). -- **Adding a desktop app (PR 3b, design reviewed 2026-10-09):** inspected first, statically (`electron-inspect.ts`: only `plutil` and `codesign` run): refused outside where an app can stay (a disk image, a temporary translocated copy, the Trash, inside another app), not Electron (its framework; no version gate: Figma reports 13.6.1), an executable that isn't its own file, a Mac App Store build (App Sandbox), a developer signature that doesn't hold (`codesign --verify --strict` against its team and signed identifier). Kept: its id, name, Electron's version, and its signer: a developer (team id) or, ad hoc and unsigned (the user's own builds), pinned to a digest of the code it runs (executable, Info.plist, Electron's framework, app.asar and what's unpacked, `Resources/app`). Then **tried confined** (`electron-trial.ts`): launched with the network as in a run (an updater can't touch the real app: nothing outside the sandbox is written; a wrapper's offline page can't hide its site), its main window attached, closed; a wrapper's site is named (even unreached: the error page's unreachable URL, over CDP) and allowed by the user in a second trial. A main window that never loaded its own page (offline, a dev server not running) is refused at launch, said as such: never a window driven on Chromium's error page. A pinned build's digest covers the whole bundle (helpers, frameworks, links as links). codesign that fails or times out refuses the app (never read as unsigned or unsandboxed). Apps that allow one copy (Slack, VS Code, Discord) need Chromium's temp folder in the sandbox (`MAC_CHROMIUM_TMPDIR`): its lock's socket otherwise goes to the user's /var/folders, denied, and the app quits. +- **Every launch confined (PR 3a, 2026-10-09, design reviewed twice, measured):** on macOS each launch runs under Seatbelt (`sandbox-exec`, `electron-confine.ts`): writes only into its own sandbox; no reads of /Users, /Volumes, the temp folders, the user's home and the work area wherever they are (a home outside /Users) but its sandbox and its bundle; nothing run from its sandbox (the one place it writes: a program it drops there, a copied launcher), no `open`, `osascript`, `launchctl` nor Apple Events; no unix sockets (an agent's: ssh, Docker) but mDNSResponder; no preferences (cfprefsd), keychain (SecurityServer: a mock keychain instead, `--use-mock-keychain`) nor clipboard (the user's may hold a password just copied: an app's own copy and paste doesn't work in a run); the network on, for runs and trials alike (the app's backend, a wrapper's site: an updater can't touch the real app, nothing outside the sandbox being written; what an app could send is only what it can read here: its bundle, the system's files, its own sandbox). Chromium's own sandbox can't nest in it (`--no-sandbox`): the guard and this profile replace it. A canary before every launch, before the files are copied (a read the profile refuses must fail, one of its sandbox succeed: a confinement that runs nothing never passes); no `sandbox-exec`, or a canary failed: no launch (never unconfined). Off macOS: desktop apps don't launch (CI's tests only, unconfined). Measured with a probe app: every escape above `EPERM` (a hard link or a symlink to an outside file too), the app driven as usual. This replaces the trial's after-the-fact isolation check, rejected by review (an app that leaks does its harm as it starts). **What it doesn't stop (stated):** an app can still ask macOS to open a file or a URL (LaunchServices: no app starts without reaching it, measured), and what opens runs outside the confinement; it reaches services on this machine (localhost: a dev build's own server needs it). The app's own pages are its bundle and what it writes in its sandbox's home, profile and temp folders (a page it builds there from remote content counts as its own, as its cache already does); never the files/ copy. The confinement keeps an app from the user's data by accident (the minmux incident), not an app built to escape (one the user runs unconfined every day anyway). +- **Adding a desktop app (PR 3b, design reviewed 2026-10-09):** inspected first, statically (`electron-inspect.ts`: only `plutil` and `codesign` run): refused outside where an app can stay (a disk image, a temporary translocated copy, the Trash, inside another app), not Electron (its framework; no version gate: Figma reports 13.6.1), an executable that isn't its own file, a Mac App Store build (App Sandbox), a developer signature that doesn't hold (`codesign --verify --strict` against its team and signed identifier). Kept: its id, name, Electron's version, and its signer: a developer (team id) or, ad hoc and unsigned (the user's own builds), pinned to a digest of the whole bundle (a link leading outside it refused; at most 4 GB and 200,000 entries). Then **tried confined** (`electron-trial.ts`): launched with the network as in a run (an updater can't touch the real app: nothing outside the sandbox is written; a wrapper's offline page can't hide its site), its main window attached, closed; a wrapper's site is named (even unreached: the error page's unreachable URL, over CDP) and allowed by the user in a second trial. A main window that never loaded its own page (offline, a dev server not running) is refused at launch, said as such: never a window driven on Chromium's error page. An attach that hangs as the first page fails (measured 1 in 5; cause not found) is tried again within the launch's time (stated). codesign that fails or times out refuses the app (never read as unsigned or unsandboxed). Apps that allow one copy (Slack, VS Code, Discord) need Chromium's temp folder in the sandbox (`MAC_CHROMIUM_TMPDIR`): its lock's socket otherwise goes to the user's /var/folders, denied, and the app quits. - **The work area `~/.kiframe/`** (decided by the user 2026-10-09: deletable any time): `sandboxes/` (one per launch, 0700, never a link), each named after the Kiframe that made it (`kiframe-app---…`: owned from the moment it exists, no shared state file to race on); a crash sweep at start removes only sandboxes no running Kiframe owns (the processes pointing at them killed first), never another build's launch, and nothing when `ps` can't be read. Kept out of Time Machine and Spotlight. Approvals stay in the app's settings, takes in app data. - **A project's `files/`** (decided by the user 2026-10-09): what a desktop app opens. Copied into the sandbox at each launch by Kiframe's own walk (folders and regular files only: a link, a FIFO, a device refused; 500 MB, 20,000 files, depth 32; cloned where the disk can, times kept); `args` name paths in it only (`files/vault`, given as the copy's path); the app's edits stay in the copy (thrown away: every run from the same files). The copy is the project's content, never the app's own pages for the guard. Filled by the user in v0: the agent's file tools never reach `files/` (it may hold what runs inside the app: an editor's tasks, a vault's plugins). - **Measured on a fixture app (PR 2, 2026-10-08):** HOME, XDG and `--user-data-dir` hold; macOS's own places don't move: Electron's `appData` (`~/Library/Application Support`) and the app's preferences (`NSUserDefaults`, written by `cfprefsd` into the user's real `~/Library/Preferences/.plist`) stay the user's whatever `HOME` or `CFFIXED_USER_HOME` say. Hence the confinement (PR 3a, below): the preferences daemon is denied and writes outside the sandbox refused, so neither can be reached (a check after launch, the earlier plan, came too late). diff --git a/packages/runtime/src/electron-confine.ts b/packages/runtime/src/electron-confine.ts index c0e0efd..7312268 100644 --- a/packages/runtime/src/electron-confine.ts +++ b/packages/runtime/src/electron-confine.ts @@ -28,7 +28,7 @@ export interface Confinement { * they are (a home outside /Users on a managed Mac). */ private: readonly string[] - /** "all": the app's backend reachable (a run); "loopback": nothing beyond this machine (a trial). */ + /** "all": the app's backend reachable (runs and trials); "loopback": nothing beyond this machine. */ network: "all" | "loopback" } diff --git a/packages/runtime/src/electron-inspect.ts b/packages/runtime/src/electron-inspect.ts index ae987ff..6b8c845 100644 --- a/packages/runtime/src/electron-inspect.ts +++ b/packages/runtime/src/electron-inspect.ts @@ -100,28 +100,49 @@ export async function signatureHolds(path: string, signer: Signer): Promise { +/** A pinned build's limits (hashed when it's picked, and before each run). */ +export const DIGEST_LIMITS = { bytes: 4 * 1024 * 1024 * 1024, entries: 200_000 } + +/** + * The code an unsigned app runs, hashed: the whole bundle, what pins the exact build (any change: a + * new digest). A link is kept as a link only when it stays inside the bundle (its target hashed as + * part of it); one leading outside is refused (code the pin couldn't cover). Unreadable, too large + * or stopped: refused, said. + */ +export async function codeDigest(path: string, signal?: AbortSignal): Promise { const hash = createHash("sha256") + let bytes = 0 + let entries = 0 const add = async (rel: string) => { - const full = join(path, rel) - let stat - try { - stat = await lstat(full) - } catch { - return - } + signal?.throwIfAborted() + const full = rel === "" ? path : join(path, rel) + const stat = await lstat(full) + entries += 1 + if (entries > DIGEST_LIMITS.entries) throw new InspectError("that app is too large to pin") if (stat.isSymbolicLink()) { + const target = await realpath(full).catch(() => undefined) + if (target === undefined || (target !== path && !target.startsWith(`${path}/`))) { + throw new InspectError(`that app links outside itself (${rel}): Kiframe can't pin it`) + } hash.update(`link ${rel} ${await readlink(full)}\n`) } else if (stat.isDirectory()) { - for (const name of (await readdir(full)).sort()) await add(join(rel, name)) + for (const name of (await readdir(full)).sort()) + await add(rel === "" ? name : join(rel, name)) } else if (stat.isFile()) { + bytes += stat.size + if (bytes > DIGEST_LIMITS.bytes) throw new InspectError("that app is too large to pin") hash.update(`file ${rel} ${stat.size}\n`) for await (const chunk of createReadStream(full)) hash.update(chunk as Buffer) } } - // All of it (helpers, frameworks, links as links): any code it could run is in the digest. - await add("Contents") + try { + await add("") + } catch (error) { + if (error instanceof InspectError || signal?.aborted === true) throw signal?.reason ?? error + throw new InspectError( + `that app can't be read whole (${(error as { code?: string }).code ?? "unreadable"})`, + ) + } return hash.digest("hex") } @@ -129,14 +150,17 @@ export async function codeDigest(path: string): Promise { * The signer of an app at `path`, and whether it's signed at all (an unsigned one has no * entitlements to read). Unread: refused (never taken for unsigned). */ -export async function signerOf(path: string): Promise { +export async function signerOf( + path: string, + signal?: AbortSignal, +): Promise { const signing = await run("/usr/bin/codesign", ["-dv", path], TIMEOUT) const parsed = parseSigning(signing.code, signing.stderr) if (parsed.kind === "unread") throw new InspectError("that app's signature couldn't be read") if (parsed.kind === "team") return { ...parsed, signed: true } return { kind: "pinned", - digest: await codeDigest(path), + digest: await codeDigest(path, signal), signed: parsed.kind === "adhoc", } } @@ -146,7 +170,7 @@ export async function signerOf(path: string): Promise { +export async function inspectDesktopApp(picked: string, signal?: AbortSignal): Promise { let path: string try { path = await realpath(picked) @@ -179,7 +203,7 @@ export async function inspectDesktopApp(picked: string): Promise { } catch { throw new InspectError("that app's executable isn't its own runnable file") } - const { signed, ...signer } = await signerOf(path) + const { signed, ...signer } = await signerOf(path, signal) // A signed app's entitlements, read (unread: refused, never taken for "not sandboxed"); an // unsigned one has none. if (signed) { diff --git a/packages/runtime/src/electron-trial.ts b/packages/runtime/src/electron-trial.ts index ecaa9de..f579203 100644 --- a/packages/runtime/src/electron-trial.ts +++ b/packages/runtime/src/electron-trial.ts @@ -43,9 +43,8 @@ export async function trialDesktopApp( return { ok: true } } catch (error) { if (opts.signal?.aborted === true) throw opts.signal.reason - // Anything else (a timeout while attaching): said as is, never thrown at the caller. - if (!(error instanceof ElectronLaunchError)) - return { failed: String((error as Error).message ?? error) } + // Anything else is Kiframe's own fault: thrown, never shown as the app's. + if (!(error instanceof ElectronLaunchError)) throw error if (error.why === "site" && error.site !== undefined) return { site: error.site } if (error.why === "quit") return { quit: true } return { failed: error.message } diff --git a/packages/runtime/src/electron.ts b/packages/runtime/src/electron.ts index 3bc7dd7..c93633d 100644 --- a/packages/runtime/src/electron.ts +++ b/packages/runtime/src/electron.ts @@ -128,7 +128,7 @@ export interface ElectronLaunch { files?: string /** Kiframe's work area (default `~/.kiframe`): the launches' sandboxes. */ workDir?: string - /** The network: "all" (a run: the app's backend), "loopback" (a trial: nothing beyond). */ + /** The network: "all" (the app's backend: runs and trials), "loopback" (nothing beyond). */ network?: "all" | "loopback" /** The size its windows are shown at (emulated: the window itself is never moved). */ viewport: Pick @@ -164,7 +164,11 @@ const ATTACH_TRY_MS = 5000 * Attached over CDP, tried again while time is left: an attach as the app's first page turns into * Chromium's error page (a dev server not started) can hang (measured: 1 in 5), a second never did. */ -async function attach(endpoint: string, deadline: number, signal?: AbortSignal): Promise { +export async function attach( + endpoint: string, + deadline: number, + signal?: AbortSignal, +): Promise { for (;;) { const left = deadline - Date.now() try { @@ -173,7 +177,11 @@ async function attach(endpoint: string, deadline: number, signal?: AbortSignal): signal, ) } catch (error) { - if (!(error instanceof errors.TimeoutError) || left <= ATTACH_TRY_MS) throw error + if (!(error instanceof errors.TimeoutError)) throw error + // Out of time: said as the launch's own (never Playwright's call log, its endpoint). + if (left <= ATTACH_TRY_MS) { + throw new ElectronLaunchError("the app didn't answer in time (it may refuse automation)") + } } } } @@ -456,6 +464,12 @@ export async function launchElectronWith( origins: [...(opts.origins ?? []), ...embedded], } const allows = (url: string) => allowedPage(url, own) + // Failed to load and where to unknown: its page, never loaded. + if (failed && shown.startsWith("chrome-error:")) { + throw new ElectronLaunchError( + "the app couldn't load its page (offline? its server not running?)", + ) + } if (!allows(shown)) { const url = URL.parse(shown) throw new ElectronLaunchError( diff --git a/packages/runtime/test/electron-inspect.test.ts b/packages/runtime/test/electron-inspect.test.ts index a673b5a..e8ed7b2 100644 --- a/packages/runtime/test/electron-inspect.test.ts +++ b/packages/runtime/test/electron-inspect.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process" import { + chmodSync, cpSync, mkdirSync, mkdtempSync, @@ -171,6 +172,21 @@ describe.runIf(process.platform === "darwin")("an app's inspection", { timeout: expect((await inspectDesktopApp(app)).name).toBe("Kiframe Fixture") }) + it("refuses a pinned app that links outside itself, or can't be read whole", async () => { + // After signing (code a link leads to outside the bundle: what the pin couldn't cover). + const outside = bundle("Outside") + symlinkSync("/etc/hosts", join(outside, "Contents/Resources/shared.js")) + await expect(inspectDesktopApp(outside)).rejects.toThrow(/links outside itself/) + const unreadable = bundle("Unreadable") + const locked = join(unreadable, "Contents/Resources/app/index.html") + chmodSync(locked, 0o000) + try { + await expect(inspectDesktopApp(unreadable)).rejects.toThrow(/can't be read whole \(EACCES\)/) + } finally { + chmodSync(locked, 0o644) + } + }) + it("refuses an app inside another app", async () => { const outer = join(mkdtempSync(join(root, "outer-")), "Outer.app") mkdirSync(join(outer, "Contents"), { recursive: true }) diff --git a/packages/runtime/test/electron.test.ts b/packages/runtime/test/electron.test.ts index a5476a1..a3ec898 100644 --- a/packages/runtime/test/electron.test.ts +++ b/packages/runtime/test/electron.test.ts @@ -36,7 +36,12 @@ import { sweepWorkArea, } from "../src/index.ts" import { checkConfinement, copyFiles } from "../src/electron-confine.ts" -import { type ElectronLaunch, launchElectronWith, type LaunchHooks } from "../src/electron.ts" +import { + attach, + type ElectronLaunch, + launchElectronWith, + type LaunchHooks, +} from "../src/electron.ts" import { sandboxesOf } from "../src/electron-workarea.ts" // The Electron target (OBJECT-MODEL §0.9 "Electron apps"): a fixture desktop app launched in a @@ -507,6 +512,28 @@ steps: expect(target.page.url()).toBe(secure) }) + it("tries a stalled attach again, then says it as the launch's own (never Playwright's)", async () => { + // A debugging endpoint that accepts and never answers. + let connections = 0 + const silent = createNetServer(() => (connections += 1)) + await new Promise((resolve) => silent.listen(0, "127.0.0.1", resolve)) + const port = (silent.address() as AddressInfo).port + try { + const error = await attach( + `ws://127.0.0.1:${port}/devtools/browser/x`, + Date.now() + 7000, + ).then( + () => undefined, + (e: unknown) => e, + ) + expect(error).toBeInstanceOf(ElectronLaunchError) + expect(String(error)).toMatch(/didn't answer in time/) + expect(connections).toBe(2) + } finally { + silent.close() + } + }, 30_000) + it("says a dev build whose server isn't running, as such (never an off-app site)", async () => { const closed = createNetServer() await new Promise((resolve) => closed.listen(0, "127.0.0.1", resolve)) From 6d8be2ed1194ab35fc1f358337012b494d073329 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Fri, 9 Oct 2026 21:07:44 +0200 Subject: [PATCH 04/10] refactor(runtime): one launch failure rule and an unforgeable build digest Redesign after three review rounds on the same two seams (reviewed): - every failure ending a launch is an ElectronLaunchError with its why, by one rule (stop, not started, quit first, said, anything else in a fixed phrase, its detail on stderr only when asked); the trial maps only its why - the pinned digest: the path resolved, each entry a JSON line under a version, links resolved inside the bundle step by step (a chain leaving it refused, one leading nowhere kept), sizes checked, sockets and devices refused, a stop honoured per chunk - the unused loopback network mode and a duplicate branch removed --- packages/runtime/src/electron-confine.ts | 3 - packages/runtime/src/electron-inspect.ts | 102 ++++++++++++++---- packages/runtime/src/electron-trial.ts | 12 +-- packages/runtime/src/electron.ts | 65 +++++++---- .../runtime/test/electron-inspect.test.ts | 75 +++++++++++++ packages/runtime/test/electron.test.ts | 101 ++++++++++++++--- 6 files changed, 292 insertions(+), 66 deletions(-) diff --git a/packages/runtime/src/electron-confine.ts b/packages/runtime/src/electron-confine.ts index 7312268..5fc0e25 100644 --- a/packages/runtime/src/electron-confine.ts +++ b/packages/runtime/src/electron-confine.ts @@ -28,8 +28,6 @@ export interface Confinement { * they are (a home outside /Users on a managed Mac). */ private: readonly string[] - /** "all": the app's backend reachable (runs and trials); "loopback": nothing beyond this machine. */ - network: "all" | "loopback" } /** A string in the profile's language (quoted, its backslashes and quotes escaped). */ @@ -49,7 +47,6 @@ export function seatbeltProfile(c: Confinement): string { (deny appleevent-send) (deny process-exec* (subpath ${q(c.sandbox)}) (literal "/usr/bin/open") (literal "/usr/bin/osascript") (literal "/bin/launchctl")) (deny mach-lookup (global-name "com.apple.cfprefsd.agent") (global-name "com.apple.SecurityServer") (global-name "com.apple.pasteboard.1")) -${c.network === "loopback" ? '(deny network-outbound)\n(allow network-outbound (remote ip "localhost:*"))\n(allow network-outbound (remote unix-socket (path-literal "/private/var/run/mDNSResponder")))' : ""} ` } diff --git a/packages/runtime/src/electron-inspect.ts b/packages/runtime/src/electron-inspect.ts index 6b8c845..be99a31 100644 --- a/packages/runtime/src/electron-inspect.ts +++ b/packages/runtime/src/electron-inspect.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto" -import { constants, createReadStream } from "node:fs" +import { constants, createReadStream, lstatSync, readlinkSync } from "node:fs" import { access, lstat, readdir, readlink, realpath } from "node:fs/promises" -import { basename, join } from "node:path" +import { basename, isAbsolute, join } from "node:path" import { command as run } from "./electron-workarea.ts" // A desktop app the user picks, inspected before Kiframe ever runs it (PR 3b, design reviewed @@ -100,45 +100,105 @@ export async function signatureHolds(path: string, signer: Signer): Promise .` then `e -> d/../x` is seen leaving): its parts under the root, `"outside"` + * (absolute, or above the root at any step), or `"dangling"` (a step that isn't there: it leads + * nowhere, so nowhere outside). + */ +function resolveIn( + root: string, + base: readonly string[], + target: string, + depth: number, +): string[] | "outside" | "dangling" { + if (depth > 40 || isAbsolute(target)) return "outside" + const parts = [...base] + for (const step of target.split("/")) { + if (step === "" || step === ".") continue + if (step === "..") { + if (parts.length === 0) return "outside" + parts.pop() + continue + } + parts.push(step) + let stat + try { + stat = lstatSync(join(root, ...parts)) + } catch { + return "dangling" + } + if (stat.isSymbolicLink()) { + const inner = resolveIn( + root, + parts.slice(0, -1), + readlinkSync(join(root, ...parts)), + depth + 1, + ) + if (typeof inner === "string") return inner + parts.splice(0, parts.length, ...inner) + } + } + return parts +} + /** * The code an unsigned app runs, hashed: the whole bundle, what pins the exact build (any change: a - * new digest). A link is kept as a link only when it stays inside the bundle (its target hashed as - * part of it); one leading outside is refused (code the pin couldn't cover). Unreadable, too large - * or stopped: refused, said. + * new digest). Each entry a JSON line (no name can pass for another entry); a link kept as a link + * when it leads inside the bundle (or nowhere), refused when it leads outside (code the pin + * couldn't cover); a socket or a device refused. Unreadable, changed while read, too large or + * stopped: refused, said. */ -export async function codeDigest(path: string, signal?: AbortSignal): Promise { +export async function codeDigest(picked: string, signal?: AbortSignal): Promise { const hash = createHash("sha256") + const line = (entry: readonly unknown[]) => hash.update(`${JSON.stringify(entry)}\n`) + line([DIGEST_VERSION]) let bytes = 0 let entries = 0 - const add = async (rel: string) => { + const tooLarge = () => new InspectError("that app is too large to pin") + const walk = async (root: string, rel: readonly string[]) => { signal?.throwIfAborted() - const full = rel === "" ? path : join(path, rel) + const full = join(root, ...rel) + const name = rel.join("/") const stat = await lstat(full) entries += 1 - if (entries > DIGEST_LIMITS.entries) throw new InspectError("that app is too large to pin") + if (entries > DIGEST_LIMITS.entries) throw tooLarge() if (stat.isSymbolicLink()) { - const target = await realpath(full).catch(() => undefined) - if (target === undefined || (target !== path && !target.startsWith(`${path}/`))) { - throw new InspectError(`that app links outside itself (${rel}): Kiframe can't pin it`) + const target = await readlink(full) + if (resolveIn(root, rel.slice(0, -1), target, 0) === "outside") { + throw new InspectError(`that app links outside itself (${name}): Kiframe can't pin it`) } - hash.update(`link ${rel} ${await readlink(full)}\n`) + line(["link", name, target]) } else if (stat.isDirectory()) { - for (const name of (await readdir(full)).sort()) - await add(rel === "" ? name : join(rel, name)) + line(["dir", name]) + for (const entry of (await readdir(full)).sort()) await walk(root, [...rel, entry]) } else if (stat.isFile()) { bytes += stat.size - if (bytes > DIGEST_LIMITS.bytes) throw new InspectError("that app is too large to pin") - hash.update(`file ${rel} ${stat.size}\n`) - for await (const chunk of createReadStream(full)) hash.update(chunk as Buffer) + if (bytes > DIGEST_LIMITS.bytes) throw tooLarge() + line(["file", name, stat.size, (stat.mode & 0o111) !== 0]) + let read = 0 + for await (const chunk of createReadStream(full)) { + signal?.throwIfAborted() + read += (chunk as Buffer).length + hash.update(chunk as Buffer) + } + if (read !== stat.size) + throw new InspectError("that app changed while it was read: try again") + } else { + throw new InspectError(`that app holds what isn't a file (${name}): Kiframe can't pin it`) } } try { - await add("") + await walk(await realpath(picked), []) } catch (error) { - if (error instanceof InspectError || signal?.aborted === true) throw signal?.reason ?? error + if (signal?.aborted === true) throw signal.reason + if (error instanceof InspectError) throw error throw new InspectError( `that app can't be read whole (${(error as { code?: string }).code ?? "unreadable"})`, ) diff --git a/packages/runtime/src/electron-trial.ts b/packages/runtime/src/electron-trial.ts index f579203..6e3c139 100644 --- a/packages/runtime/src/electron-trial.ts +++ b/packages/runtime/src/electron-trial.ts @@ -26,27 +26,27 @@ export async function trialDesktopApp( opts: TrialOptions = {}, ): Promise { const origins = opts.origins ?? [] + let target try { - const target = await launchElectron({ + target = await launchElectron({ executable: app.executable, bundle: app.path, // The network as in a run (its site shown even behind its own offline page): an updater // can't touch the real app all the same (nothing outside the sandbox is written). - network: "all", origins, viewport: opts.viewport ?? { width: 1440, height: 900, deviceScaleFactor: 1 }, ...(opts.workDir !== undefined && { workDir: opts.workDir }), ...(opts.signal !== undefined && { signal: opts.signal }), ...(opts.timeoutMs !== undefined && { timeoutMs: opts.timeoutMs }), }) - await target.close() - return { ok: true } } catch (error) { - if (opts.signal?.aborted === true) throw opts.signal.reason - // Anything else is Kiframe's own fault: thrown, never shown as the app's. + // The launch says every failure as an ElectronLaunchError (but a stop: thrown as is). if (!(error instanceof ElectronLaunchError)) throw error if (error.why === "site" && error.site !== undefined) return { site: error.site } if (error.why === "quit") return { quit: true } return { failed: error.message } } + // Its close is bounded and never fails a trial that worked. + await target.close().catch(() => undefined) + return { ok: true } } diff --git a/packages/runtime/src/electron.ts b/packages/runtime/src/electron.ts index c93633d..3a48992 100644 --- a/packages/runtime/src/electron.ts +++ b/packages/runtime/src/electron.ts @@ -88,6 +88,40 @@ async function exitedSoon(child: ChildProcess, ms: number): Promise { }) } +/** + * What a launch that failed says, the one rule (every failure an ElectronLaunchError with its + * `why`, but a stop): stopped → the stop; not started → said; the app quit (whatever the attach + * was doing) → quit; already said → as is; anything else (Playwright, CDP) → a fixed phrase (never + * its message: it can carry the debugging endpoint or a page's address), its detail on stderr for + * Kiframe's own debugging only (KIFRAME_ELECTRON_DEBUG). + */ +export function launchFailure( + error: unknown, + state: { stopped: unknown; spawnError: Error | undefined; quit: boolean }, +): unknown { + if (state.stopped !== undefined) return state.stopped + if (state.spawnError !== undefined) { + return new ElectronLaunchError(`the app couldn't be launched (${state.spawnError.message})`) + } + if (state.quit) return new ElectronLaunchError(QUIT_EARLY, { why: "quit" }) + if (error instanceof ElectronLaunchError) return error + debug("launch", error) + return new ElectronLaunchError( + error instanceof errors.TimeoutError + ? "the app didn't answer in time (it may refuse automation)" + : "the app couldn't be driven (its debugging connection failed)", + ) +} + +/** A failure's detail, for Kiframe's own debugging: stderr, only when asked (never a tool's). */ +function debug(phase: string, error: unknown) { + if (process.env.KIFRAME_ELECTRON_DEBUG !== undefined) { + process.stderr.write( + `[electron] ${phase}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, + ) + } +} + /** Shutdown phases that took long, said on stderr (KIFRAME_ELECTRON_TIMING): CI's evidence. */ function timing(phase: string, since: number) { const took = Date.now() - since @@ -128,8 +162,6 @@ export interface ElectronLaunch { files?: string /** Kiframe's work area (default `~/.kiframe`): the launches' sandboxes. */ workDir?: string - /** The network: "all" (the app's backend: runs and trials), "loopback" (nothing beyond). */ - network?: "all" | "loopback" /** The size its windows are shown at (emulated: the window itself is never moved). */ viewport: Pick /** The https sites a wrapper app shows as its own (the project's `origins`). */ @@ -355,7 +387,6 @@ export async function launchElectronWith( readable: [...bundles.map(realpathOr), ...(hooks.readable ?? []).map(realpathOr)], // The home and the work area wherever they are, and the one temp folder left (/var/tmp). private: [realpathOr(userInfo().homedir), realpathOr(work), "/private/var/tmp"], - network: opts.network ?? "all", } // Its own canary, beside its sandbox (never inside: that's readable) and named after it: // launches at once never touch each other's, and a crash's is swept with its sandbox. @@ -373,10 +404,13 @@ export async function launchElectronWith( opts.signal?.throwIfAborted() } catch (error) { await rm(sandbox.root, { recursive: true, force: true }).catch(() => undefined) + if (opts.signal?.aborted === true) throw opts.signal.reason as Error if (error instanceof FilesError || error instanceof ConfinementError) { throw new ElectronLaunchError(error.message) } - throw error + if (error instanceof ElectronLaunchError) throw error + debug("prepare", error) + throw new ElectronLaunchError("the app's sandbox couldn't be prepared") } // The app's own time to start (the copy and the canary never count against it). const deadline = Date.now() + (opts.timeoutMs ?? LAUNCH_MS) @@ -464,13 +498,7 @@ export async function launchElectronWith( origins: [...(opts.origins ?? []), ...embedded], } const allows = (url: string) => allowedPage(url, own) - // Failed to load and where to unknown: its page, never loaded. - if (failed && shown.startsWith("chrome-error:")) { - throw new ElectronLaunchError( - "the app couldn't load its page (offline? its server not running?)", - ) - } - if (!allows(shown)) { + if (!shown.startsWith("chrome-error:") && !allows(shown)) { const url = URL.parse(shown) throw new ElectronLaunchError( `the app shows ${placeOf(shown)}: if that's the app's own, list it in its origins`, @@ -479,8 +507,9 @@ export async function launchElectronWith( } // Its own page, never loaded: said (never a window driven on Chromium's error page). if (failed) { + const where = shown.startsWith("chrome-error:") ? "its page" : placeOf(shown) throw new ElectronLaunchError( - `the app couldn't load ${placeOf(shown)} (offline? its server not running?)`, + `the app couldn't load ${where} (offline? its server not running?)`, ) } // The guard: every frame of every window, every navigation, at once; one that isn't the app's @@ -570,13 +599,11 @@ export async function launchElectronWith( // A moment for its exit to be told (it may quit as the attach drops: the drop comes first). const quit = await exitedSoon(child, EXIT_TELL_MS) await end() - if (opts.signal?.aborted === true) throw opts.signal.reason as Error - if (spawnError !== undefined) { - throw new ElectronLaunchError(`the app couldn't be launched (${spawnError.message})`) - } - if (quit && !(error instanceof ElectronLaunchError)) - throw new ElectronLaunchError(QUIT_EARLY, { why: "quit" }) - throw error + throw launchFailure(error, { + stopped: opts.signal?.aborted === true ? (opts.signal.reason as unknown) : undefined, + spawnError, + quit, + }) } } diff --git a/packages/runtime/test/electron-inspect.test.ts b/packages/runtime/test/electron-inspect.test.ts index e8ed7b2..7dc9f7a 100644 --- a/packages/runtime/test/electron-inspect.test.ts +++ b/packages/runtime/test/electron-inspect.test.ts @@ -15,6 +15,7 @@ import { join } from "node:path" import { afterAll, beforeAll, describe, expect, it } from "vitest" import { codeDigest, + DIGEST_LIMITS, inspectDesktopApp, parseSigning, placeRefusal, @@ -70,6 +71,80 @@ describe("a signature", () => { }) }) +// A pinned build's digest (any platform: plain folders). +describe("a pinned build's digest", () => { + const tree = (entries: Record, links: Record = {}) => { + const root = mkdtempSync(join(tmpdir(), "kiframe-el-digest-")) + for (const [rel, content] of Object.entries(entries)) { + mkdirSync(join(root, rel, ".."), { recursive: true }) + writeFileSync(join(root, rel), content) + } + for (const [rel, target] of Object.entries(links)) { + mkdirSync(join(root, rel, ".."), { recursive: true }) + symlinkSync(target, join(root, rel)) + } + return root + } + + it("keeps links inside the bundle, and one leading nowhere", async () => { + const root = tree( + { "Versions/A/code": "x" }, + { "Versions/Current": "A", code: "Versions/Current/code", "bin/gone": "../pruned/cli.js" }, + ) + expect(await codeDigest(root)).toMatch(/^[0-9a-f]{64}$/) + }) + + it("refuses a link leading outside, however it gets there", async () => { + for (const links of [ + { "lib.js": "/etc/hosts" }, + { "a/lib.js": "../../outside.js" }, + // Through an inner link: d is the root itself, so d/../x is above it. + { d: ".", e: "d/../x" }, + ]) { + await expect( + codeDigest(tree({ "a/code": "x" }, links)), + JSON.stringify(links), + ).rejects.toThrow(/links outside itself/) + } + }) + + it("never lets one tree pass for another (names can't forge entries)", async () => { + const two = tree({ A: "X", B: "Y" }) + // One file whose name spells A's entry, A's byte and B's entry (in a plain "file name size + // exec" encoding the two trees would read the same). + const one = tree({ ["A 1 false\nXfile B"]: "Y" }) + expect(await codeDigest(two)).not.toBe(await codeDigest(one)) + }) + + it("is the same through a link to the bundle, and changes with the code", async () => { + const root = tree({ "a/code": "x" }) + const linked = join(mkdtempSync(join(tmpdir(), "kiframe-el-digest-link-")), "App.app") + symlinkSync(root, linked) + const digest = await codeDigest(root) + expect(await codeDigest(linked)).toBe(digest) + writeFileSync(join(root, "a/code"), "y") + expect(await codeDigest(root)).not.toBe(digest) + }) + + it("refuses what isn't a file, what's too large, and stops when asked", async () => { + const fifo = tree({ "a/code": "x" }) + execFileSync("mkfifo", [join(fifo, "a/pipe")]) + await expect(codeDigest(fifo)).rejects.toThrow(/isn't a file/) + const limit = DIGEST_LIMITS.entries + DIGEST_LIMITS.entries = 2 + try { + await expect(codeDigest(tree({ "a/b": "1", "a/c": "2" }))).rejects.toThrow(/too large/) + } finally { + DIGEST_LIMITS.entries = limit + } + const stopping = new AbortController() + stopping.abort() + await expect(codeDigest(tree({ a: "1" }), stopping.signal)).rejects.toMatchObject({ + name: "AbortError", + }) + }) +}) + describe.runIf(process.platform === "darwin")("an app's inspection", { timeout: 60_000 }, () => { let root: string let base: string diff --git a/packages/runtime/test/electron.test.ts b/packages/runtime/test/electron.test.ts index a3ec898..676b1b4 100644 --- a/packages/runtime/test/electron.test.ts +++ b/packages/runtime/test/electron.test.ts @@ -39,6 +39,7 @@ import { checkConfinement, copyFiles } from "../src/electron-confine.ts" import { attach, type ElectronLaunch, + launchFailure, launchElectronWith, type LaunchHooks, } from "../src/electron.ts" @@ -161,7 +162,6 @@ const launch = async ( readable?: string[] timeoutMs?: number signal?: AbortSignal - network?: "all" | "loopback" origins?: string[] } = {}, ) => { @@ -494,8 +494,8 @@ steps: expect(await target.page.title()).toBe("Fixture notes") }) - it("names the site a wrapper app shows, even when it can't load it (a trial's loopback)", async () => { - const error = await launch(["wrapper"], { network: "loopback" }).then( + it("names the site a wrapper app shows, even when it can't load it", async () => { + const error = await launch(["wrapper"]).then( () => undefined, (e: unknown) => e as ElectronLaunchError, ) @@ -512,6 +512,81 @@ steps: expect(target.page.url()).toBe(secure) }) + // A fake app: announces a debugging endpoint (a test's server) and, asked, quits after a while. + const fakeApp = () => { + const dir = mkdtempSync(join(tmpdir(), "kiframe-el-fake-")) + const script = join(dir, "fake-app") + writeFileSync( + script, + `#!${process.execPath} +const [port, quitAfter] = process.argv.slice(2) +process.stderr.write("DevTools listening on ws://127.0.0.1:" + port + "/devtools/browser/0f0e0d0c-0000-4000-8000-000000000000\\n") +if (quitAfter !== "never") setTimeout(() => process.exit(0), Number(quitAfter)) +else setInterval(() => undefined, 60_000) +`, + { mode: 0o755 }, + ) + return { script, readable: [realpathSync(dir), realpathSync(dirname(process.execPath))] } + } + const fakeLaunch = (port: number, quitAfter: string, timeoutMs: number) => { + const fake = fakeApp() + return launchElectron({ + executable: fake.script, + appArgs: [String(port), quitAfter], + readable: fake.readable, + workDir: work, + allowUnconfined: true, + viewport, + timeoutMs, + }).then( + () => undefined, + (e: unknown) => e, + ) + } + + it("says a broken debugging connection in its own words (never Playwright's, its endpoint)", async () => { + // An endpoint that refuses the WebSocket (a CDP it can't speak). + const refusing = createServer((_req, res) => res.writeHead(404).end()) + await new Promise((resolve) => refusing.listen(0, "127.0.0.1", resolve)) + try { + const error = await fakeLaunch((refusing.address() as AddressInfo).port, "never", 8000) + expect(error).toBeInstanceOf(ElectronLaunchError) + expect(String(error)).toMatch(/couldn't be driven \(its debugging connection failed\)/) + expect(String(error)).not.toMatch(/ws:\/\/|127\.0\.0\.1/) + } finally { + refusing.close() + } + }, 30_000) + + it("says an app that quits while its attach stalls as quit (never 'didn't answer')", async () => { + const silent = createNetServer(() => undefined) + await new Promise((resolve) => silent.listen(0, "127.0.0.1", resolve)) + try { + // It quits just after the launch's time: the attach times out, its exit lands after. + const error = await fakeLaunch((silent.address() as AddressInfo).port, "3300", 3000) + expect(error).toBeInstanceOf(ElectronLaunchError) + expect((error as ElectronLaunchError).why).toBe("quit") + } finally { + silent.close() + } + }, 30_000) + + it("words every failure by one rule: a stop, not started, quit, said, anything else", () => { + const stop = new DOMException("stopped", "AbortError") + const said = new ElectronLaunchError("said") + const raw = new Error("connect ECONNREFUSED ws://127.0.0.1:9/devtools/browser/x") + const none = { stopped: undefined, spawnError: undefined, quit: false } + expect(launchFailure(raw, { ...none, stopped: stop, quit: true })).toBe(stop) + expect( + String(launchFailure(raw, { ...none, spawnError: new Error("ENOENT"), quit: true })), + ).toMatch(/couldn't be launched \(ENOENT\)/) + expect(launchFailure(said, { ...none, quit: true })).toMatchObject({ why: "quit" }) + expect(launchFailure(said, none)).toBe(said) + const worded = launchFailure(raw, none) + expect(worded).toBeInstanceOf(ElectronLaunchError) + expect(String(worded)).not.toMatch(/ws:\/\//) + }) + it("tries a stalled attach again, then says it as the launch's own (never Playwright's)", async () => { // A debugging endpoint that accepts and never answers. let connections = 0 @@ -1141,7 +1216,7 @@ steps: expect(linked).not.toBe(real) await expect( checkConfinement( - { sandbox: linked, readable: [], private: [], network: "all" }, + { sandbox: linked, readable: [], private: [] }, join(mkdtempSync(join(tmpdir(), "kiframe-el-work-")), "canary.txt"), ), ).rejects.toThrow(/doesn't run programs/) @@ -1151,7 +1226,7 @@ steps: // /var/tmp: outside every folder the profile denies by itself (as a /Network/Users home). const home = mkdtempSync("/private/var/tmp/kiframe-el-home-") const sandbox = mkdtempSync(join(realpathSync(tmpdir()), "kiframe-el-sb-")) - const confinement = { sandbox, readable: [], network: "all" } as const + const confinement = { sandbox, readable: [] } as const try { // Denied (the canary unread) only as one of the user's own folders. await expect( @@ -1165,18 +1240,10 @@ steps: } }) - it("keeps a trial off the network but this machine (no updater fetch)", () => { - const profile = seatbeltProfile({ - sandbox: "/s", - readable: [], - private: [], - network: "loopback", - }) - expect(profile).toContain("(deny network-outbound)") - expect(profile).toContain('(allow network-outbound (remote ip "localhost:*"))') - expect( - seatbeltProfile({ sandbox: "/s", readable: [], private: [], network: "all" }), - ).not.toContain("(deny network-outbound)\n") + it("leaves the network to the app (its backend), never other programs' sockets", () => { + const profile = seatbeltProfile({ sandbox: "/s", readable: [], private: [] }) + expect(profile).not.toContain("(deny network-outbound)\n") + expect(profile).toContain("(deny network-outbound (remote unix-socket))") }) }, ) From 5af2da2c71bf0761dcfafd1e50a5720265fd1a4b Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Fri, 9 Oct 2026 21:23:36 +0200 Subject: [PATCH 05/10] fix(runtime): keep a launch's said reason and hold a trial to the picked build - what a launch already said (a site, a page never loaded) stays said if the app then quits; the attach hands its timeout to the one rule - a pinned build is held by its digest (signatureHolds compares it); a trial runs only the build that was picked - an inspection stops when asked (codesign and plutil killed) - bundle ids with underscores accepted (schema and inspection) - one quoting helper; the digest's link steps no longer block --- packages/runtime/src/electron-confine.ts | 2 +- packages/runtime/src/electron-inspect.ts | 76 ++++++++++++++----- packages/runtime/src/electron-trial.ts | 8 +- packages/runtime/src/electron-workarea.ts | 8 +- packages/runtime/src/electron.ts | 14 ++-- .../runtime/test/electron-inspect.test.ts | 21 +++++ packages/runtime/test/electron.test.ts | 18 ++++- packages/schema/src/apps.test.ts | 2 + packages/schema/src/project.ts | 3 +- 9 files changed, 115 insertions(+), 37 deletions(-) diff --git a/packages/runtime/src/electron-confine.ts b/packages/runtime/src/electron-confine.ts index 5fc0e25..5273ae4 100644 --- a/packages/runtime/src/electron-confine.ts +++ b/packages/runtime/src/electron-confine.ts @@ -31,7 +31,7 @@ export interface Confinement { } /** A string in the profile's language (quoted, its backslashes and quotes escaped). */ -const q = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` +export const q = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` /** The Seatbelt profile of a launch. Later rules win over earlier ones. */ export function seatbeltProfile(c: Confinement): string { diff --git a/packages/runtime/src/electron-inspect.ts b/packages/runtime/src/electron-inspect.ts index be99a31..9be4826 100644 --- a/packages/runtime/src/electron-inspect.ts +++ b/packages/runtime/src/electron-inspect.ts @@ -1,7 +1,8 @@ import { createHash } from "node:crypto" -import { constants, createReadStream, lstatSync, readlinkSync } from "node:fs" +import { constants, createReadStream } from "node:fs" import { access, lstat, readdir, readlink, realpath } from "node:fs/promises" import { basename, isAbsolute, join } from "node:path" +import { q } from "./electron-confine.ts" import { command as run } from "./electron-workarea.ts" // A desktop app the user picks, inspected before Kiframe ever runs it (PR 3b, design reviewed @@ -32,7 +33,7 @@ export interface DesktopApp { /** An app Kiframe won't add: said to the user as is. */ export class InspectError extends Error {} -const BUNDLE_ID = /^[A-Za-z][A-Za-z0-9-]*(\.[A-Za-z0-9-]+)+$/ +const BUNDLE_ID = /^[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)+$/ /** codesign on a large bundle (a verify reads it all) can take a while: never forever. */ const TIMEOUT = 60_000 @@ -53,8 +54,18 @@ export function placeRefusal(real: string): string | undefined { } /** A value of an Info.plist (undefined when it has none), read by plutil. */ -async function plistValue(plist: string, key: string): Promise { - const out = await run("/usr/bin/plutil", ["-extract", key, "raw", "-o", "-", plist], TIMEOUT) +async function plistValue( + plist: string, + key: string, + signal?: AbortSignal, +): Promise { + const out = await run( + "/usr/bin/plutil", + ["-extract", key, "raw", "-o", "-", plist], + TIMEOUT, + signal, + ) + signal?.throwIfAborted() // An empty value is no value (a build with an empty name: its fallback instead). const value = out.code === 0 ? out.stdout.trim() : "" return value === "" ? undefined : value @@ -85,18 +96,34 @@ export function parseSigning( /** The requirement a developer's signature must meet: their team, the signed identifier. */ export function teamRequirement(team: string, identifier: string): string { - const q = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` return `anchor apple generic and identifier ${q(identifier)} and certificate leaf[subject.OU] = ${q(team)}` } -/** Whether the bundle's signature is whole and its developer's (`codesign --verify --strict`). */ -export async function signatureHolds(path: string, signer: Signer): Promise { - if (signer.kind !== "team") return true +/** + * Whether the app at `path` is still the one approved: a developer's, its signature whole and + * theirs (`codesign --verify --strict`); a pinned build, the same digest. + */ +export async function signatureHolds( + path: string, + signer: Signer, + signal?: AbortSignal, +): Promise { + if (signer.kind === "pinned") { + try { + return (await codeDigest(path, signal)) === signer.digest + } catch (error) { + if (signal?.aborted === true) throw signal.reason + if (error instanceof InspectError) return false + throw error + } + } const out = await run( "/usr/bin/codesign", ["--verify", "--strict", `-R=${teamRequirement(signer.team, signer.identifier)}`, path], TIMEOUT, + signal, ) + signal?.throwIfAborted() return out.code === 0 } @@ -112,12 +139,12 @@ const DIGEST_VERSION = "kiframe-code-v1" * (absolute, or above the root at any step), or `"dangling"` (a step that isn't there: it leads * nowhere, so nowhere outside). */ -function resolveIn( +async function resolveIn( root: string, base: readonly string[], target: string, depth: number, -): string[] | "outside" | "dangling" { +): Promise { if (depth > 40 || isAbsolute(target)) return "outside" const parts = [...base] for (const step of target.split("/")) { @@ -130,15 +157,15 @@ function resolveIn( parts.push(step) let stat try { - stat = lstatSync(join(root, ...parts)) + stat = await lstat(join(root, ...parts)) } catch { return "dangling" } if (stat.isSymbolicLink()) { - const inner = resolveIn( + const inner = await resolveIn( root, parts.slice(0, -1), - readlinkSync(join(root, ...parts)), + await readlink(join(root, ...parts)), depth + 1, ) if (typeof inner === "string") return inner @@ -171,7 +198,7 @@ export async function codeDigest(picked: string, signal?: AbortSignal): Promise< if (entries > DIGEST_LIMITS.entries) throw tooLarge() if (stat.isSymbolicLink()) { const target = await readlink(full) - if (resolveIn(root, rel.slice(0, -1), target, 0) === "outside") { + if ((await resolveIn(root, rel.slice(0, -1), target, 0)) === "outside") { throw new InspectError(`that app links outside itself (${name}): Kiframe can't pin it`) } line(["link", name, target]) @@ -214,7 +241,8 @@ export async function signerOf( path: string, signal?: AbortSignal, ): Promise { - const signing = await run("/usr/bin/codesign", ["-dv", path], TIMEOUT) + const signing = await run("/usr/bin/codesign", ["-dv", path], TIMEOUT, signal) + signal?.throwIfAborted() const parsed = parseSigning(signing.code, signing.stderr) if (parsed.kind === "unread") throw new InspectError("that app's signature couldn't be read") if (parsed.kind === "team") return { ...parsed, signed: true } @@ -241,17 +269,21 @@ export async function inspectDesktopApp(picked: string, signal?: AbortSignal): P const place = placeRefusal(path) if (place !== undefined) throw new InspectError(place) const plist = join(path, "Contents", "Info.plist") - const bundleId = await plistValue(plist, "CFBundleIdentifier") + const bundleId = await plistValue(plist, "CFBundleIdentifier", signal) if (bundleId === undefined || !BUNDLE_ID.test(bundleId)) { throw new InspectError("that app has no bundle id Kiframe can use") } const framework = join(path, "Contents/Frameworks/Electron Framework.framework") - const electron = await plistValue(join(framework, "Resources/Info.plist"), "CFBundleVersion") + const electron = await plistValue( + join(framework, "Resources/Info.plist"), + "CFBundleVersion", + signal, + ) if (electron === undefined) { throw new InspectError("that isn't an Electron app: Kiframe drives Electron apps only") } - const exe = await plistValue(plist, "CFBundleExecutable") - if (exe === undefined || exe === "" || exe === "." || exe === ".." || exe.includes("/")) { + const exe = await plistValue(plist, "CFBundleExecutable", signal) + if (exe === undefined || exe === "." || exe === ".." || exe.includes("/")) { throw new InspectError("that app names no executable Kiframe can run") } const executable = join(path, "Contents", "MacOS", exe) @@ -271,7 +303,9 @@ export async function inspectDesktopApp(picked: string, signal?: AbortSignal): P "/usr/bin/codesign", ["-d", "--entitlements", "-", "--xml", path], TIMEOUT, + signal, ) + signal?.throwIfAborted() if (entitlements.code !== 0) { throw new InspectError("that app's entitlements couldn't be read") } @@ -281,12 +315,12 @@ export async function inspectDesktopApp(picked: string, signal?: AbortSignal): P ) } } - if (!(await signatureHolds(path, signer))) { + if (signer.kind === "team" && !(await signatureHolds(path, signer, signal))) { throw new InspectError("that app's signature is broken: reinstall it") } const [display, short, version] = await Promise.all( ["CFBundleDisplayName", "CFBundleName", "CFBundleShortVersionString"].map((key) => - plistValue(plist, key), + plistValue(plist, key, signal), ), ) const name = display ?? short ?? basename(path, ".app") diff --git a/packages/runtime/src/electron-trial.ts b/packages/runtime/src/electron-trial.ts index 6e3c139..bda321e 100644 --- a/packages/runtime/src/electron-trial.ts +++ b/packages/runtime/src/electron-trial.ts @@ -1,6 +1,6 @@ import type { Viewport } from "@kiframe/schema" import { ElectronLaunchError, launchElectron } from "./electron.ts" -import type { DesktopApp } from "./electron-inspect.ts" +import { type DesktopApp, signatureHolds } from "./electron-inspect.ts" /** * A trial's outcome, read structurally: it runs confined and is driven (`ok`); its window is a site @@ -22,10 +22,14 @@ export interface TrialOptions { * closed. A stop is thrown as the stop. */ export async function trialDesktopApp( - app: Pick, + app: Pick, opts: TrialOptions = {}, ): Promise { const origins = opts.origins ?? [] + // The build that was picked, nothing else (a dev build rebuilt meanwhile: said). + if (!(await signatureHolds(app.path, app.signer, opts.signal))) { + return { failed: "the app changed since it was picked: pick it again" } + } let target try { target = await launchElectron({ diff --git a/packages/runtime/src/electron-workarea.ts b/packages/runtime/src/electron-workarea.ts index c25aa1e..806e858 100644 --- a/packages/runtime/src/electron-workarea.ts +++ b/packages/runtime/src/electron-workarea.ts @@ -121,12 +121,18 @@ export function command( cmd: string, args: string[], timeoutMs: number, + signal?: AbortSignal, ): Promise<{ code: number; stdout: string; stderr: string }> { return new Promise((resolve) => { execFile( cmd, args, - { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } }, + { + timeout: timeoutMs, + maxBuffer: 16 * 1024 * 1024, + env: { ...process.env, LC_ALL: "C" }, + ...(signal !== undefined && { signal }), + }, (error, stdout, stderr) => { const code = error === null ? 0 : typeof error.code === "number" ? error.code : -1 resolve({ code: error?.killed === true ? -1 : code, stdout, stderr }) diff --git a/packages/runtime/src/electron.ts b/packages/runtime/src/electron.ts index 3a48992..ce55c92 100644 --- a/packages/runtime/src/electron.ts +++ b/packages/runtime/src/electron.ts @@ -90,8 +90,8 @@ async function exitedSoon(child: ChildProcess, ms: number): Promise { /** * What a launch that failed says, the one rule (every failure an ElectronLaunchError with its - * `why`, but a stop): stopped → the stop; not started → said; the app quit (whatever the attach - * was doing) → quit; already said → as is; anything else (Playwright, CDP) → a fixed phrase (never + * `why`, but a stop): stopped → the stop; not started → said; already said → as is; the app quit + * (whatever the attach was doing) → quit; anything else (Playwright, CDP) → a fixed phrase (never * its message: it can carry the debugging endpoint or a page's address), its detail on stderr for * Kiframe's own debugging only (KIFRAME_ELECTRON_DEBUG). */ @@ -103,8 +103,9 @@ export function launchFailure( if (state.spawnError !== undefined) { return new ElectronLaunchError(`the app couldn't be launched (${state.spawnError.message})`) } - if (state.quit) return new ElectronLaunchError(QUIT_EARLY, { why: "quit" }) + // Already said (a site, a page never loaded): kept, whatever the app did next. if (error instanceof ElectronLaunchError) return error + if (state.quit) return new ElectronLaunchError(QUIT_EARLY, { why: "quit" }) debug("launch", error) return new ElectronLaunchError( error instanceof errors.TimeoutError @@ -209,11 +210,8 @@ export async function attach( signal, ) } catch (error) { - if (!(error instanceof errors.TimeoutError)) throw error - // Out of time: said as the launch's own (never Playwright's call log, its endpoint). - if (left <= ATTACH_TRY_MS) { - throw new ElectronLaunchError("the app didn't answer in time (it may refuse automation)") - } + // Out of time (or another failure): the launch's one rule words it. + if (!(error instanceof errors.TimeoutError) || left <= ATTACH_TRY_MS) throw error } } } diff --git a/packages/runtime/test/electron-inspect.test.ts b/packages/runtime/test/electron-inspect.test.ts index 7dc9f7a..d27c610 100644 --- a/packages/runtime/test/electron-inspect.test.ts +++ b/packages/runtime/test/electron-inspect.test.ts @@ -19,6 +19,7 @@ import { inspectDesktopApp, parseSigning, placeRefusal, + signatureHolds, teamRequirement, } from "../src/electron-inspect.ts" import { trialDesktopApp } from "../src/electron-trial.ts" @@ -307,6 +308,26 @@ describe.runIf(process.platform === "darwin")("an app's inspection", { timeout: expect(readdirSync(join(work, "sandboxes"))).toEqual([]) }) + it("holds an app to the build that was picked (a pinned one rebuilt: changed)", async () => { + const path = withMode("Rebuilt", "plain") + const app = await inspectDesktopApp(path) + expect(await signatureHolds(app.path, app.signer)).toBe(true) + writeFileSync(join(path, "Contents/Resources/app/index.html"), "

rebuilt

") + expect(await signatureHolds(app.path, app.signer)).toBe(false) + // Never tried as the build that was picked. + expect( + await trialDesktopApp(app, { workDir: mkdtempSync(join(tmpdir(), "kiframe-el-work-")) }), + ).toEqual({ failed: "the app changed since it was picked: pick it again" }) + }) + + it("stops an inspection when asked", async () => { + const stopping = new AbortController() + stopping.abort() + await expect(inspectDesktopApp(bundle("Stop"), stopping.signal)).rejects.toMatchObject({ + name: "AbortError", + }) + }) + it("throws a stop as the stop", async () => { const app = await inspectDesktopApp(withMode("Stopped", "plain")) const stopping = new AbortController() diff --git a/packages/runtime/test/electron.test.ts b/packages/runtime/test/electron.test.ts index 676b1b4..c31d7c9 100644 --- a/packages/runtime/test/electron.test.ts +++ b/packages/runtime/test/electron.test.ts @@ -17,6 +17,7 @@ import { createRequire } from "node:module" import { tmpdir, userInfo } from "node:os" import { dirname, join } from "node:path" import { parseProjectYaml, parseScenarioYaml } from "@kiframe/schema" +import { errors } from "playwright" import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest" import { execFileSync, spawn, spawnSync } from "node:child_process" import { createServer, type Server } from "node:http" @@ -580,8 +581,12 @@ else setInterval(() => undefined, 60_000) expect( String(launchFailure(raw, { ...none, spawnError: new Error("ENOENT"), quit: true })), ).toMatch(/couldn't be launched \(ENOENT\)/) - expect(launchFailure(said, { ...none, quit: true })).toMatchObject({ why: "quit" }) + // Already said (a site to allow, a page never loaded) stays said, even if the app then quit. + const site = new ElectronLaunchError("site", { why: "site", site: "https://a.example" }) + expect(launchFailure(site, { ...none, quit: true })).toBe(site) expect(launchFailure(said, none)).toBe(said) + // Anything unworded (an attach that stalled) and the app gone: it quit. + expect(launchFailure(raw, { ...none, quit: true })).toMatchObject({ why: "quit" }) const worded = launchFailure(raw, none) expect(worded).toBeInstanceOf(ElectronLaunchError) expect(String(worded)).not.toMatch(/ws:\/\//) @@ -601,9 +606,16 @@ else setInterval(() => undefined, 60_000) () => undefined, (e: unknown) => e, ) - expect(error).toBeInstanceOf(ElectronLaunchError) - expect(String(error)).toMatch(/didn't answer in time/) + // Tried twice, then the timeout handed to the launch's one rule, which words it. expect(connections).toBe(2) + expect(error).toBeInstanceOf(errors.TimeoutError) + const worded = launchFailure(error, { + stopped: undefined, + spawnError: undefined, + quit: false, + }) + expect(worded).toBeInstanceOf(ElectronLaunchError) + expect(String(worded)).toMatch(/didn't answer in time/) } finally { silent.close() } diff --git a/packages/schema/src/apps.test.ts b/packages/schema/src/apps.test.ts index bb9d056..13dc0fa 100644 --- a/packages/schema/src/apps.test.ts +++ b/packages/schema/src/apps.test.ts @@ -298,6 +298,8 @@ describe("a desktop Electron app (design 2026-10-08)", () => { expect(issue({ x: desk({ args: ["--inspect=9229"] }) })).toMatch(/positional arguments only/) expect(issue({ x: desk({ args: ["-e"] }) })).toMatch(/positional arguments only/) expect(issue({ x: desk({ bundleId: "/Applications/Notes.app" }) })).toMatch(/a bundle id/) + // An underscore is a bundle id's (electron-builder's com.electron.my_app). + expect(issue({ x: desk({ bundleId: "com.electron.my_app" }) })).toBe("parsed") expect(issue({ x: desk({ bundleId: "notes" }) })).toMatch(/a bundle id/) expect(issue({ x: desk({ url: "https://a.dev" }) })).not.toBe("parsed") // Chromium trims an argument before telling a switch: spaces and control characters too. diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts index f57e0dd..fbee170 100644 --- a/packages/schema/src/project.ts +++ b/packages/schema/src/project.ts @@ -63,7 +63,8 @@ export const ElectronApp = z.strictObject({ bundleId: z .string() .max(255) - .regex(/^[A-Za-z][A-Za-z0-9-]*(\.[A-Za-z0-9-]+)+$/, "a bundle id (com.example.app)"), + // Underscores too: macOS runs such ids (electron-builder's com.electron.my_app). + .regex(/^[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)+$/, "a bundle id (com.example.app)"), /** * What the app opens: paths in the project's files/ (`files/demo-vault`; a copy is what it gets, * decided 2026-10-09), positional only, never a switch. From fc49d488b15f26f52256a815d5fb9f29a6726b78 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Fri, 9 Oct 2026 21:35:22 +0200 Subject: [PATCH 06/10] fix(runtime): say a quit over a generic failure and an unread build as such - only what the app showed (a site, a page never loaded) beats a quit - a build that can't be checked (codesign timed out, unreadable) is said as unread, never as changed; buildHolds says which - the stop honoured in the middle of one large file - one bundle id rule, shared with the schema; one quoting helper, named for the two languages it serves --- docs/OBJECT-MODEL.md | 2 +- packages/runtime/src/electron-confine.ts | 15 +++-- packages/runtime/src/electron-inspect.ts | 23 ++++--- packages/runtime/src/electron-trial.ts | 12 ++-- packages/runtime/src/electron.ts | 19 ++++-- packages/runtime/src/index.ts | 2 +- .../runtime/test/electron-inspect.test.ts | 65 +++++++++++++++++-- packages/runtime/test/electron.test.ts | 6 +- packages/schema/src/project.ts | 9 ++- 9 files changed, 111 insertions(+), 42 deletions(-) diff --git a/docs/OBJECT-MODEL.md b/docs/OBJECT-MODEL.md index 082e0e4..08b6b0e 100644 --- a/docs/OBJECT-MODEL.md +++ b/docs/OBJECT-MODEL.md @@ -230,7 +230,7 @@ One scene, three phases. The agent's own instructions say the same (`packages/st - **Confinement: a guard, sealed at launch (PR 2, redesigned after three review rounds, design reviewed).** Once the app settles (its windows unchanged 1.5 s), its main window is the last opened still open; its own scheme or dev server is learned from that window alone, then sealed (nothing a run does adds to it). A main window that isn't the app's own (a wrapper's unlisted site) refuses the launch ("list it in its origins"). From then a **guard** checks every navigation of every frame of every window as it happens: a page that isn't the app's own is stopped at once (a popup closed, any other frame blanked: never left for a read or a frame) and recorded; the step fails `off-app`. The app's own: a local `file:` in its bundle or sandbox (decoded, normalized), its sealed scheme or dev server, a listed https site (`sameApp`, never plain http), a blob of those. Stated (as a web run): the guard acts as a page commits, so a page that isn't the app's own has loaded (its scripts ran, with the app's own rights) before it's stopped: following a link the app offers is what its user does too (blocking before the load needs request interception, which never sees `file:`); and the runner's own navigation events name it as they do for web runs (origin and path, known secret values scrubbed). What the main window embeds as it opens (a frame of another site) is the app as shipped: its origin is the app's own. A main window stopped goes back to the app's page (no goto there to bring it back); a failed load is said as one. **One app per run:** a desktop app's run never shows a site (no goto, URL condition or web preset; a web interrupt rule's goto skipped, said), is confined while grounding too, and keeps no sign-in between runs yet. - **Windows (PR 2, known limits, to settle on real apps in PR 3 and PR 5: decided by the user 2026-10-08):** the main window is the last opened still open once the app's windows were unchanged for 1.5 s (a splash that stays up longer is taken for the main window); windows the app's main process opens are followed as popups are (a hidden helper window opened during a step may be followed: CDP can't tell it's hidden); only the launch window is sent back when it goes off-app (another window is closed); a stop that lands after its step ended counts against the next step. A window that opens blank gets a moment to load (as a web page does); a frame of a window the run isn't driving is said, never the step's failure. - **Every launch confined (PR 3a, 2026-10-09, design reviewed twice, measured):** on macOS each launch runs under Seatbelt (`sandbox-exec`, `electron-confine.ts`): writes only into its own sandbox; no reads of /Users, /Volumes, the temp folders, the user's home and the work area wherever they are (a home outside /Users) but its sandbox and its bundle; nothing run from its sandbox (the one place it writes: a program it drops there, a copied launcher), no `open`, `osascript`, `launchctl` nor Apple Events; no unix sockets (an agent's: ssh, Docker) but mDNSResponder; no preferences (cfprefsd), keychain (SecurityServer: a mock keychain instead, `--use-mock-keychain`) nor clipboard (the user's may hold a password just copied: an app's own copy and paste doesn't work in a run); the network on, for runs and trials alike (the app's backend, a wrapper's site: an updater can't touch the real app, nothing outside the sandbox being written; what an app could send is only what it can read here: its bundle, the system's files, its own sandbox). Chromium's own sandbox can't nest in it (`--no-sandbox`): the guard and this profile replace it. A canary before every launch, before the files are copied (a read the profile refuses must fail, one of its sandbox succeed: a confinement that runs nothing never passes); no `sandbox-exec`, or a canary failed: no launch (never unconfined). Off macOS: desktop apps don't launch (CI's tests only, unconfined). Measured with a probe app: every escape above `EPERM` (a hard link or a symlink to an outside file too), the app driven as usual. This replaces the trial's after-the-fact isolation check, rejected by review (an app that leaks does its harm as it starts). **What it doesn't stop (stated):** an app can still ask macOS to open a file or a URL (LaunchServices: no app starts without reaching it, measured), and what opens runs outside the confinement; it reaches services on this machine (localhost: a dev build's own server needs it). The app's own pages are its bundle and what it writes in its sandbox's home, profile and temp folders (a page it builds there from remote content counts as its own, as its cache already does); never the files/ copy. The confinement keeps an app from the user's data by accident (the minmux incident), not an app built to escape (one the user runs unconfined every day anyway). -- **Adding a desktop app (PR 3b, design reviewed 2026-10-09):** inspected first, statically (`electron-inspect.ts`: only `plutil` and `codesign` run): refused outside where an app can stay (a disk image, a temporary translocated copy, the Trash, inside another app), not Electron (its framework; no version gate: Figma reports 13.6.1), an executable that isn't its own file, a Mac App Store build (App Sandbox), a developer signature that doesn't hold (`codesign --verify --strict` against its team and signed identifier). Kept: its id, name, Electron's version, and its signer: a developer (team id) or, ad hoc and unsigned (the user's own builds), pinned to a digest of the whole bundle (a link leading outside it refused; at most 4 GB and 200,000 entries). Then **tried confined** (`electron-trial.ts`): launched with the network as in a run (an updater can't touch the real app: nothing outside the sandbox is written; a wrapper's offline page can't hide its site), its main window attached, closed; a wrapper's site is named (even unreached: the error page's unreachable URL, over CDP) and allowed by the user in a second trial. A main window that never loaded its own page (offline, a dev server not running) is refused at launch, said as such: never a window driven on Chromium's error page. An attach that hangs as the first page fails (measured 1 in 5; cause not found) is tried again within the launch's time (stated). codesign that fails or times out refuses the app (never read as unsigned or unsandboxed). Apps that allow one copy (Slack, VS Code, Discord) need Chromium's temp folder in the sandbox (`MAC_CHROMIUM_TMPDIR`): its lock's socket otherwise goes to the user's /var/folders, denied, and the app quits. +- **Adding a desktop app (PR 3b, design reviewed 2026-10-09):** inspected first, statically (`electron-inspect.ts`: only `plutil` and `codesign` run): refused outside where an app can stay (a disk image, a temporary translocated copy, the Trash, inside another app), not Electron (its framework; no version gate: Figma reports 13.6.1), an executable that isn't its own file, a Mac App Store build (App Sandbox), a developer signature that doesn't hold (`codesign --verify --strict` against its team and signed identifier). Kept: its id, name, Electron's version, and its signer: a developer (team id) or, ad hoc and unsigned (the user's own builds), pinned to a digest of the whole bundle (a link leading outside it refused; at most 4 GB and 200,000 entries). Then **tried confined** (`electron-trial.ts`): launched with the network as in a run (an updater can't touch the real app: nothing outside the sandbox is written; a wrapper's offline page can't hide its site), its main window attached, closed; a wrapper's site is named (even unreached: the error page's unreachable URL, over CDP) and allowed by the user in a second trial. A main window that never loaded its own page (offline, a dev server not running) is refused at launch, said as such: never a window driven on Chromium's error page. An attach that hangs as the first page fails (measured 1 in 5; cause not found) is tried again within the launch's time (stated). A trial runs only the build that was picked (its developer's signature, or its pinned digest, checked first; a change between that check and the launch is the user's own build tool's: holding every launch to its approval is PR 4's, with runs). codesign that fails or times out refuses the app (never read as unsigned or unsandboxed). Apps that allow one copy (Slack, VS Code, Discord) need Chromium's temp folder in the sandbox (`MAC_CHROMIUM_TMPDIR`): its lock's socket otherwise goes to the user's /var/folders, denied, and the app quits. - **The work area `~/.kiframe/`** (decided by the user 2026-10-09: deletable any time): `sandboxes/` (one per launch, 0700, never a link), each named after the Kiframe that made it (`kiframe-app---…`: owned from the moment it exists, no shared state file to race on); a crash sweep at start removes only sandboxes no running Kiframe owns (the processes pointing at them killed first), never another build's launch, and nothing when `ps` can't be read. Kept out of Time Machine and Spotlight. Approvals stay in the app's settings, takes in app data. - **A project's `files/`** (decided by the user 2026-10-09): what a desktop app opens. Copied into the sandbox at each launch by Kiframe's own walk (folders and regular files only: a link, a FIFO, a device refused; 500 MB, 20,000 files, depth 32; cloned where the disk can, times kept); `args` name paths in it only (`files/vault`, given as the copy's path); the app's edits stay in the copy (thrown away: every run from the same files). The copy is the project's content, never the app's own pages for the guard. Filled by the user in v0: the agent's file tools never reach `files/` (it may hold what runs inside the app: an editor's tasks, a vault's plugins). - **Measured on a fixture app (PR 2, 2026-10-08):** HOME, XDG and `--user-data-dir` hold; macOS's own places don't move: Electron's `appData` (`~/Library/Application Support`) and the app's preferences (`NSUserDefaults`, written by `cfprefsd` into the user's real `~/Library/Preferences/.plist`) stay the user's whatever `HOME` or `CFFIXED_USER_HOME` say. Hence the confinement (PR 3a, below): the preferences daemon is denied and writes outside the sandbox refused, so neither can be reached (a check after launch, the earlier plan, came too late). diff --git a/packages/runtime/src/electron-confine.ts b/packages/runtime/src/electron-confine.ts index 5273ae4..7b529f8 100644 --- a/packages/runtime/src/electron-confine.ts +++ b/packages/runtime/src/electron-confine.ts @@ -30,22 +30,25 @@ export interface Confinement { private: readonly string[] } -/** A string in the profile's language (quoted, its backslashes and quotes escaped). */ -export const q = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` +/** + * A double-quoted string, its backslashes and quotes escaped: what Seatbelt's profile language and + * codesign's requirement language both read (a change here must hold for both). + */ +export const quoted = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` /** The Seatbelt profile of a launch. Later rules win over earlier ones. */ export function seatbeltProfile(c: Confinement): string { - const readable = [c.sandbox, ...c.readable].map((p) => `(subpath ${q(p)})`).join(" ") + const readable = [c.sandbox, ...c.readable].map((p) => `(subpath ${quoted(p)})`).join(" ") return `(version 1) (allow default) (deny file-write*) -(allow file-write* (subpath ${q(c.sandbox)}) (literal "/dev/null") (literal "/dev/zero") (literal "/dev/dtracehelper") (regex #"^/dev/tty") (regex #"^/dev/fd/")) -(deny file-read-data (subpath "/Users") (subpath "/Volumes") (subpath "/private/var/folders") (subpath "/private/tmp") (subpath "/var/folders") (subpath "/tmp")${c.private.map((p) => ` (subpath ${q(p)})`).join("")}) +(allow file-write* (subpath ${quoted(c.sandbox)}) (literal "/dev/null") (literal "/dev/zero") (literal "/dev/dtracehelper") (regex #"^/dev/tty") (regex #"^/dev/fd/")) +(deny file-read-data (subpath "/Users") (subpath "/Volumes") (subpath "/private/var/folders") (subpath "/private/tmp") (subpath "/var/folders") (subpath "/tmp")${c.private.map((p) => ` (subpath ${quoted(p)})`).join("")}) (allow file-read-data ${readable}) (deny network-outbound (remote unix-socket)) (allow network-outbound (remote unix-socket (path-literal "/private/var/run/mDNSResponder"))) (deny appleevent-send) -(deny process-exec* (subpath ${q(c.sandbox)}) (literal "/usr/bin/open") (literal "/usr/bin/osascript") (literal "/bin/launchctl")) +(deny process-exec* (subpath ${quoted(c.sandbox)}) (literal "/usr/bin/open") (literal "/usr/bin/osascript") (literal "/bin/launchctl")) (deny mach-lookup (global-name "com.apple.cfprefsd.agent") (global-name "com.apple.SecurityServer") (global-name "com.apple.pasteboard.1")) ` } diff --git a/packages/runtime/src/electron-inspect.ts b/packages/runtime/src/electron-inspect.ts index 9be4826..082c369 100644 --- a/packages/runtime/src/electron-inspect.ts +++ b/packages/runtime/src/electron-inspect.ts @@ -2,7 +2,8 @@ import { createHash } from "node:crypto" import { constants, createReadStream } from "node:fs" import { access, lstat, readdir, readlink, realpath } from "node:fs/promises" import { basename, isAbsolute, join } from "node:path" -import { q } from "./electron-confine.ts" +import { BUNDLE_ID } from "@kiframe/schema" +import { quoted } from "./electron-confine.ts" import { command as run } from "./electron-workarea.ts" // A desktop app the user picks, inspected before Kiframe ever runs it (PR 3b, design reviewed @@ -33,8 +34,6 @@ export interface DesktopApp { /** An app Kiframe won't add: said to the user as is. */ export class InspectError extends Error {} -const BUNDLE_ID = /^[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)+$/ - /** codesign on a large bundle (a verify reads it all) can take a while: never forever. */ const TIMEOUT = 60_000 @@ -96,24 +95,24 @@ export function parseSigning( /** The requirement a developer's signature must meet: their team, the signed identifier. */ export function teamRequirement(team: string, identifier: string): string { - return `anchor apple generic and identifier ${q(identifier)} and certificate leaf[subject.OU] = ${q(team)}` + return `anchor apple generic and identifier ${quoted(identifier)} and certificate leaf[subject.OU] = ${quoted(team)}` } /** * Whether the app at `path` is still the one approved: a developer's, its signature whole and - * theirs (`codesign --verify --strict`); a pinned build, the same digest. + * theirs (`codesign --verify --strict`); a pinned build, the same digest. "unread" when that can't + * be told (codesign timed out or couldn't run; the bundle unreadable): never taken for a change. */ -export async function signatureHolds( +export async function buildHolds( path: string, signer: Signer, signal?: AbortSignal, -): Promise { +): Promise<"holds" | "changed" | "unread"> { if (signer.kind === "pinned") { try { - return (await codeDigest(path, signal)) === signer.digest + return (await codeDigest(path, signal)) === signer.digest ? "holds" : "changed" } catch (error) { - if (signal?.aborted === true) throw signal.reason - if (error instanceof InspectError) return false + if (error instanceof InspectError) return "unread" throw error } } @@ -124,7 +123,7 @@ export async function signatureHolds( signal, ) signal?.throwIfAborted() - return out.code === 0 + return out.code === 0 ? "holds" : out.code === -1 ? "unread" : "changed" } /** A pinned build's limits (it's read whole when picked). */ @@ -315,7 +314,7 @@ export async function inspectDesktopApp(picked: string, signal?: AbortSignal): P ) } } - if (signer.kind === "team" && !(await signatureHolds(path, signer, signal))) { + if (signer.kind === "team" && (await buildHolds(path, signer, signal)) !== "holds") { throw new InspectError("that app's signature is broken: reinstall it") } const [display, short, version] = await Promise.all( diff --git a/packages/runtime/src/electron-trial.ts b/packages/runtime/src/electron-trial.ts index bda321e..58926dd 100644 --- a/packages/runtime/src/electron-trial.ts +++ b/packages/runtime/src/electron-trial.ts @@ -1,6 +1,6 @@ import type { Viewport } from "@kiframe/schema" import { ElectronLaunchError, launchElectron } from "./electron.ts" -import { type DesktopApp, signatureHolds } from "./electron-inspect.ts" +import { buildHolds, type DesktopApp } from "./electron-inspect.ts" /** * A trial's outcome, read structurally: it runs confined and is driven (`ok`); its window is a site @@ -26,10 +26,12 @@ export async function trialDesktopApp( opts: TrialOptions = {}, ): Promise { const origins = opts.origins ?? [] - // The build that was picked, nothing else (a dev build rebuilt meanwhile: said). - if (!(await signatureHolds(app.path, app.signer, opts.signal))) { - return { failed: "the app changed since it was picked: pick it again" } - } + // The build that was picked, nothing else (a dev build rebuilt meanwhile: said). Checked before + // the launch only: a change in between is the user's own build tool's (stated). + const holds = await buildHolds(app.path, app.signer, opts.signal) + if (holds === "changed") return { failed: "the app changed since it was picked: pick it again" } + if (holds === "unread") + return { failed: "the app couldn't be checked (busy or unreadable): try again" } let target try { target = await launchElectron({ diff --git a/packages/runtime/src/electron.ts b/packages/runtime/src/electron.ts index ce55c92..2922e7e 100644 --- a/packages/runtime/src/electron.ts +++ b/packages/runtime/src/electron.ts @@ -90,8 +90,9 @@ async function exitedSoon(child: ChildProcess, ms: number): Promise { /** * What a launch that failed says, the one rule (every failure an ElectronLaunchError with its - * `why`, but a stop): stopped → the stop; not started → said; already said → as is; the app quit - * (whatever the attach was doing) → quit; anything else (Playwright, CDP) → a fixed phrase (never + * `why`, but a stop): stopped → the stop; not started → said; what the app showed (a site, a + * page never loaded) → as is; the app quit → quit (whatever else was said); already said → as is; + * anything else (Playwright, CDP) → a fixed phrase (never * its message: it can carry the debugging endpoint or a page's address), its detail on stderr for * Kiframe's own debugging only (KIFRAME_ELECTRON_DEBUG). */ @@ -103,9 +104,12 @@ export function launchFailure( if (state.spawnError !== undefined) { return new ElectronLaunchError(`the app couldn't be launched (${state.spawnError.message})`) } - // Already said (a site, a page never loaded): kept, whatever the app did next. - if (error instanceof ElectronLaunchError) return error + // What the app showed (a site, a page it never loaded): kept, whatever the app did next. + if (error instanceof ElectronLaunchError && (error.why === "site" || error.why === "unloaded")) { + return error + } if (state.quit) return new ElectronLaunchError(QUIT_EARLY, { why: "quit" }) + if (error instanceof ElectronLaunchError) return error debug("launch", error) return new ElectronLaunchError( error instanceof errors.TimeoutError @@ -140,11 +144,13 @@ const QUIT_EARLY = * showed or failed to load: a wrapper's own, to allow), or anything else. */ export class ElectronLaunchError extends Error { - readonly why: "quit" | "site" | "other" + readonly why: "quit" | "site" | "unloaded" | "other" readonly site: string | undefined constructor( message: string, - detail: { why: "quit" | "other" } | { why: "site"; site: string } = { why: "other" }, + detail: { why: "quit" | "unloaded" | "other" } | { why: "site"; site: string } = { + why: "other", + }, ) { super(message) this.why = detail.why @@ -508,6 +514,7 @@ export async function launchElectronWith( const where = shown.startsWith("chrome-error:") ? "its page" : placeOf(shown) throw new ElectronLaunchError( `the app couldn't load ${where} (offline? its server not running?)`, + { why: "unloaded" }, ) } // The guard: every frame of every window, every navigation, at once; one that isn't the app's diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 1e670d4..c141306 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -17,12 +17,12 @@ export { } from "./electron.ts" export { defaultWorkDir, sweepWorkArea, WorkAreaError } from "./electron-workarea.ts" export { + buildHolds, codeDigest, type DesktopApp, inspectDesktopApp, InspectError, type Signer, - signatureHolds, signerOf, } from "./electron-inspect.ts" export { trialDesktopApp, type TrialOptions, type TrialOutcome } from "./electron-trial.ts" diff --git a/packages/runtime/test/electron-inspect.test.ts b/packages/runtime/test/electron-inspect.test.ts index d27c610..5d20c9f 100644 --- a/packages/runtime/test/electron-inspect.test.ts +++ b/packages/runtime/test/electron-inspect.test.ts @@ -7,6 +7,7 @@ import { readdirSync, rmSync, symlinkSync, + truncateSync, writeFileSync, } from "node:fs" import { createRequire } from "node:module" @@ -14,12 +15,12 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { afterAll, beforeAll, describe, expect, it } from "vitest" import { + buildHolds, codeDigest, DIGEST_LIMITS, inspectDesktopApp, parseSigning, placeRefusal, - signatureHolds, teamRequirement, } from "../src/electron-inspect.ts" import { trialDesktopApp } from "../src/electron-trial.ts" @@ -143,7 +144,24 @@ describe("a pinned build's digest", () => { await expect(codeDigest(tree({ a: "1" }), stopping.signal)).rejects.toMatchObject({ name: "AbortError", }) - }) + // In the middle of one large file (400 MB, sparse): ended at once, never at its end. + const large = tree({}) + const file = join(large, "framework") + writeFileSync(file, "") + truncateSync(file, 400 * 1024 * 1024) + try { + const t0 = Date.now() + await codeDigest(large) + const full = Date.now() - t0 + const midway = new AbortController() + setTimeout(() => midway.abort(), full / 4) + const t1 = Date.now() + await expect(codeDigest(large, midway.signal)).rejects.toMatchObject({ name: "AbortError" }) + expect(Date.now() - t1).toBeLessThan(full / 2 + 50) + } finally { + rmSync(large, { recursive: true, force: true }) + } + }, 60_000) }) describe.runIf(process.platform === "darwin")("an app's inspection", { timeout: 60_000 }, () => { @@ -311,21 +329,58 @@ describe.runIf(process.platform === "darwin")("an app's inspection", { timeout: it("holds an app to the build that was picked (a pinned one rebuilt: changed)", async () => { const path = withMode("Rebuilt", "plain") const app = await inspectDesktopApp(path) - expect(await signatureHolds(app.path, app.signer)).toBe(true) + expect(await buildHolds(app.path, app.signer)).toBe("holds") writeFileSync(join(path, "Contents/Resources/app/index.html"), "

rebuilt

") - expect(await signatureHolds(app.path, app.signer)).toBe(false) + expect(await buildHolds(app.path, app.signer)).toBe("changed") // Never tried as the build that was picked. expect( await trialDesktopApp(app, { workDir: mkdtempSync(join(tmpdir(), "kiframe-el-work-")) }), ).toEqual({ failed: "the app changed since it was picked: pick it again" }) }) - it("stops an inspection when asked", async () => { + it("stops an inspection when asked, even in the middle of reading it", async () => { const stopping = new AbortController() stopping.abort() await expect(inspectDesktopApp(bundle("Stop"), stopping.signal)).rejects.toMatchObject({ name: "AbortError", }) + // Mid-read (the whole build hashed: hundreds of MB): ended at once. + const path = bundle("Midway") + const t0 = Date.now() + const full = await codeDigest(path).then(() => Date.now() - t0) + const midway = new AbortController() + setTimeout(() => midway.abort(), full / 4) + const t1 = Date.now() + await expect(codeDigest(path, midway.signal)).rejects.toMatchObject({ name: "AbortError" }) + expect(Date.now() - t1).toBeLessThan(full / 2 + 100) + }) + + it("says a build it can't read as unread, never as changed", async () => { + const path = withMode("Locked", "plain") + const app = await inspectDesktopApp(path) + const locked = join(path, "Contents/Resources/app/index.html") + chmodSync(locked, 0o000) + try { + expect(await buildHolds(app.path, app.signer)).toBe("unread") + expect( + await trialDesktopApp(app, { workDir: mkdtempSync(join(tmpdir(), "kiframe-el-work-")) }), + ).toEqual({ failed: "the app couldn't be checked (busy or unreadable): try again" }) + } finally { + chmodSync(locked, 0o644) + } + }) + + it("takes a bundle id with underscores (electron-builder's own)", async () => { + const app = bundle("Underscore", (a) => + execFileSync("plutil", [ + "-replace", + "CFBundleIdentifier", + "-string", + "com.electron.my_app", + join(a, "Contents/Info.plist"), + ]), + ) + expect((await inspectDesktopApp(app)).bundleId).toBe("com.electron.my_app") }) it("throws a stop as the stop", async () => { diff --git a/packages/runtime/test/electron.test.ts b/packages/runtime/test/electron.test.ts index c31d7c9..4fcc93c 100644 --- a/packages/runtime/test/electron.test.ts +++ b/packages/runtime/test/electron.test.ts @@ -585,7 +585,11 @@ else setInterval(() => undefined, 60_000) const site = new ElectronLaunchError("site", { why: "site", site: "https://a.example" }) expect(launchFailure(site, { ...none, quit: true })).toBe(site) expect(launchFailure(said, none)).toBe(said) - // Anything unworded (an attach that stalled) and the app gone: it quit. + // Anything else said (no window in time) or unworded (an attach that stalled), the app gone: + // it quit (the one-copy advice kept). + expect(launchFailure(said, { ...none, quit: true })).toMatchObject({ why: "quit" }) + const unloaded = new ElectronLaunchError("unloaded", { why: "unloaded" }) + expect(launchFailure(unloaded, { ...none, quit: true })).toBe(unloaded) expect(launchFailure(raw, { ...none, quit: true })).toMatchObject({ why: "quit" }) const worded = launchFailure(raw, none) expect(worded).toBeInstanceOf(ElectronLaunchError) diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts index fbee170..ca4c997 100644 --- a/packages/schema/src/project.ts +++ b/packages/schema/src/project.ts @@ -41,6 +41,9 @@ const HttpsOrigin = z return url !== null && url.protocol === "https:" && url.origin === s }, "an https origin (https://host, no path)") +/** A macOS bundle id's form (underscores too: macOS runs electron-builder's com.electron.my_app). */ +export const BUNDLE_ID = /^[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)+$/ + /** * A desktop app's argument as a path in the project's files/ (`files/a/b` → ["a", "b"]); undefined * for anything else (files/ itself, an empty, "." or ".." part: never outside it). @@ -60,11 +63,7 @@ export function filesPath(arg: string): string[] | undefined { export const ElectronApp = z.strictObject({ kind: z.literal("electron"), /** The macOS bundle id (`com.example.app`). */ - bundleId: z - .string() - .max(255) - // Underscores too: macOS runs such ids (electron-builder's com.electron.my_app). - .regex(/^[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)+$/, "a bundle id (com.example.app)"), + bundleId: z.string().max(255).regex(BUNDLE_ID, "a bundle id (com.example.app)"), /** * What the app opens: paths in the project's files/ (`files/demo-vault`; a copy is what it gets, * decided 2026-10-09), positional only, never a switch. From 6b567ace5b1b9f0e68cf5ecce588d0562be54115 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Fri, 9 Oct 2026 21:54:36 +0200 Subject: [PATCH 07/10] fix(runtime): keep a frame still loading as a desktop app opens What the main window embeds as it opens is read by a loading frame's src too: its first navigation uncommitted when the launch seals what's the app's own, it was stopped once it loaded (the macOS runner's flake). The single-instance test runs where its fix applies (macOS). --- packages/runtime/src/electron.ts | 17 ++++++++--- packages/runtime/test/electron.test.ts | 42 +++++++++++++++++++++----- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/packages/runtime/src/electron.ts b/packages/runtime/src/electron.ts index 2922e7e..4647a56 100644 --- a/packages/runtime/src/electron.ts +++ b/packages/runtime/src/electron.ts @@ -486,12 +486,21 @@ export async function launchElectronWith( const scheme = ownScheme(shown) if (scheme !== undefined) schemes.add(scheme) // What the main window embeds as it opens (a video, a widget) is part of the app as shipped. + // A frame still loading (its first navigation not committed: its url empty or about:blank) is + // read by its element's src, resolved against the page (else it'd be stopped once it loads). for (const frame of page.frames()) { - const url = URL.parse(frame.url()) - // https only (a listed site's rule: never plain http in the app's name). - if (frame !== page.mainFrame() && url !== null && url.protocol === "https:") { - embedded.add(url.origin) + if (frame === page.mainFrame()) continue + let shownIn = frame.url() + if (shownIn === "" || shownIn === "about:blank") { + const src = await frame + .frameElement() + .then((element) => element.getAttribute("src")) + .catch(() => null) + shownIn = src === null ? shownIn : (URL.parse(src, page.url())?.href ?? shownIn) } + const url = URL.parse(shownIn) + // https only (a listed site's rule: never plain http in the app's name). + if (url !== null && url.protocol === "https:") embedded.add(url.origin) } const own = { bundles, diff --git a/packages/runtime/test/electron.test.ts b/packages/runtime/test/electron.test.ts index 4fcc93c..36a43f4 100644 --- a/packages/runtime/test/electron.test.ts +++ b/packages/runtime/test/electron.test.ts @@ -125,9 +125,14 @@ beforeAll(async () => { ) secureServer = createHttpsServer( { key: readFileSync(join(certs, "key.pem")), cert: readFileSync(join(certs, "cert.pem")) }, - (_req, res) => { - res.writeHead(200, { "content-type": "text/html" }) - res.end("

A widget

") + (req, res) => { + // "/slow": answered after a while (a widget still loading as the app opens). + const answer = () => { + res.writeHead(200, { "content-type": "text/html" }) + res.end("

A widget

") + } + if (req.url === "/slow") setTimeout(answer, 3000) + else answer() }, ) await new Promise((resolve) => secureServer.listen(0, "127.0.0.1", resolve)) @@ -490,10 +495,14 @@ steps: } }, 60_000) - it("launches an app that allows one copy only (its lock in the sandbox, never /var/folders)", async () => { - const target = await launch(["single"]) - expect(await target.page.title()).toBe("Fixture notes") - }) + // macOS's fix (Linux runs unconfined, its lock's socket path under its own length limit). + it.runIf(process.platform === "darwin")( + "launches an app that allows one copy only (its lock in the sandbox, never /var/folders)", + async () => { + const target = await launch(["single"]) + expect(await target.page.title()).toBe("Fixture notes") + }, + ) it("names the site a wrapper app shows, even when it can't load it", async () => { const error = await launch(["wrapper"]).then( @@ -763,6 +772,25 @@ steps: [{ id: add, action: click, target: { by: role, role: button, name: Add no open = [] }) + it("keeps a frame the app embeds that's still loading as it opens", async () => { + // Its first navigation uncommitted when the launch seals what's its own: read by its src. + const slow = `${secure}slow` + const embeds = await launchElectron({ + ...base, + appArgs: [fixture, "hidden", `link=${slow}`, "embed-at-start", "trust-test-cert"], + settleMs: 300, + }) + open.push(embeds) + expect(await waitFor(() => embeds.page.frames().some((f) => f.url() === slow), 8000)).toBe(true) + await runScenario( + embeds.page, + parseScenarioYaml(`version: 1\nsteps: [{ id: a, action: pause, ms: 300 }]\n`), + project, + { electron: inTarget(embeds) }, + ) + expect(embeds.page.frames().some((f) => f.url() === slow)).toBe(true) + }) + it("keeps what the app embeds as it opens, and takes its window back from a site", async () => { // A frame of another (https) site shown at launch is the app as shipped: never a stop. const embeds = await launchElectron({ From 68d6655878b162cae994a4106091ccb342432aae Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Fri, 9 Oct 2026 22:13:00 +0200 Subject: [PATCH 08/10] fix(runtime): never stop a desktop app's own devtools opened mid-run A dev build that opens DevTools after Kiframe attached showed them in a window that appears blank first (never filtered as DevTools), and the guard stopped it, failing the step (the macOS runner's failure, then reproduced every time with DevTools opened late). The guard leaves devtools: pages alone, as the window list and emulation already did. --- packages/runtime/src/electron.ts | 3 +++ packages/runtime/test/electron.test.ts | 20 ++++++++++--------- .../test/fixtures/electron-app/main.cjs | 6 +++++- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/runtime/src/electron.ts b/packages/runtime/src/electron.ts index 4647a56..81ba0ba 100644 --- a/packages/runtime/src/electron.ts +++ b/packages/runtime/src/electron.ts @@ -540,6 +540,9 @@ export async function launchElectronWith( } // A load that failed (Chromium's error page): said as such by the step, never a place. if (url.startsWith("chrome-error:")) return true + // The app's own DevTools (a dev build opens them, at once or later: a window that appears + // blank, then shows them): never a page a run drives or films (no web page can show them). + if (isDevtools(url)) return true return allows(url) } const watch = (p: Page) => { diff --git a/packages/runtime/test/electron.test.ts b/packages/runtime/test/electron.test.ts index 36a43f4..c745dda 100644 --- a/packages/runtime/test/electron.test.ts +++ b/packages/runtime/test/electron.test.ts @@ -942,18 +942,20 @@ steps: ) }) - it("never counts the app's own DevTools as leaving it", async () => { - const target = await launch(["devtools"]) - await runScenario( - target.page, - parseScenarioYaml(`version: 1 + it("never counts the app's own DevTools as leaving it, opened at once or later", async () => { + for (const mode of ["devtools", "devtools-late"]) { + const target = await launch([mode]) + await runScenario( + target.page, + parseScenarioYaml(`version: 1 steps: - - { id: wait, action: pause, ms: 1500 } + - { id: wait, action: pause, ms: 3500 } - { id: add, action: click, target: { by: role, role: button, name: Add note } } `), - project, - { electron: inTarget(target), timeoutMs: 5000 }, - ) + project, + { electron: inTarget(target), timeoutMs: 5000 }, + ) + } }) it("follows a window that opens blank and loads later; a frame of another window is said only", async () => { diff --git a/packages/runtime/test/fixtures/electron-app/main.cjs b/packages/runtime/test/fixtures/electron-app/main.cjs index 33fc518..a663db1 100644 --- a/packages/runtime/test/fixtures/electron-app/main.cjs +++ b/packages/runtime/test/fixtures/electron-app/main.cjs @@ -78,12 +78,16 @@ if ( prefs.loadFile(join(__dirname, "other.html")) }, 2500) } - // "devtools": it opens DevTools itself (a dev build). + // "devtools": it opens DevTools itself (a dev build); "devtools-late": a while after it opens + // (once Kiframe is attached: a window that appears, then shows DevTools). if (process.argv.includes("devtools")) { win.webContents.once("did-finish-load", () => win.webContents.openDevTools({ mode: "detach" }), ) } + if (process.argv.includes("devtools-late")) { + setTimeout(() => win.webContents.openDevTools({ mode: "detach" }), 2500) + } // "elsewhere": its window shows a page that's never an app's own (a data: page). // "link=": the other site its links and windows go to (a test's local server); // "embed-at-start": it shows that site in a frame as it opens. From 728724e64f77900cd4a48be41fd741d63ac39590 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Fri, 9 Oct 2026 22:23:43 +0200 Subject: [PATCH 09/10] test(runtime): read the app under its devtools, never click it on the runner On the macOS runner a click under DevTools opened mid-run waits for stability until its timeout (a covered window's animation frames throttled); the test's property is DevTools never counted as leaving the app. Clicking with DevTools open is checked on a real app (BACKLOG, PR 5). --- docs/BACKLOG.md | 1 + packages/runtime/test/electron.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 00aef78..50ff9f0 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -246,6 +246,7 @@ Non-severe review findings deferred on purpose (see the review-round rule: only - **Copy and paste in a desktop app's demo:** the confinement denies the pasteboard (the user's clipboard may hold a password), so an app's own copy and paste does nothing in a run. A demo that needs it: a run-only pasteboard (a private named pasteboard can't be forced on an app) or typing the pasted text. - **What a confined app opens through LaunchServices:** an app can't start without `launchservicesd`, and through it it can open a file or a URL that then runs unconfined (a script it wrote, the user's browser). Stated in §0.9 (accident, not a hostile app); an Endpoint Security watcher or a per-run user account would close it. - **Desktop-app sweeps read processes more than once:** the startup sweep runs one `ps` per leftover sandbox (one `ps -axo pid=,lstart=` table would answer every owner), and a run's close reads the process table twice (`orphanedGroup` and `sweepProcesses`). Startup and close time only (#58 review). +- **Clicks in a desktop app under its own DevTools (macOS runner):** with DevTools opened mid-run over a hidden test window, a click waited for the button to be stable until its timeout on GitHub's macOS runner (animation frames throttled: the window covered); locally it clicks. The DevTools test reads instead of clicking; check clicks with DevTools open on a real app in PR 5. - **One launch helper:** browsers are launched by each entry point; nothing enforces an off-the-record context. A runtime-owned `launchBrowser()` / context factory would make it hard to bypass (the desktop app's main process). - **Secrets into contenteditable:** v0 writes secrets to inputs and textareas only (the value set on the approved element); a rich-text field is refused. - **Network exfiltration by the page itself:** a hostile page receives the typed password in its own field and can send it anywhere; that's the target app's own data. Out of scope for the vault (it only guarantees the value goes to the bound field on the allowed origins). diff --git a/packages/runtime/test/electron.test.ts b/packages/runtime/test/electron.test.ts index c745dda..7675df3 100644 --- a/packages/runtime/test/electron.test.ts +++ b/packages/runtime/test/electron.test.ts @@ -950,7 +950,7 @@ steps: parseScenarioYaml(`version: 1 steps: - { id: wait, action: pause, ms: 3500 } - - { id: add, action: click, target: { by: role, role: button, name: Add note } } + - { id: still, action: expect, that: { visible: { by: role, role: button, name: Add note } } } `), project, { electron: inTarget(target), timeoutMs: 5000 }, From b49f7f59fef155a42b80b31e610074fbb33e90dc Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Fri, 9 Oct 2026 22:54:09 +0200 Subject: [PATCH 10/10] fix(runtime): stop a desktop app's off-site frame at launch, blank a failed back - a frame still loading toward what isn't the app's when the launch seals is stopped then (never landing as a stop in a run's first step) - a main window whose back failed is blanked (never left showing the site; the run says to relaunch, as with no history), the failure's detail logged - CI logs desktop-app failures' detail (KIFRAME_ELECTRON_DEBUG) --- .github/workflows/ci.yml | 5 ++++- packages/runtime/src/electron.ts | 20 ++++++++++++++++-- packages/runtime/test/electron.test.ts | 29 +++++++++++++++++++++++--- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2022033..1a22e25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,8 +37,10 @@ jobs: - run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - run: xvfb-run -a pnpm test:coverage env: - # Slow shutdown phases of a desktop app's launch, said (the Electron target's evidence). + # Slow shutdown phases of a desktop app's launch, and its failures' detail, said (the + # Electron target's evidence). KIFRAME_ELECTRON_TIMING: "1" + KIFRAME_ELECTRON_DEBUG: "1" # The desktop app, built and launched under a virtual display. - name: Desktop end-to-end run: xvfb-run -a pnpm --filter @kiframe/desktop e2e @@ -66,3 +68,4 @@ jobs: - run: pnpm exec vitest run --project electron env: KIFRAME_ELECTRON_TIMING: "1" + KIFRAME_ELECTRON_DEBUG: "1" diff --git a/packages/runtime/src/electron.ts b/packages/runtime/src/electron.ts index 81ba0ba..abf46ae 100644 --- a/packages/runtime/src/electron.ts +++ b/packages/runtime/src/electron.ts @@ -488,6 +488,7 @@ export async function launchElectronWith( // What the main window embeds as it opens (a video, a widget) is part of the app as shipped. // A frame still loading (its first navigation not committed: its url empty or about:blank) is // read by its element's src, resolved against the page (else it'd be stopped once it loads). + const loading: { frame: Frame; url: string }[] = [] for (const frame of page.frames()) { if (frame === page.mainFrame()) continue let shownIn = frame.url() @@ -496,7 +497,11 @@ export async function launchElectronWith( .frameElement() .then((element) => element.getAttribute("src")) .catch(() => null) - shownIn = src === null ? shownIn : (URL.parse(src, page.url())?.href ?? shownIn) + const resolved = src === null ? undefined : URL.parse(src, page.url())?.href + if (resolved !== undefined) { + shownIn = resolved + loading.push({ frame, url: resolved }) + } } const url = URL.parse(shownIn) // https only (a listed site's rule: never plain http in the app's name). @@ -511,6 +516,11 @@ export async function launchElectronWith( origins: [...(opts.origins ?? []), ...embedded], } const allows = (url: string) => allowedPage(url, own) + // A frame still loading toward what isn't the app's: stopped now, at the launch (never as it + // lands in a run's first step). + for (const { frame, url } of loading) { + if (!allows(url)) await frame.goto("about:blank", { timeout: BACK_MS }).catch(() => undefined) + } if (!shown.startsWith("chrome-error:") && !allows(shown)) { const url = URL.parse(shown) throw new ElectronLaunchError( @@ -557,10 +567,16 @@ export async function launchElectronWith( ? frame.goto("about:blank") : p !== page ? p.close() - : // Committed is enough (never the page's load, nor a step's short timeout). + : // Committed is enough (never the page's load, nor a step's short timeout). No + // history, or a back that failed: blank (never left showing the site; the run + // then says to relaunch). p .goBack({ waitUntil: "commit", timeout: BACK_MS }) .then((back) => (back === null ? frame.goto("about:blank") : back)) + .catch((error: unknown) => { + debug("back", error) + return frame.goto("about:blank") + }) const done = action.then( () => undefined, () => undefined, diff --git a/packages/runtime/test/electron.test.ts b/packages/runtime/test/electron.test.ts index 7675df3..638a083 100644 --- a/packages/runtime/test/electron.test.ts +++ b/packages/runtime/test/electron.test.ts @@ -137,9 +137,14 @@ beforeAll(async () => { ) await new Promise((resolve) => secureServer.listen(0, "127.0.0.1", resolve)) secure = `https://127.0.0.1:${(secureServer.address() as AddressInfo).port}/` - server = createServer((_req, res) => { - res.writeHead(200, { "content-type": "text/html" }) - res.end("

Another site

") + server = createServer((req, res) => { + // "/slow": answered after a while (a frame still loading as the app opens). + const answer = () => { + res.writeHead(200, { "content-type": "text/html" }) + res.end("

Another site

") + } + if (req.url === "/slow") setTimeout(answer, 3000) + else answer() }) await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) other = `http://127.0.0.1:${(server.address() as AddressInfo).port}/` @@ -791,6 +796,24 @@ steps: [{ id: add, action: click, target: { by: role, role: button, name: Add no expect(embeds.page.frames().some((f) => f.url() === slow)).toBe(true) }) + it("stops a frame loading toward another site as the app opens, never in the run", async () => { + // Its first navigation uncommitted when the launch seals: stopped then, at the launch. + const slow = `${other}slow` + const plain = await launchElectron({ + ...base, + appArgs: [fixture, "hidden", `link=${slow}`, "embed-at-start"], + settleMs: 300, + }) + open.push(plain) + await runScenario( + plain.page, + parseScenarioYaml(`version: 1\nsteps: [{ id: a, action: pause, ms: 4000 }]\n`), + project, + { electron: inTarget(plain) }, + ) + expect(plain.page.frames().some((f) => f.url() === slow)).toBe(false) + }) + it("keeps what the app embeds as it opens, and takes its window back from a site", async () => { // A frame of another (https) site shown at launch is the app as shipped: never a stop. const embeds = await launchElectron({