From 1ef054bd2f9e0c93007ff4491c3cdc635bbff4be Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Fri, 9 Oct 2026 14:06:56 +0200 Subject: [PATCH 1/3] feat(runtime)!: confine every desktop-app launch under seatbelt Each launch runs under macOS Seatbelt: it writes only into its own sandbox, never reads the user's folders, runs nothing it drops there, and reaches neither preferences, the keychain, the clipboard nor other agents' sockets. A canary checks the confinement before every launch (one read refused, one allowed); without it, no launch. A project's files/ is copied into each launch's sandbox (links, devices and oversized trees refused); the app's arguments name paths in it. Sandboxes live in ~/.kiframe/sandboxes, each named after the Kiframe that made it, so a crash sweep removes only what no running Kiframe owns. BREAKING CHANGE: a desktop app's args must be paths in the project's files/ (files/); any other argument no longer parses. --- docs/BACKLOG.md | 2 + docs/OBJECT-MODEL.md | 7 +- packages/runtime/src/electron-confine.ts | 188 ++++++++ packages/runtime/src/electron-workarea.ts | 271 +++++++++++ packages/runtime/src/electron.ts | 299 ++++++------ packages/runtime/src/index.ts | 3 +- packages/runtime/test/electron.test.ts | 441 +++++++++++++++--- .../runtime/test/fixtures/crash-launch.ts | 22 + .../test/fixtures/electron-app/main.cjs | 60 +++ packages/schema/src/apps.test.ts | 19 +- packages/schema/src/project.ts | 20 +- 11 files changed, 1094 insertions(+), 238 deletions(-) create mode 100644 packages/runtime/src/electron-confine.ts create mode 100644 packages/runtime/src/electron-workarea.ts create mode 100644 packages/runtime/test/fixtures/crash-launch.ts diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index a73ca14..f8dab09 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -243,6 +243,8 @@ Non-severe review findings deferred on purpose (see the review-round rule: only ## Vault hardening (M1-7) - **Electron targets:** an app attached over CDP (F4) runs in its own persistent profile: no ephemeral context there. The app's own storage keeps whatever the scene typed; document it for Electron projects and prefer a throwaway app profile (`--user-data-dir` per run). +- **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. - **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 99e661e..8535bee 100644 --- a/docs/OBJECT-MODEL.md +++ b/docs/OBJECT-MODEL.md @@ -226,10 +226,13 @@ One scene, three phases. The agent's own instructions say the same (`packages/st **Electron apps (designed 2026-10-08, design reviewed; moved into v0).** `{ kind: "electron", bundleId, args?, origins?, viewport }`: - **Named by bundle id, never a path or a program** (a project may come from someone else): the desktop app finds the bundle on each machine and keeps the user's approval in its own settings (bundle id, real path, signing team); only `.app` bundles holding `Electron Framework.framework`; App Store (sandboxed) apps refused. `args` are positional only (no switches). `origins`: the https sites a wrapper app (Slack, Notion) shows as its own. - **No address**: a goto or a URL condition on it is refused; a scene starting in it opens on its first window; it takes no secrets in v0 (the user signs in by hand: the handover). -- **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 verified when the app is added: after a trial launch its state must be inside the sandbox, else refused. Its process group killed after each run, leftovers swept. +- **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. -- **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. The trial launch therefore checks both: the app's state inside the sandbox, and its preferences file unchanged; else the user is told. +- **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 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). +- **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). - **Stated limits:** native dialogs and menus are unreachable; links open in the user's real browser; a sign-in through the browser with an app-scheme callback isn't supported in v0; the keychain and an app's macOS preferences are the user's (an app reading its own keychain item signs in as them); a window built from several views films one view. - **Built in steps** (epic `epic/electron-targets`): the format (2026-10-08), the launcher and target, the desktop approval and trial, the studio and batch on it, a real pass on a sandboxed minmux. diff --git a/packages/runtime/src/electron-confine.ts b/packages/runtime/src/electron-confine.ts new file mode 100644 index 0000000..af29ff1 --- /dev/null +++ b/packages/runtime/src/electron-confine.ts @@ -0,0 +1,188 @@ +import { execFile } from "node:child_process" +import { constants, existsSync, rmSync, writeFileSync } from "node:fs" +import { copyFile, lstat, mkdir, readdir, utimes } from "node:fs/promises" +import { join } from "node:path" +import { filesPath } from "@kiframe/schema" + +// A desktop app's launch confined by macOS's Seatbelt (`sandbox-exec`; measured on a fixture app, +// 2026-10-09: every escape below refused, the app driven as usual). Its own sandbox is the only +// place it writes; it never reads the user's folders, never starts a program outside the +// confinement (LaunchServices, AppleScript, launchd), never reaches the user's preferences or +// keychain (a mock keychain instead), never connects to the user's agents' sockets. Chromium's own +// sandbox can't nest in it (turned off: the guard and this profile replace it). + +/** Where the confinement lives (macOS); absent: desktop apps can't be launched (never unconfined). */ +export const SANDBOX_EXEC = "/usr/bin/sandbox-exec" + +/** Kiframe's own switches to an app run under the profile (after the project's arguments). */ +export const CONFINED_SWITCHES = ["--no-sandbox", "--use-mock-keychain"] + +export interface Confinement { + /** The launch's sandbox (real path): the only place written, read in full. */ + sandbox: string + /** What the app reads besides: its bundle (real paths). */ + readable: readonly string[] + /** + * The user's own folders, never read (real paths: the home and Kiframe's work area), wherever + * 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). */ + network: "all" | "loopback" +} + +/** A string in the profile's language (quoted, its backslashes and quotes escaped). */ +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 { + const readable = [c.sandbox, ...c.readable].map((p) => `(subpath ${q(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-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 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")))' : ""} +` +} + +/** A confinement that can't be used: said, and no desktop app is launched (never unconfined). */ +export class ConfinementError extends Error {} + +/** Whether a program confined by `c` reads `file` (its content, exactly). */ +function readsUnder(c: Confinement, file: string, content: string): Promise { + return new Promise((resolve) => { + execFile( + SANDBOX_EXEC, + ["-p", seatbeltProfile(c), "/bin/cat", file], + { timeout: 5000 }, + (error, stdout) => resolve(error === null && stdout === content), + ) + }) +} + +/** + * Before each launch: the confinement still holds (a canary: a file the profile denies isn't + * read, while one in the sandbox is: a confinement that runs nothing never passes for one that + * refuses). Missing or broken (a macOS that removed or changed it): refused. + */ +export async function checkConfinement(c: Confinement, denied: string): Promise { + if (!existsSync(SANDBOX_EXEC)) { + throw new ConfinementError( + "macOS's sandbox-exec is missing: Kiframe can't confine desktop apps", + ) + } + const allowed = join(c.sandbox, "canary.txt") + let deniedRead: boolean + let allowedRead: boolean + try { + writeFileSync(denied, "canary") + writeFileSync(allowed, "canary") + ;[deniedRead, allowedRead] = await Promise.all([ + readsUnder(c, denied, "canary"), + readsUnder(c, allowed, "canary"), + ]) + } catch (error) { + throw new ConfinementError( + `the confinement couldn't be checked (${(error as { code?: string }).code ?? String(error)})`, + ) + } finally { + rmSync(allowed, { force: true }) + rmSync(denied, { force: true }) + } + if (deniedRead) { + throw new ConfinementError( + "the confinement didn't hold (a canary was read): desktop apps are off", + ) + } + if (!allowedRead) { + throw new ConfinementError( + "the confinement doesn't run programs (its own sandbox unread): desktop apps are off", + ) + } +} + +/** A project's files/ too large to copy at each launch, or holding what can't be copied safely. */ +export class FilesError extends Error {} + +/** The copy's limits (it's made at every launch). */ +export const FILES_LIMITS = { bytes: 500 * 1024 * 1024, files: 20_000, depth: 32 } + +/** + * A project's `files/` copied into a launch's sandbox (what the app opens: every run from the same + * files, its edits thrown away). Own walk, never a library copy: only folders and regular files + * (a link, a FIFO, a socket, a device refused: never followed, never opened), within the limits; + * cloned where the disk can (APFS: near instant), times kept (a run sees the files as they are). + */ +export async function copyFiles(from: string, to: string): Promise { + let bytes = 0 + let entries = 0 + const tooLarge = () => + new FilesError("files/ is too large to copy at each launch (500 MB, 20,000 entries)") + const walk = async (src: string, dst: string, depth: number, rel: string): Promise => { + if (depth > FILES_LIMITS.depth) throw new FilesError(`files/${rel} is nested too deep`) + await mkdir(dst, { recursive: true }) + for (const name of await readdir(src)) { + const s = join(src, name) + const d = join(dst, name) + const r = rel === "" ? name : `${rel}/${name}` + const stat = await lstat(s) + // Folders count too (a tree of empty ones is as slow to copy and remove). + entries += 1 + if (entries > FILES_LIMITS.files) throw tooLarge() + if (stat.isDirectory()) { + await walk(s, d, depth + 1, r) + } else if (stat.isFile()) { + bytes += stat.size + if (bytes > FILES_LIMITS.bytes) throw tooLarge() + await copyFile(s, d, constants.COPYFILE_FICLONE) + // copyFile follows a link: an entry swapped for one meanwhile is refused (still the file + // that was checked, never what a link points at). + const after = await lstat(s) + if (!after.isFile() || after.ino !== stat.ino || after.dev !== stat.dev) { + throw new FilesError(`files/${r} changed while it was copied: try again`) + } + await utimes(d, stat.atime, stat.mtime) + } else { + throw new FilesError(`files/${r} isn't a file or a folder (a link?): only those are copied`) + } + } + } + try { + // files/ itself a real folder (a shared project's files/ linked to the user's documents: never + // followed). + const root = await lstat(from) + if (!root.isDirectory()) { + throw new FilesError( + "the project's files/ isn't a folder (a link?): only a real one is copied", + ) + } + await walk(from, to, 0, "") + } catch (error) { + if (error instanceof FilesError) throw error + const code = (error as { code?: string }).code + throw new FilesError( + code === "ENOENT" && !existsSync(from) + ? "the project has no files/ folder (what its arguments name)" + : `files/ couldn't be copied (${code ?? String(error)})`, + ) + } +} + +/** + * A launch argument (the project's `files/`) as the app gets it: the copy's absolute path in + * the sandbox. Anything else refused (the schema allows nothing else). + */ +export function argumentIn(arg: string, filesCopy: string): string { + const parts = filesPath(arg) + if (parts === undefined) { + throw new FilesError(`"${arg}": a desktop app opens only what's in the project's files/`) + } + return join(filesCopy, ...parts) +} diff --git a/packages/runtime/src/electron-workarea.ts b/packages/runtime/src/electron-workarea.ts new file mode 100644 index 0000000..b8e4354 --- /dev/null +++ b/packages/runtime/src/electron-workarea.ts @@ -0,0 +1,271 @@ +import { execFile } from "node:child_process" +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + realpathSync, + writeFileSync, +} from "node:fs" +import { readdir, readlink, rm } from "node:fs/promises" +import { homedir } from "node:os" +import { basename, dirname, join } from "node:path" + +// Kiframe's work area for desktop apps (decided by the user 2026-10-09): `~/.kiframe/`, safe to +// delete at any time. Each launch's sandbox is a folder in `sandboxes/` whose name says which +// Kiframe made it (`kiframe-app---…`: owned from the moment it exists, no state file +// to race on), so what a crash left is swept at the next start, never another running Kiframe's (a +// dev and a release build share the folder). + +/** The default work area. */ +export function defaultWorkDir(): string { + return join(homedir(), ".kiframe") +} + +/** A work area that can't be used (a link, another user's, not a folder): said as is. */ +export class WorkAreaError extends Error {} + +const prepared = new Set() + +/** + * `work`/sandboxes made (or checked): real folders (never links), this user's, readable by them + * alone (0700); the work area kept out of backups and Spotlight (once per process). Returns the + * sandboxes folder's real path. + */ +export function sandboxesOf(work: string): string { + const sandboxes = join(work, "sandboxes") + for (const dir of [work, sandboxes]) { + let stat + try { + mkdirSync(dir, { recursive: true, mode: 0o700 }) + stat = lstatSync(dir) + } catch (error) { + throw new WorkAreaError( + `${dir} can't be made (${(error as { code?: string }).code ?? String(error)})`, + ) + } + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new WorkAreaError(`${dir} isn't a folder (a link?): Kiframe uses only a real one`) + } + if (process.getuid !== undefined && stat.uid !== process.getuid()) { + throw new WorkAreaError(`${dir} belongs to another user`) + } + if ((stat.mode & 0o777) !== 0o700) chmodSync(dir, 0o700) + } + if (!prepared.has(work)) { + prepared.add(work) + // Never indexed nor backed up (an app's throwaway data). Best effort: said by no one. + try { + writeFileSync(join(work, ".metadata_never_index"), "") + } catch { + // read-only? the launch says it next + } + if (process.platform === "darwin") { + execFile("tmutil", ["addexclusion", work], () => undefined) + } + } + return realpathSync(sandboxes) +} + +/** + * A new sandbox folder, owned by this process from the moment it exists (its name says so). Its + * real path (a file: page's path is compared with it). + */ +export async function newSandbox(work: string): Promise { + const sandboxes = sandboxesOf(work) + const owner = await self() + return realpathSync(mkdtempSync(join(sandboxes, `${PREFIX}${owner.pid}-${owner.started}-`))) +} + +const PREFIX = "kiframe-app-" + +/** A sandbox folder this module makes: `kiframe-app-*` directly in the work area's sandboxes. */ +export function isSandbox(path: string, work: string): boolean { + try { + const real = realpathSync(path) + return ( + dirname(real) === realpathSync(join(work, "sandboxes")) && + basename(real).startsWith(PREFIX) && + !lstatSync(path).isSymbolicLink() + ) + } catch { + return false + } +} + +/** Who started a launch: a Kiframe process, by its id and when it started (ids are reused). */ +interface Owner { + pid: number + started: number +} + +/** A sandbox's owner, from its name (none: an older or foreign name, owned by no one). */ +function ownerOf(name: string): Owner | undefined { + const match = /^kiframe-app-(\d+)-(\d+)-/.exec(name) + return match === null ? undefined : { pid: Number(match[1]), started: Number(match[2]) } +} + +/** A command's output (undefined when it fails; lsof's "nothing found" is an answer). */ +export function run(command: string, args: string[]): Promise { + return new Promise((resolve) => { + execFile( + command, + 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) + }, + ) + }) +} + +/** + * When a process started, in seconds, from `ps`: undefined when it isn't running, null when `ps` + * can't be read (nothing can be said of any owner). + */ +async function startOf(pid: number): Promise { + const out = await run("ps", ["-o", "lstart=", "-p", String(pid)]) + if (out === undefined) return null + const at = Date.parse(out.trim()) + return Number.isNaN(at) ? undefined : Math.round(at / 1000) +} + +let me: Promise | undefined +/** This process as an owner. */ +function self(): Promise { + me ??= startOf(process.pid).then((started) => ({ + pid: process.pid, + started: started ?? Math.round(Date.now() / 1000 - process.uptime()), + })) + return me +} + +/** + * Whether an owner still runs: its id alive and started when it was noted (never a reused id, this + * process's own included: a note from before a reboot can carry it). Null when `ps` can't say. + */ +async function alive(owner: Owner): Promise { + const mine = await self() + if (owner.pid === mine.pid) return owner.started === mine.started + const started = await startOf(owner.pid) + if (started === null) return null + return started !== undefined && Math.abs(started - owner.started) <= 2 +} + +/** + * Every process's working folder: Linux's /proc (quick), else `lsof` (macOS: no /proc); undefined + * when it can't be read. + */ +async function workingDirs(): Promise<[number, string][] | undefined> { + if (process.platform === "linux") { + const out: [number, string][] = [] + try { + for (const entry of await readdir("/proc")) { + if (!/^\d+$/.test(entry)) continue + try { + out.push([Number(entry), await readlink(`/proc/${entry}/cwd`)]) + } catch { + // gone, or another user's + } + } + return out + } catch { + return undefined + } + } + const listed = await run("lsof", ["-a", "-d", "cwd", "-Fpn"]) + if (listed === undefined) return undefined + const out: [number, string][] = [] + let pid: number | undefined + for (const line of listed.split("\n")) { + if (line.startsWith("p")) pid = Number(line.slice(1)) + else if (line.startsWith("n") && pid !== undefined) out.push([pid, line.slice(1)]) + } + return out +} + +/** + * Kills every process (never this one) that points at `under`: its command line names it (an + * app's helpers carry `--user-data-dir=/profile`), or it works in it (a helper detached + * with its cwd there); and the groups of the app's own processes (those with its profile: what + * stays in the group after a crash, pointing nowhere). Unread: said. + */ +export async function sweepProcesses( + unders: string | readonly string[], +): Promise<{ swept: number; unread: boolean }> { + const all = typeof unders === "string" ? [unders] : unders + if (all.length === 0) return { swept: 0, unread: false } + const [ps, cwds] = await Promise.all([run("ps", ["-axo", "pid=,pgid=,command="]), workingDirs()]) + const table: { pid: number; pgid: number; command: string }[] = [] + for (const line of ps?.split("\n") ?? []) { + const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line) + if (match !== null) { + table.push({ pid: Number(match[1]), pgid: Number(match[2]), command: match[3] ?? "" }) + } + } + const pids = new Set() + const groups = new Set() + const within = (path: string) => + all.some((under) => path === under || path.startsWith(`${under}/`)) + for (const p of table) { + if (!all.some((under) => p.command.includes(under))) continue + pids.add(p.pid) + if (all.some((under) => p.command.includes(`--user-data-dir=${under}/`))) groups.add(p.pgid) + } + for (const [pid, cwd] of cwds ?? []) if (within(cwd)) pids.add(pid) + // Never this process's own group. + const own = table.find((p) => p.pid === process.pid)?.pgid + if (own !== undefined) groups.delete(own) + for (const p of table) if (groups.has(p.pgid)) pids.add(p.pid) + pids.delete(process.pid) + for (const p of pids) { + try { + process.kill(p, "SIGKILL") + } catch { + // gone + } + } + return { swept: pids.size, unread: ps === undefined || cwds === undefined } +} + +/** + * At the host's start: every sandbox in the work area that no running Kiframe owns (a crash left + * it) has the processes pointing at it killed and is removed. Another running Kiframe's launches + * are left alone; when `ps` can't be read nothing is swept (an owner can't be told dead). Only this + * module's sandboxes are ever touched. + */ +export async function sweepWorkArea(work: string): Promise { + if (!existsSync(join(work, "sandboxes"))) return 0 + const sandboxes = sandboxesOf(work) + if ((await startOf(process.pid)) === null) return 0 + const dead: string[] = [] + for (const name of readdirSync(sandboxes)) { + const path = join(sandboxes, name) + if (!isSandbox(path, work)) continue + const owner = ownerOf(name) + const live = owner === undefined ? false : await alive(owner) + // ps unreadable meanwhile: nothing said dead. + if (live === null) return 0 + if (!live) dead.push(realpathOr(path)) + } + // One read of the processes for all of them (lsof is slow). + await sweepProcesses(dead) + for (const path of dead) { + await rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch( + () => undefined, + ) + } + return dead.length +} + +/** A path's real path, or the path itself when it can't be resolved (gone). */ +export function realpathOr(path: string): string { + try { + return realpathSync(path) + } catch { + return path + } +} diff --git a/packages/runtime/src/electron.ts b/packages/runtime/src/electron.ts index d961b57..2821d29 100644 --- a/packages/runtime/src/electron.ts +++ b/packages/runtime/src/electron.ts @@ -1,17 +1,28 @@ -import { execFile, spawn, type ChildProcess } from "node:child_process" +import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process" import { posix } from "node:path" -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - realpathSync, - writeFileSync, -} from "node:fs" -import { readdir, readlink, rm } from "node:fs/promises" -import { tmpdir } from "node:os" -import { basename, dirname, join, sep } from "node:path" +import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs" +import { rm } from "node:fs/promises" +import { userInfo } from "node:os" +import { join, sep } from "node:path" import { sameApp, type Viewport } from "@kiframe/schema" +import { + ConfinementError, + CONFINED_SWITCHES, + argumentIn, + checkConfinement, + copyFiles, + FilesError, + SANDBOX_EXEC, + seatbeltProfile, +} from "./electron-confine.ts" +import { + defaultWorkDir, + newSandbox, + realpathOr, + run, + sweepProcesses, + WorkAreaError, +} from "./electron-workarea.ts" import { untilStopped } from "./look.ts" import { chromium, @@ -95,14 +106,30 @@ export interface ElectronLaunch { executable: string /** Its `.app` bundle: a `file:` page inside it is the app's own. */ bundle?: string - /** What the app opens (positional: the project's `args`). */ + /** What the app opens: paths in the project's files/ (`files/demo-vault`), given as the copy's. */ args?: readonly string[] + /** The project's files/ folder: copied into the sandbox at each launch (what `args` name). */ + files?: string + /** Kiframe's work area (default `~/.kiframe`): the sandboxes and the launches' state. */ + 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`). */ origins?: readonly string[] - /** A file noting the launches still running: what a crash left is swept at the next start. */ - stateFile?: string signal?: AbortSignal /** * How long its windows must stay as they are before its main window is chosen (default 1.5 s: a @@ -147,10 +174,34 @@ export interface ElectronTarget { close: () => Promise<{ unread: boolean }> } -/** A fresh sandbox: the app's HOME (and macOS's), its XDG folders, its temp and its profile. */ -function makeSandbox(): { root: string; env: NodeJS.ProcessEnv; profile: string } { - // The real path: macOS's /var is a link (a file: page's path is compared with it). - const root = realpathSync(mkdtempSync(join(tmpdir(), "kiframe-app-"))) +/** Its own process group (killed whole), no terminal; its output read for the port only. */ +function spawnOptions(sandbox: { root: string; env: NodeJS.ProcessEnv }): SpawnOptions { + return { + detached: true, + env: sandbox.env, + stdio: ["ignore", "ignore", "pipe"], + cwd: sandbox.root, + } +} + +/** + * A fresh sandbox in the work area: the app's HOME (and macOS's), XDG, temp, profile, files. One + * that can't be made is said (and nothing of it kept). + */ +async function makeSandbox(work: string): Promise<{ + root: string + env: NodeJS.ProcessEnv + profile: string + home: string + files: string +}> { + let root: string + try { + root = await newSandbox(work) + } catch (error) { + if (error instanceof WorkAreaError) throw new ElectronLaunchError(error.message) + throw new ElectronLaunchError(`Kiframe's work area can't be used (${String(error)})`) + } const dirs = { home: join(root, "home"), config: join(root, "home", ".config"), @@ -160,7 +211,12 @@ function makeSandbox(): { root: string; env: NodeJS.ProcessEnv; profile: string tmp: join(root, "tmp"), profile: join(root, "profile"), } - for (const dir of Object.values(dirs)) mkdirSync(dir, { recursive: true }) + try { + for (const dir of Object.values(dirs)) mkdirSync(dir, { recursive: true }) + } catch (error) { + await rm(root, { recursive: true, force: true }).catch(() => undefined) + throw new ElectronLaunchError(`the app's sandbox can't be made (${String(error)})`) + } const env: NodeJS.ProcessEnv = {} for (const name of PASSED_ENV) if (process.env[name] !== undefined) env[name] = process.env[name] for (const [name, value] of Object.entries(process.env)) { @@ -177,7 +233,7 @@ function makeSandbox(): { root: string; env: NodeJS.ProcessEnv; profile: string XDG_STATE_HOME: dirs.state, TMPDIR: `${dirs.tmp}${sep}`, }) - return { root, env, profile: dirs.profile } + return { root, env, profile: dirs.profile, home: dirs.home, files: join(root, "files") } } /** How long the app's windows must stay as they are before its main window is chosen. */ @@ -194,14 +250,66 @@ export async function launchElectron(opts: ElectronLaunch): Promise argumentIn(a, sandbox.files)) + for (const arg of args) { + if (!existsSync(arg)) { + throw new ElectronLaunchError(`${arg.slice(sandbox.files.length + 1)} isn't in files/`) + } + } + } catch (error) { + await rm(sandbox.root, { recursive: true, force: true }).catch(() => undefined) + if (error instanceof FilesError || error instanceof ConfinementError) { + throw new ElectronLaunchError(error.message) + } + throw error + } + const launchArgs = [ + ...(opts.appArgs ?? []), + ...args, + `--user-data-dir=${sandbox.profile}`, + "--remote-debugging-port=0", + ] + const child = + profile === undefined + ? spawn(opts.executable, launchArgs, spawnOptions(sandbox)) + : // Seatbelt runs the app itself (one process: its group, its pid), Kiframe's switches last. + spawn( + SANDBOX_EXEC, + ["-p", profile, opts.executable, ...launchArgs, ...CONFINED_SWITCHES], + spawnOptions(sandbox), + ) // Its spawn failing is said by the launch (never an unhandled 'error' event). let spawnError: Error | undefined child.on("error", (error) => (spawnError = error)) @@ -214,7 +322,7 @@ export async function launchElectron(opts: ElectronLaunch): Promise undefined), @@ -224,9 +332,6 @@ export async function launchElectron(opts: ElectronLaunch): Promise schemes: ReadonlySet origins: readonly string[] @@ -558,7 +665,7 @@ export function allowedPage( path = posix.normalize(path) const inside = (dir: string) => path === dir || path.startsWith(dir.endsWith("/") ? dir : `${dir}/`) - return own.bundles.some(inside) || inside(own.sandbox) + return own.bundles.some(inside) || own.trusted.some(inside) } if (parsed.protocol === "http:" || parsed.protocol === "https:") { return ( @@ -578,7 +685,6 @@ export function allowedPage( async function stop( child: ChildProcess, sandbox: string, - stateFile: string | undefined, alive: boolean, ): Promise<{ unread: boolean }> { const pid = child.pid @@ -592,18 +698,13 @@ async function stop( timing("kill", t0) // Helpers that left the group (a daemon, a pty host) still point at the sandbox. t0 = Date.now() - const swept = await sweepSandbox(sandbox) + const swept = await sweepProcesses(sandbox) timing("sweep", t0) t0 = Date.now() await rm(sandbox, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch( () => undefined, ) timing("remove", t0) - try { - note(stateFile, sandbox, false) - } catch { - // kept: swept at the next start - } return { unread: swept.unread } } @@ -630,117 +731,3 @@ function signalGroup(pid: number, signal: NodeJS.Signals) { // gone } } - -/** A command's output (empty when it fails: said by the caller's next check). */ -function run(command: string, args: string[]): Promise { - return new Promise((resolve) => { - execFile(command, args, { timeout: 5000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => { - // lsof exits 1 when nothing matches: its output (empty) is still the answer. - resolve(error !== null && (error as { code?: unknown }).code !== 1 ? undefined : stdout) - }) - }) -} - -/** - * Every process's working folder: Linux's /proc (quick), else `lsof` (macOS: no /proc); undefined - * when it can't be read. - */ -async function workingDirs(): Promise<[number, string][] | undefined> { - if (process.platform === "linux") { - const out: [number, string][] = [] - try { - for (const entry of await readdir("/proc")) { - if (!/^\d+$/.test(entry)) continue - try { - out.push([Number(entry), await readlink(`/proc/${entry}/cwd`)]) - } catch { - // gone, or another user's - } - } - return out - } catch { - return undefined - } - } - const listed = await run("lsof", ["-a", "-d", "cwd", "-Fpn"]) - if (listed === undefined) return undefined - const out: [number, string][] = [] - let pid: number | undefined - for (const line of listed.split("\n")) { - if (line.startsWith("p")) pid = Number(line.slice(1)) - else if (line.startsWith("n") && pid !== undefined) out.push([pid, line.slice(1)]) - } - return out -} - -/** - * Kills every process (never this one) that points at `sandbox`: its command line names it (the - * app's helpers carry `--user-data-dir=/profile`), or it works in it (a helper detached - * with its cwd there). Both are quick to read (never a walk of every file). Unread: said. - */ -export async function sweepSandbox(sandbox: string): Promise<{ swept: number; unread: boolean }> { - if (!existsSync(sandbox)) return { swept: 0, unread: false } - const [ps, cwds] = await Promise.all([run("ps", ["-axo", "pid=,command="]), workingDirs()]) - const pids = new Set() - const within = (path: string) => path === sandbox || path.startsWith(`${sandbox}/`) - for (const line of ps?.split("\n") ?? []) { - const match = /^\s*(\d+)\s+(.*)$/.exec(line) - if (match !== null && match[2]?.includes(sandbox) === true) pids.add(Number(match[1])) - } - for (const [pid, cwd] of cwds ?? []) if (within(cwd)) pids.add(pid) - pids.delete(process.pid) - for (const p of pids) { - try { - process.kill(p, "SIGKILL") - } catch { - // gone - } - } - return { swept: pids.size, unread: ps === undefined || cwds === undefined } -} - -/** Adds (or removes) a running launch's sandbox in the state file. */ -function note(stateFile: string | undefined, sandbox: string, running: boolean) { - if (stateFile === undefined) return - const all = readState(stateFile).filter((s) => s !== sandbox) - if (running) all.push(sandbox) - writeFileSync(stateFile, JSON.stringify(all)) -} - -/** A sandbox this module made: a `kiframe-app-*` folder directly in the temp folder, nothing else. */ -function isSandbox(path: string): boolean { - try { - const real = realpathSync(path) - return dirname(real) === realpathSync(tmpdir()) && basename(real).startsWith("kiframe-app-") - } catch { - return false - } -} - -function readState(stateFile: string): string[] { - try { - const parsed: unknown = JSON.parse(readFileSync(stateFile, "utf8")) - return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [] - } catch { - return [] - } -} - -/** - * What a crash left (launches still noted): every process pointing at their sandboxes killed (by - * the sandbox, never a process id that may be another program's by now), the sandboxes removed. - * Only folders this module makes are touched. Called at the host's start. - */ -export async function sweepElectronOrphans(stateFile: string): Promise { - const left = readState(stateFile).filter(isSandbox) - for (const sandbox of left) { - await sweepSandbox(sandbox) - await rm(sandbox, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch( - () => undefined, - ) - } - // Only what was swept: a launch noted meanwhile (a run started during the sweep) stays. - const now = readState(stateFile).filter((s) => !left.includes(s)) - writeFileSync(stateFile, JSON.stringify(now)) - return left.length -} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 0c1a248..f762c97 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -14,8 +14,9 @@ export { ElectronLaunchError, type ElectronTarget, launchElectron, - sweepElectronOrphans, } from "./electron.ts" +export { defaultWorkDir, sweepWorkArea, WorkAreaError } from "./electron-workarea.ts" +export { ConfinementError, FILES_LIMITS, seatbeltProfile } from "./electron-confine.ts" export { LookRefusal, maskedScreenshot, type MaskedShot } from "./look.ts" export { fitImage, diff --git a/packages/runtime/test/electron.test.ts b/packages/runtime/test/electron.test.ts index 0269032..cb4f831 100644 --- a/packages/runtime/test/electron.test.ts +++ b/packages/runtime/test/electron.test.ts @@ -1,31 +1,42 @@ import { + chmodSync, existsSync, + lstatSync, + mkdirSync, mkdtempSync, readdirSync, readFileSync, + realpathSync, rmSync, + statSync, symlinkSync, writeFileSync, } from "node:fs" import { createRequire } from "node:module" -import { tmpdir } from "node:os" -import { join } from "node:path" +import { tmpdir, userInfo } from "node:os" +import { dirname, join } from "node:path" import { parseProjectYaml, parseScenarioYaml } from "@kiframe/schema" import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest" -import { execFileSync } from "node:child_process" +import { execFileSync, spawn, spawnSync } from "node:child_process" import { createServer, type Server } from "node:http" +import { createServer as createNetServer } from "node:net" +import { pathToFileURL } from "node:url" import { createServer as createHttpsServer } from "node:https" import type { AddressInfo } from "node:net" import { allowedPage, ElectronLaunchError, type ElectronTarget, + FILES_LIMITS, launchElectron, recordScenario, runScenario, StepError, - sweepElectronOrphans, + seatbeltProfile, + sweepWorkArea, } from "../src/index.ts" +import { checkConfinement } from "../src/electron-confine.ts" +import { sandboxesOf } from "../src/electron-workarea.ts" // The Electron target (OBJECT-MODEL §0.9 "Electron apps"): a fixture desktop app launched in a // sandbox with the repo's own Electron, driven and filmed like a web page, ended whole. @@ -35,6 +46,22 @@ const electron = createRequire(join(import.meta.dirname, "../../../apps/desktop/ ) as string const fixture = join(import.meta.dirname, "fixtures/electron-app") const viewport = { width: 800, height: 600, deviceScaleFactor: 1 } +/** The repo's Electron.app (the fixture runs on it): what the confined app may read besides. */ +const electronApp = electron.slice(0, electron.indexOf(".app/") + 4) +/** How many sandboxes this file's work area holds (one left behind is a leak). */ +const sandboxCount = () => + existsSync(join(work, "sandboxes")) ? readdirSync(join(work, "sandboxes")).length : 0 +/** 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. */ +const base = { + executable: electron, + bundle: fixture, + readable: [electronApp], + workDir: work, + allowUnconfined: true, + viewport, +} const project = parseProjectYaml(`version: 2 apps: @@ -115,14 +142,15 @@ const inTarget = (target: ElectronTarget) => ({ }) let open: ElectronTarget[] = [] -const launch = async (extra: string[] = [], options: { stateFile?: string } = {}) => { +const launch = async ( + extra: string[] = [], + options: { workDir?: string; files?: string; args?: string[]; readable?: string[] } = {}, +) => { const target = await launchElectron({ - executable: electron, - bundle: fixture, - args: [fixture, "hidden", `link=${other}`, ...extra], + ...base, + appArgs: [fixture, "hidden", `link=${other}`, ...extra], // A plain fixture settles at once; the window-shape tests keep the real wait. ...(!extra.some((e) => ["splash", "swap", "embed-at-start"].includes(e)) && { settleMs: 300 }), - viewport, ...options, }) open.push(target) @@ -354,23 +382,30 @@ steps: expect(await waitFor(() => !alive(grouped))).toBe(true) }) - it("never leaves an app running when its launch can't be noted", async () => { - const before = readdirSync(tmpdir()).filter((d) => d.startsWith("kiframe-app-")).length - await expect( - launch([], { stateFile: join(tmpdir(), "no-such-folder-x", "launches.json") }), - ).rejects.toThrow() - expect(readdirSync(tmpdir()).filter((d) => d.startsWith("kiframe-app-")).length).toBe(before) + it("never sweeps a launch as it starts (owned from the moment its sandbox exists)", async () => { + const own = mkdtempSync(join(tmpdir(), "kiframe-el-work-")) + // Another Kiframe starting meanwhile sweeps the work area again and again. + let starting = true + const sweeps = (async () => { + while (starting) { + await sweepWorkArea(own) + await new Promise((resolve) => setTimeout(resolve, 5)) + } + })() + try { + const target = await launch([], { workDir: own }) + expect(existsSync(target.sandbox)).toBe(true) + expect(await target.page.title()).toBe("Fixture notes") + } finally { + starting = false + await sweeps + } }) it("takes a bundle given through a link (its pages report either path)", async () => { const link = join(mkdtempSync(join(tmpdir(), "kiframe-el-link-")), "Fixture.app") symlinkSync(fixture, link) - const target = await launchElectron({ - executable: electron, - bundle: link, - args: [link, "hidden"], - viewport, - }) + const target = await launchElectron({ ...base, bundle: link, appArgs: [link, "hidden"] }) open.push(target) await runScenario( target.page, @@ -387,25 +422,59 @@ steps: ) }) - it("sweeps what a crash left (by the files they hold), at the next start", async () => { - const stateFile = join(mkdtempSync(join(tmpdir(), "kiframe-el-state-")), "launches.json") - const target = await launch([], { stateFile }) - const pidFile = join(target.sandbox, "profile", "helper.pid") - await waitFor(() => existsSync(pidFile)) - const helper = Number(readFileSync(pidFile, "utf8")) - expect(JSON.parse(readFileSync(stateFile, "utf8"))).toHaveLength(1) - // Kiframe "crashed": never closed. The next start sweeps it. - expect(await sweepElectronOrphans(stateFile)).toBe(1) - expect(await waitFor(() => !alive(helper))).toBe(true) - expect(existsSync(target.sandbox)).toBe(false) - expect(JSON.parse(readFileSync(stateFile, "utf8"))).toEqual([]) - }) + it("sweeps what a crash left at the next start (its owner gone)", async () => { + const own = mkdtempSync(join(tmpdir(), "kiframe-el-work-")) + // A Kiframe that launched the app, then crashed (killed outright: nothing of it ran after). + const kiframe = spawn( + process.execPath, + [ + "--experimental-strip-types", + "--no-warnings", + join(import.meta.dirname, "fixtures/crash-launch.ts"), + own, + electron, + fixture, + electronApp, + fixture, + ], + { stdio: ["ignore", "pipe", "inherit"] }, + ) + let printed = "" + kiframe.stdout.on("data", (d: Buffer) => (printed += d.toString())) + const pids: number[] = [] + try { + expect(await waitFor(() => printed.includes("\n"), 30_000)).toBe(true) + const sandbox = printed.trim() + const pid = (name: string) => Number(readFileSync(join(sandbox, "profile", name), "utf8")) + // main.pid is written last. + await waitFor(() => existsSync(join(sandbox, "profile", "main.pid"))) + pids.push(pid("main.pid")) + const helper = pid("helper.pid") + // And one left in the app's group, pointing nowhere (its cwd /, no sandbox in its command). + const grouped = pid("grouped.pid") + pids.push(helper, grouped) + // While it runs, its launch is its own: never swept. + expect(await sweepWorkArea(own)).toBe(0) + kiframe.kill("SIGKILL") + expect(await waitFor(() => kiframe.exitCode !== null || kiframe.signalCode !== null)).toBe( + true, + ) + expect(alive(helper) && alive(grouped)).toBe(true) + expect(await sweepWorkArea(own)).toBe(1) + expect(await waitFor(() => !alive(helper) && !alive(grouped))).toBe(true) + expect(existsSync(sandbox)).toBe(false) + } finally { + // Never left running when the test fails midway. + kiframe.kill("SIGKILL") + for (const pid of pids) if (alive(pid)) process.kill(pid, "SIGKILL") + } + }, 60_000) it("says an app that quits at once, and leaves nothing behind", async () => { - const before = readdirSync(tmpdir()).filter((d) => d.startsWith("kiframe-app-")).length + 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/) - expect(readdirSync(tmpdir()).filter((d) => d.startsWith("kiframe-app-")).length).toBe(before) + expect(sandboxCount()).toBe(before) }) it("learns its own scheme from a window it opens after a splash (never from a step)", async () => { @@ -529,10 +598,8 @@ steps: [{ id: add, action: click, target: { by: role, role: button, name: Add no 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({ - executable: electron, - bundle: fixture, - args: [fixture, "hidden", `link=${secure}`, "embed-at-start", "trust-test-cert"], - viewport, + ...base, + appArgs: [fixture, "hidden", `link=${secure}`, "embed-at-start", "trust-test-cert"], }) open.push(embeds) await runScenario( @@ -763,11 +830,11 @@ steps: }) it("says an app that isn't there any more, or can't start, never crashing the host", async () => { + await expect(launchElectron({ ...base, bundle: "/Applications/Gone.app" })).rejects.toThrow( + "the app isn't at /Applications/Gone.app any more", + ) await expect( - launchElectron({ executable: electron, bundle: "/Applications/Gone.app", viewport }), - ).rejects.toThrow("the app isn't at /Applications/Gone.app any more") - await expect( - launchElectron({ executable: "/no/such/executable", viewport, timeoutMs: 5000 }), + launchElectron({ ...base, executable: "/no/such/executable", timeoutMs: 5000 }), ).rejects.toThrow(/the app couldn't be launched/) }) @@ -803,10 +870,8 @@ presets: it("stops when the run is stopped while it launches", async () => { const stop = new AbortController() const launching = launchElectron({ - executable: electron, - bundle: fixture, - args: [fixture, "hidden"], - viewport, + ...base, + appArgs: [fixture, "hidden"], signal: stop.signal, }) stop.abort(new Error("stopped")) @@ -817,7 +882,7 @@ presets: describe("a desktop app's own pages", () => { const own = { bundles: ["/Applications/Notes.app"], - sandbox: "/tmp/kiframe-app-x", + trusted: ["/tmp/kiframe-app-x/home", "/tmp/kiframe-app-x/profile"], launched: new Set(["http://localhost:5173"]), schemes: new Set(["app:", "vscode-file:"]), origins: ["https://app.slack.com"], @@ -843,6 +908,9 @@ describe("a desktop app's own pages", () => { it("are never the user's files, another site or port, or a data: page", () => { for (const url of [ "file:///etc/hosts", + // The sandbox's copy of the project's files/ (its content: never the app's own pages). + "file:///tmp/kiframe-app-x/files/demo/index.html", + "file:///tmp/kiframe-app-x/tmp/x.html", "file:///Applications/Notes.app.evil/x.html", "file:///Applications/Notes.app/../Mail.app/x.html", "file:///Users/me/Documents/secret.pdf", @@ -869,36 +937,259 @@ describe("a desktop app's own pages", () => { }) }) -// The state file only names sandboxes Kiframe made: never a folder from a tampered file. -describe("the launches' state file", () => { - it("keeps a launch noted while it sweeps (only what it swept is removed)", async () => { - const dir = mkdtempSync(join(tmpdir(), "kiframe-el-state-")) - const left = mkdtempSync(join(tmpdir(), "kiframe-app-")) - const meanwhile = mkdtempSync(join(tmpdir(), "kiframe-app-")) - const stateFile = join(dir, "launches.json") - writeFileSync(stateFile, JSON.stringify([left])) +// The confinement (macOS's Seatbelt, measured 2026-10-09): every escape refused, the app driven. +describe.runIf(process.platform === "darwin")( + "a desktop app's confinement", + { timeout: 60_000 }, + () => { + it("refuses every escape it tries: the user's folders, other launchers, sockets, the keychain", async () => { + const outside = join(mkdtempSync(join(tmpdir(), "kiframe-el-outside-")), "private.txt") + writeFileSync(outside, "the user's own file") + // An agent's socket outside the sandbox (as ssh-agent's, Docker's): it would accept. + const socket = `/private/tmp/kf-${process.pid}.sock` + const agent = createNetServer((c) => c.end()) + await new Promise((resolve) => agent.listen(socket, resolve)) + const probes = [ + join(userInfo().homedir, "Library", "KiframeProbe.txt"), + "/Users/Shared/KiframeProbe.txt", + "/private/tmp/KiframeProbe.txt", + "/Applications/KiframeProbe.txt", + ] + try { + const target = await launch(["probes", `outside=${outside}`, `socket=${socket}`]) + const file = join(target.sandbox, "profile", "probes.json") + expect(await waitFor(() => existsSync(file), 10_000)).toBe(true) + const seen = JSON.parse(readFileSync(file, "utf8")) as Record + for (const escape of [ + "write-real-home", + "read-real-home", + "write-users-shared", + "write-private-tmp", + "write-applications", + "read-outside", + "hardlink-outside", + "exec-open", + "exec-osascript", + "exec-launchctl", + "exec-copied", + "socket-agent", + ]) { + // Refused by the confinement itself (never an error of another kind passing for one). + expect(seen[escape], escape).toBe("DENIED EPERM") + } + // Never the user's clipboard (measured: an unconfined read saw what the host had copied). + expect(seen["clipboard-read"]).toBe("0") + // A mock keychain (never the user's): encryption still works for the app. + expect(seen["keychain-encrypts"]).toBe("OK true") + // And the app is driven as usual. + await runScenario( + target.page, + parseScenarioYaml(`version: 1 +steps: + - { id: add, action: click, target: { by: role, role: button, name: Add note } } + - { id: one, action: expect, that: { text: "Notes: 1" } } +`), + project, + { electron: inTarget(target), timeoutMs: 5000 }, + ) + } finally { + // A probe that got through would have left its file: never kept. + for (const probe of probes) rmSync(probe, { force: true }) + agent.close() + } + }) + + it("never launches when the confinement doesn't hold (a canary is read)", async () => { + // A profile that lets the canary be read (its folder readable): refused, no app started. + const own = mkdtempSync(join(tmpdir(), "kiframe-el-work-")) + await expect( + launch([], { workDir: own, readable: [electronApp, realpathSync(own)] }), + ).rejects.toThrow(/the confinement didn't hold/) + expect(readdirSync(join(own, "sandboxes"))).toEqual([]) + }) + + it("never passes a confinement that runs nothing for one that refuses", async () => { + // A sandbox named through a link (/var → /private/var): Seatbelt matches real paths, so the + // confined read of its own sandbox fails, as it would were sandbox-exec broken. + const real = mkdtempSync(join(realpathSync(tmpdir()), "kiframe-el-canary-")) + const linked = real.replace(/^\/private\/var\//, "/var/") + expect(linked).not.toBe(real) + await expect( + checkConfinement( + { sandbox: linked, readable: [], private: [], network: "all" }, + join(mkdtempSync(join(tmpdir(), "kiframe-el-work-")), "canary.txt"), + ), + ).rejects.toThrow(/doesn't run programs/) + }) + + it("never reads the user's home wherever it is (a home outside /Users)", async () => { + // /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 + try { + // Denied (the canary unread) only as one of the user's own folders. + await expect( + checkConfinement({ ...confinement, private: [home] }, join(home, "canary.txt")), + ).resolves.toBeUndefined() + await expect( + checkConfinement({ ...confinement, private: [] }, join(home, "canary.txt")), + ).rejects.toThrow(/didn't hold/) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + + 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") + }) + }, +) + +// The project's files/ (decided 2026-10-09): copied into each launch, what its arguments name. +describe("a desktop app's files", { timeout: 60_000 }, () => { + const filesWith = (entries: Record) => { + const dir = join(mkdtempSync(join(tmpdir(), "kiframe-el-files-")), "files") + for (const [path, content] of Object.entries(entries)) { + mkdirSync(join(dir, path, ".."), { recursive: true }) + writeFileSync(join(dir, path), content) + } + return dir + } + + it("opens a copy of the project's files: its edits never reach the project", async () => { + const files = filesWith({ "vault/note.md": "the project's note", "vault/sub/deep.md": "deep" }) + const target = await launch(["touch-arg"], { files, args: ["files/vault"] }) + const seen = await seenBy(target) + const copy = join(target.sandbox, "files", "vault") + expect(seen.args).toContain(copy) + expect(readFileSync(join(copy, "sub", "deep.md"), "utf8")).toBe("deep") + expect( + await waitFor(() => readFileSync(join(copy, "note.md"), "utf8") === "edited by the app"), + ).toBe(true) + expect(readFileSync(join(files, "vault", "note.md"), "utf8")).toBe("the project's note") + // The copy is the project's content, never the app's own pages (a page there: off-app). + expect(target.allows(pathToFileURL(join(copy, "note.md")).href)).toBe(false) + expect(target.allows(pathToFileURL(join(target.sandbox, "profile", "x.html")).href)).toBe(true) + }) + + it("refuses what isn't a file or a folder in files/, and arguments outside it", async () => { + const linked = filesWith({ "vault/note.md": "x" }) + symlinkSync("/etc/hosts", join(linked, "vault", "hosts")) + await expect(launch([], { files: linked, args: ["files/vault"] })).rejects.toThrow( + /files\/vault\/hosts isn't a file or a folder/, + ) + const files = filesWith({ "vault/note.md": "x" }) + for (const arg of ["files/../escape", "/etc", "vault", "files", "files/missing"]) { + await expect(launch([], { files, args: [arg] }), arg).rejects.toThrow(ElectronLaunchError) + } + // files/ itself a link (a shared project's, to the user's documents): never followed. + const elsewhere = filesWith({ "vault/secret.md": "the user's" }) + const project = join(mkdtempSync(join(tmpdir(), "kiframe-el-files-")), "files") + symlinkSync(elsewhere, project) + await expect(launch([], { files: project, args: ["files/vault"] })).rejects.toThrow( + /files\/ isn't a folder \(a link\?\)/, + ) + // Folders count toward the limit (a tree of empty ones is as slow to copy and remove). + const wide = filesWith({}) + mkdirSync(wide, { recursive: true }) + for (let i = 0; i < 6; i++) mkdirSync(join(wide, `d${i}`)) + const limit = FILES_LIMITS.files + FILES_LIMITS.files = 5 + try { + await expect(launch([], { files: wide })).rejects.toThrow(/too large to copy/) + } finally { + FILES_LIMITS.files = limit + } + // A project without its files/ folder: said as such (never a raw error). + await expect( + launch([], { files: join(dirname(files), "absent"), args: ["files/vault"] }), + ).rejects.toThrow(/the project has no files\/ folder/) + expect(sandboxCount()).toBe(0) + }) +}) + +// The work area: only its own sandboxes swept, never another running Kiframe's, never a link. +describe("the work area", () => { + // A work area with: a launch of another running Kiframe, one whose Kiframe ended, one carrying + // this process's id from before (a reboot reuses ids), an unowned one, a link to elsewhere. + const workArea = (other: number) => { + const own = mkdtempSync(join(tmpdir(), "kiframe-el-work-")) + const sandboxes = join(own, "sandboxes") + mkdirSync(sandboxes, { recursive: true, mode: 0o700 }) + const startOf = (pid: number) => + Math.round( + Date.parse( + execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { + encoding: "utf8", + env: { ...process.env, LC_ALL: "C" }, + }).trim(), + ) / 1000, + ) + const at = { + live: join(sandboxes, `kiframe-app-${other}-${startOf(other)}-a`), + ended: join(sandboxes, `kiframe-app-${spawnSync("true").pid}-${startOf(process.pid)}-b`), + reused: join(sandboxes, `kiframe-app-${process.pid}-${startOf(process.pid) - 3600}-c`), + unowned: join(sandboxes, "kiframe-app-d"), + link: join(sandboxes, "kiframe-app-link"), + elsewhere: mkdtempSync(join(tmpdir(), "users-files-")), + } + for (const dir of [at.live, at.ended, at.reused, at.unowned]) mkdirSync(dir) + symlinkSync(at.elsewhere, at.link) + return { own, at } + } + + it("sweeps what no running Kiframe owns, never another's launch, a link or what's elsewhere", async () => { + // Another Kiframe running (a dev build): its launch is left alone, in any language. + const other = spawn("sleep", ["30"], { stdio: "ignore" }) + const locale = process.env["LC_ALL"] + try { + const { own, at } = workArea(other.pid!) + process.env["LC_ALL"] = "ko_KR.UTF-8" + expect(await sweepWorkArea(own)).toBe(3) + expect([at.ended, at.reused, at.unowned].filter(existsSync)).toEqual([]) + expect(existsSync(at.live)).toBe(true) + expect(existsSync(at.elsewhere)).toBe(true) + expect(lstatSync(at.link).isSymbolicLink()).toBe(true) + } finally { + if (locale === undefined) delete process.env["LC_ALL"] + else process.env["LC_ALL"] = locale + other.kill() + } + }) + + it("sweeps nothing when it can't tell who runs (ps unreadable)", async () => { + const other = spawn("sleep", ["30"], { stdio: "ignore" }) + const path = process.env["PATH"] try { - const sweeping = sweepElectronOrphans(stateFile) - // A run starts during the sweep: its launch is noted. - writeFileSync(stateFile, JSON.stringify([left, meanwhile])) - expect(await sweeping).toBe(1) - expect(JSON.parse(readFileSync(stateFile, "utf8"))).toEqual([meanwhile]) - expect(existsSync(meanwhile)).toBe(true) + const { own, at } = workArea(other.pid!) + process.env["PATH"] = mkdtempSync(join(tmpdir(), "kiframe-el-nops-")) + expect(await sweepWorkArea(own)).toBe(0) + expect([at.live, at.ended, at.reused].every(existsSync)).toBe(true) } finally { - rmSync(left, { recursive: true, force: true }) - rmSync(meanwhile, { recursive: true, force: true }) + process.env["PATH"] = path + other.kill() } }) - it("never sweeps a folder that isn't a sandbox (named like one, or anywhere else)", async () => { - const dir = mkdtempSync(join(tmpdir(), "kiframe-el-state-")) - const keep = mkdtempSync(join(tmpdir(), "users-files-")) - // Named like a sandbox, but not one this module made (not directly in the temp folder). - const lookalike = mkdtempSync(join(dir, "kiframe-app-")) - const stateFile = join(dir, "launches.json") - writeFileSync(stateFile, JSON.stringify([keep, lookalike, `${lookalike}/../..`])) - expect(await sweepElectronOrphans(stateFile)).toBe(0) - expect(existsSync(keep)).toBe(true) - expect(existsSync(lookalike)).toBe(true) + it("is the user's alone (0700), never a link", () => { + const own = mkdtempSync(join(tmpdir(), "kiframe-el-work-")) + chmodSync(own, 0o755) + sandboxesOf(own) + expect(statSync(own).mode & 0o777).toBe(0o700) + expect(statSync(join(own, "sandboxes")).mode & 0o777).toBe(0o700) + const linked = join(mkdtempSync(join(tmpdir(), "kiframe-el-link-")), "work") + symlinkSync(own, linked) + expect(() => sandboxesOf(linked)).toThrow(/isn't a folder/) }) }) diff --git a/packages/runtime/test/fixtures/crash-launch.ts b/packages/runtime/test/fixtures/crash-launch.ts new file mode 100644 index 0000000..46cd301 --- /dev/null +++ b/packages/runtime/test/fixtures/crash-launch.ts @@ -0,0 +1,22 @@ +// 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" + +const [workDir, executable, bundle, readable, app] = process.argv.slice(2) as [ + string, + string, + string, + string, + string, +] +const target = await launchElectron({ + executable, + bundle, + readable: [readable], + workDir, + allowUnconfined: true, + appArgs: [app, "hidden"], + viewport: { width: 800, height: 600, deviceScaleFactor: 1 }, +}) +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 3985634..ce4eea1 100644 --- a/packages/runtime/test/fixtures/electron-app/main.cjs +++ b/packages/runtime/test/fixtures/electron-app/main.cjs @@ -89,3 +89,63 @@ if (process.argv.includes("quit-at-once")) { }) app.on("window-all-closed", () => app.quit()) } + +// "touch-arg": it edits what it was given to open (the files/ copy: never the project's own). +const opened = process.argv.find((a) => a.includes("/files/")) +if (process.argv.includes("touch-arg") && opened !== undefined) { + app.whenReady().then(() => writeFileSync(join(opened, "note.md"), "edited by the app")) +} + +// "probes": each escape the confinement must refuse, tried and written down (never the user's +// data: a write would only create a probe file the test removes; reads only list folders). +if (process.argv.includes("probes")) { + app.whenReady().then(async () => { + const fs = require("node:fs") + const net = require("node:net") + const { execFileSync } = require("node:child_process") + const real = require("node:os").userInfo().homedir + const outside = process.argv.find((a) => a.startsWith("outside="))?.slice(8) ?? "/nonexistent" + const out = {} + const tryIt = (name, fn) => { + try { + out[name] = "OK " + String(fn()).slice(0, 40) + } catch (e) { + out[name] = "DENIED " + (e.code ?? String(e).slice(0, 40)) + } + } + tryIt("write-real-home", () => fs.writeFileSync(join(real, "Library", "KiframeProbe.txt"), "x")) + tryIt("read-real-home", () => fs.readdirSync(join(real, "Library")).length) + tryIt("write-users-shared", () => fs.writeFileSync("/Users/Shared/KiframeProbe.txt", "x")) + tryIt("write-private-tmp", () => fs.writeFileSync("/private/tmp/KiframeProbe.txt", "x")) + tryIt("write-applications", () => fs.writeFileSync("/Applications/KiframeProbe.txt", "x")) + tryIt("read-outside", () => fs.readFileSync(outside, "utf8")) + tryIt("hardlink-outside", () => fs.linkSync(outside, join(app.getPath("userData"), "linked"))) + tryIt("exec-open", () => + execFileSync("/usr/bin/open", ["-h"], { stdio: "ignore", timeout: 3000 }), + ) + tryIt("exec-osascript", () => + execFileSync("/usr/bin/osascript", ["-e", "1"], { timeout: 3000 }), + ) + tryIt("exec-launchctl", () => execFileSync("/bin/launchctl", ["version"], { timeout: 3000 })) + // A unix socket that would accept (the test's own server, outside the sandbox): an agent's. + const socket = process.argv.find((a) => a.startsWith("socket="))?.slice(7) ?? "/nonexistent" + out["socket-agent"] = await new Promise((resolve) => { + const s = net.connect(socket) + s.on("connect", () => (s.destroy(), resolve("OK connected"))) + s.on("error", (e) => resolve("DENIED " + e.code)) + }) + // A program copied into its own sandbox, run from there (a copied launcher: an exact-path rule + // would miss it). `true`: it runs, or it's refused. + tryIt("exec-copied", () => { + const copy = join(app.getPath("userData"), "copied") + fs.copyFileSync("/usr/bin/true", copy) + fs.chmodSync(copy, 0o755) + return execFileSync(copy, [], { stdio: "ignore", timeout: 3000 }) + }) + // The user's clipboard (it may hold a password just copied): only how much is seen, never what. + const { safeStorage, clipboard } = require("electron") + out["clipboard-read"] = `${(await clipboard.readText()).length}` + tryIt("keychain-encrypts", () => safeStorage.isEncryptionAvailable()) + fs.writeFileSync(join(app.getPath("userData"), "probes.json"), JSON.stringify(out)) + }) +} diff --git a/packages/schema/src/apps.test.ts b/packages/schema/src/apps.test.ts index 60e30ab..bb9d056 100644 --- a/packages/schema/src/apps.test.ts +++ b/packages/schema/src/apps.test.ts @@ -274,13 +274,13 @@ describe("a desktop Electron app (design 2026-10-08)", () => { const parse = (apps: object) => Project.safeParse(v2(apps)) it("is named by its bundle id, beside web apps (the viewport defaulted)", () => { - const parsed = parse({ web: app("https://a.dev"), notes: desk({ args: ["~/Demo"] }) }) + const parsed = parse({ web: app("https://a.dev"), notes: desk({ args: ["files/demo"] }) }) expect(parsed.success).toBe(true) const notes = parsed.data?.apps.notes expect(notes).toEqual({ kind: "electron", bundleId: "com.example.notes", - args: ["~/Demo"], + args: ["files/demo"], viewport: { width: 1440, height: 900, deviceScaleFactor: 2 }, }) expect(appIdentity(notes!)).toBe("electron:com.example.notes") @@ -311,7 +311,20 @@ describe("a desktop Electron app (design 2026-10-08)", () => { /positional arguments only/, ) } - expect(issue({ x: desk({ args: ["~/My Demo"] }) })).toBe("parsed") + expect(issue({ x: desk({ args: ["files/My Demo"] }) })).toBe("parsed") + // Only the project's files/ (never the user's own folders, never out of it). + for (const outside of [ + "~/Demo", + "/Users/me/Demo", + "files", + "files/../x", + "files//x", + "./files/x", + ]) { + expect(issue({ x: desk({ args: [outside] }) }), outside).toMatch( + /a path in the project's files/, + ) + } expect(issue({ x: desk({ bundleId: "1.0" }) })).toMatch(/a bundle id/) }) diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts index 5294e03..f57e0dd 100644 --- a/packages/schema/src/project.ts +++ b/packages/schema/src/project.ts @@ -41,6 +41,17 @@ const HttpsOrigin = z return url !== null && url.protocol === "https:" && url.origin === s }, "an https origin (https://host, no path)") +/** + * 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). + */ +export function filesPath(arg: string): string[] | undefined { + const parts = arg.split("/") + if (parts[0] !== "files" || parts.length < 2) return undefined + if (parts.some((p) => p === "" || p === "." || p === "..")) return undefined + return parts.slice(1) +} + /** * A desktop Electron app (OBJECT-MODEL §0.9, design 2026-10-08), named by its bundle id: never a * path or a program (a project may come from someone else: the app a bundle id means is found and @@ -53,7 +64,10 @@ export const ElectronApp = z.strictObject({ .string() .max(255) .regex(/^[A-Za-z][A-Za-z0-9-]*(\.[A-Za-z0-9-]+)+$/, "a bundle id (com.example.app)"), - /** What the app opens (a folder, a file): positional only, never a switch. */ + /** + * 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. + */ args: z .array( z @@ -68,6 +82,10 @@ export const ElectronApp = z.strictObject({ !/^\s|\s$/.test(a) && ![...a].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127), "positional arguments only (no switches, no spaces at the ends)", + ) + .refine( + (a) => filesPath(a) !== undefined, + "a path in the project's files/ (files/): a desktop app opens only those", ), ) .max(20) From 494e5f9e5f5e6950e286796811328625d63f89dd Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Fri, 9 Oct 2026 15:01:12 +0200 Subject: [PATCH 2/3] fix(runtime): steady a desktop-app launch's checks and copy - each launch writes its own canary, and a canary that went missing proves nothing (refused): launches at once never spoil each other's - the app's time to start begins once its files are copied - a stop during the copy ends the launch before the app starts - a folder in files/ swapped for a link while copied is refused - the app's own temp folder holds its own pages again --- docs/BACKLOG.md | 2 + packages/runtime/src/electron-confine.ts | 39 +++++++--- packages/runtime/src/electron.ts | 29 ++++++-- packages/runtime/test/electron.test.ts | 94 +++++++++++++++++++++++- 4 files changed, 144 insertions(+), 20 deletions(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index f8dab09..b1611b1 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -245,6 +245,8 @@ Non-severe review findings deferred on purpose (see the review-round rule: only - **Electron targets:** an app attached over CDP (F4) runs in its own persistent profile: no ephemeral context there. The app's own storage keeps whatever the scene typed; document it for Electron projects and prefer a throwaway app profile (`--user-data-dir` per run). - **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/packages/runtime/src/electron-confine.ts b/packages/runtime/src/electron-confine.ts index af29ff1..3cfd895 100644 --- a/packages/runtime/src/electron-confine.ts +++ b/packages/runtime/src/electron-confine.ts @@ -1,6 +1,7 @@ import { execFile } from "node:child_process" import { constants, existsSync, rmSync, writeFileSync } from "node:fs" import { copyFile, lstat, mkdir, readdir, utimes } from "node:fs/promises" +import type { Stats } from "node:fs" import { join } from "node:path" import { filesPath } from "@kiframe/schema" @@ -88,6 +89,8 @@ export async function checkConfinement(c: Confinement, denied: string): Promise< readsUnder(c, denied, "canary"), readsUnder(c, allowed, "canary"), ]) + // Unread because it was gone (removed meanwhile) says nothing of the confinement. + if (!existsSync(denied)) throw new Error("its canary went missing") } catch (error) { throw new ConfinementError( `the confinement couldn't be checked (${(error as { code?: string }).code ?? String(error)})`, @@ -114,21 +117,40 @@ export class FilesError extends Error {} /** The copy's limits (it's made at every launch). */ export const FILES_LIMITS = { bytes: 500 * 1024 * 1024, files: 20_000, depth: 32 } +/** Still the entry that was checked (same kind, same inode: never a link put in its place). */ +const same = (now: Stats, checked: Stats, kind: "dir" | "file") => + (kind === "dir" ? now.isDirectory() : now.isFile()) && + now.ino === checked.ino && + now.dev === checked.dev + +const changed = (rel: string) => + new FilesError(`files${rel} changed while it was copied: try again`) + /** * A project's `files/` copied into a launch's sandbox (what the app opens: every run from the same * files, its edits thrown away). Own walk, never a library copy: only folders and regular files * (a link, a FIFO, a socket, a device refused: never followed, never opened), within the limits; * cloned where the disk can (APFS: near instant), times kept (a run sees the files as they are). */ -export async function copyFiles(from: string, to: string): Promise { +export async function copyFiles(from: string, to: string, signal?: AbortSignal): Promise { let bytes = 0 let entries = 0 const tooLarge = () => new FilesError("files/ is too large to copy at each launch (500 MB, 20,000 entries)") - const walk = async (src: string, dst: string, depth: number, rel: string): Promise => { + const walk = async ( + src: string, + dst: string, + depth: number, + rel: string, + checked: Stats, + ): Promise => { if (depth > FILES_LIMITS.depth) throw new FilesError(`files/${rel} is nested too deep`) await mkdir(dst, { recursive: true }) - for (const name of await readdir(src)) { + const names = await readdir(src) + // readdir follows a link: a folder swapped for one meanwhile is refused (as a file is). + if (!same(await lstat(src), checked, "dir")) throw changed(rel === "" ? "" : `/${rel}`) + for (const name of names) { + signal?.throwIfAborted() const s = join(src, name) const d = join(dst, name) const r = rel === "" ? name : `${rel}/${name}` @@ -137,17 +159,14 @@ export async function copyFiles(from: string, to: string): Promise { entries += 1 if (entries > FILES_LIMITS.files) throw tooLarge() if (stat.isDirectory()) { - await walk(s, d, depth + 1, r) + await walk(s, d, depth + 1, r, stat) } else if (stat.isFile()) { bytes += stat.size if (bytes > FILES_LIMITS.bytes) throw tooLarge() await copyFile(s, d, constants.COPYFILE_FICLONE) // copyFile follows a link: an entry swapped for one meanwhile is refused (still the file // that was checked, never what a link points at). - const after = await lstat(s) - if (!after.isFile() || after.ino !== stat.ino || after.dev !== stat.dev) { - throw new FilesError(`files/${r} changed while it was copied: try again`) - } + if (!same(await lstat(s), stat, "file")) throw changed(`/${r}`) await utimes(d, stat.atime, stat.mtime) } else { throw new FilesError(`files/${r} isn't a file or a folder (a link?): only those are copied`) @@ -163,9 +182,9 @@ export async function copyFiles(from: string, to: string): Promise { "the project's files/ isn't a folder (a link?): only a real one is copied", ) } - await walk(from, to, 0, "") + await walk(from, to, 0, "", root) } catch (error) { - if (error instanceof FilesError) throw error + if (error instanceof FilesError || signal?.aborted === true) throw error const code = (error as { code?: string }).code throw new FilesError( code === "ENOENT" && !existsSync(from) diff --git a/packages/runtime/src/electron.ts b/packages/runtime/src/electron.ts index 2821d29..1479b9f 100644 --- a/packages/runtime/src/electron.ts +++ b/packages/runtime/src/electron.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process" import { posix } from "node:path" import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs" +import { randomUUID } from "node:crypto" import { rm } from "node:fs/promises" import { userInfo } from "node:os" import { join, sep } from "node:path" @@ -110,7 +111,7 @@ export interface ElectronLaunch { args?: readonly string[] /** The project's files/ folder: copied into the sandbox at each launch (what `args` name). */ files?: string - /** Kiframe's work area (default `~/.kiframe`): the sandboxes and the launches' state. */ + /** 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). */ network?: "all" | "loopback" @@ -193,6 +194,7 @@ async function makeSandbox(work: string): Promise<{ env: NodeJS.ProcessEnv profile: string home: string + tmp: string files: string }> { let root: string @@ -225,7 +227,7 @@ async function makeSandbox(work: string): Promise<{ Object.assign(env, { HOME: dirs.home, // macOS: what reads NSHomeDirectory follows it (not appData, not the app's preferences: those - // stay the user's, checked by the trial launch). + // stay the user's, out of reach under the confinement). CFFIXED_USER_HOME: dirs.home, XDG_CONFIG_HOME: dirs.config, XDG_DATA_HOME: dirs.data, @@ -233,7 +235,14 @@ async function makeSandbox(work: string): Promise<{ XDG_STATE_HOME: dirs.state, TMPDIR: `${dirs.tmp}${sep}`, }) - return { root, env, profile: dirs.profile, home: dirs.home, files: join(root, "files") } + return { + root, + env, + profile: dirs.profile, + home: dirs.home, + tmp: dirs.tmp, + files: join(root, "files"), + } } /** How long the app's windows must stay as they are before its main window is chosen. */ @@ -262,7 +271,6 @@ export async function launchElectron(opts: ElectronLaunch): Promise argumentIn(a, sandbox.files)) for (const arg of args) { if (!existsSync(arg)) { throw new ElectronLaunchError(`${arg.slice(sandbox.files.length + 1)} isn't in files/`) } } + // Stopped meanwhile: never started. + opts.signal?.throwIfAborted() } catch (error) { await rm(sandbox.root, { recursive: true, force: true }).catch(() => undefined) if (error instanceof FilesError || error instanceof ConfinementError) { @@ -295,6 +306,8 @@ export async function launchElectron(opts: ElectronLaunch): Promise ({ let open: ElectronTarget[] = [] const launch = async ( extra: string[] = [], - options: { workDir?: string; files?: string; args?: string[]; readable?: string[] } = {}, + options: { + workDir?: string + files?: string + args?: string[] + readable?: string[] + timeoutMs?: number + signal?: AbortSignal + } = {}, ) => { const target = await launchElectron({ ...base, @@ -1008,6 +1015,53 @@ steps: expect(readdirSync(join(own, "sandboxes"))).toEqual([]) }) + it("never lets launches at once spoil each other's canary", async () => { + // Broken launches (their canary readable) beside sound ones that end right after their + // canary (an argument missing): a shared canary removed by one is "unread" by another. + const own = mkdtempSync(join(tmpdir(), "kiframe-el-work-")) + const [broken, sound] = await Promise.all([ + Promise.allSettled( + Array.from({ length: 8 }, () => + launch([], { workDir: own, readable: [electronApp, realpathSync(own)] }), + ), + ), + Promise.allSettled( + Array.from({ length: 8 }, () => launch([], { workDir: own, args: ["files/missing"] })), + ), + ]) + for (const b of broken) { + expect(b.status === "rejected" && String(b.reason)).toMatch(/didn't hold/) + } + // The sound ones fail only for what they lack (never their canary spoiled by another). + for (const s of sound) { + expect(s.status === "rejected" && String(s.reason)).toMatch(/isn't in files/) + } + expect(readdirSync(own).filter((n) => n.startsWith("canary"))).toEqual([]) + }) + + it("never passes a canary that went missing for one that was refused", async () => { + // Every canary removed as soon as it's written (as another launch would): unread for that + // reason, the check proves nothing, and a broken confinement must still never pass. + const own = mkdtempSync(join(tmpdir(), "kiframe-el-work-")) + sandboxesOf(own) + // Polled on every turn of the loop: faster than a confined program starts. + let watching = true + const remove = () => { + for (const name of readdirSync(own)) { + if (name.startsWith("canary")) rmSync(join(own, name), { force: true }) + } + if (watching) setImmediate(remove) + } + remove() + try { + await expect( + launch([], { workDir: own, readable: [electronApp, realpathSync(own)] }), + ).rejects.toThrow(ElectronLaunchError) + } finally { + watching = false + } + }) + it("never passes a confinement that runs nothing for one that refuses", async () => { // A sandbox named through a link (/var → /private/var): Seatbelt matches real paths, so the // confined read of its own sandbox fails, as it would were sandbox-exec broken. @@ -1081,8 +1135,44 @@ describe("a desktop app's files", { timeout: 60_000 }, () => { // The copy is the project's content, never the app's own pages (a page there: off-app). expect(target.allows(pathToFileURL(join(copy, "note.md")).href)).toBe(false) expect(target.allows(pathToFileURL(join(target.sandbox, "profile", "x.html")).href)).toBe(true) + // Its temp too (a print preview it writes there is its own page). + expect(target.allows(pathToFileURL(join(target.sandbox, "tmp", "preview.html")).href)).toBe( + true, + ) }) + // A files/ slow to copy (15,000 small files: seconds, measured on this machine first). + const slowFiles = async () => { + const files = filesWith({}) + for (let d = 0; d < 150; d++) { + mkdirSync(join(files, `d${d}`), { recursive: true }) + for (let f = 0; f < 100; f++) writeFileSync(join(files, `d${d}`, `f${f}`), "x") + } + const t0 = Date.now() + await copyFiles(files, join(mkdtempSync(join(tmpdir(), "kiframe-el-copy-")), "files")) + return { files, copyMs: Date.now() - t0 } + } + + it("gives the app its whole time to start, however long its files take to copy", async () => { + const { files, copyMs } = await slowFiles() + // Barely more than the copy: were the copy counted, the app would have no time left. + const target = await launch([], { files, timeoutMs: copyMs + 500 }) + expect(await target.page.title()).toBe("Fixture notes") + }, 120_000) + + it("never starts the app when stopped while its files copy", async () => { + const { files } = await slowFiles() + const before = sandboxCount() + const stopping = new AbortController() + const launching = launch([], { files, signal: stopping.signal }) + setTimeout(() => stopping.abort(), 300) + const t0 = Date.now() + await expect(launching).rejects.toThrow() + // Said at once (never once the app is up), its sandbox gone, no app left. + expect(Date.now() - t0).toBeLessThan(3000) + expect(sandboxCount()).toBe(before) + }, 120_000) + it("refuses what isn't a file or a folder in files/, and arguments outside it", async () => { const linked = filesWith({ "vault/note.md": "x" }) symlinkSync("/etc/hosts", join(linked, "vault", "hosts")) From 8d747123a5df7a3c345d0a96e732aad86cdf83d3 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Fri, 9 Oct 2026 15:45:43 +0200 Subject: [PATCH 3/3] fix(runtime): finish a desktop-app launch's copy and canary checks - a folder in files/ is checked again once its entries are copied: one swapped for a link at any point of the copy is refused - each canary lies beside its sandbox, named after it: a crash's is swept - a stop ends the canary check and the copy at once, said as the stop - a canary that was read reads as a confinement that didn't hold - the slow-copy tests are sized to the app's measured start, built once and removed --- docs/OBJECT-MODEL.md | 2 +- packages/runtime/src/electron-confine.ts | 37 +++++-- packages/runtime/src/electron.ts | 6 +- packages/runtime/test/electron.test.ts | 125 +++++++++++++++++------ 4 files changed, 123 insertions(+), 47 deletions(-) diff --git a/docs/OBJECT-MODEL.md b/docs/OBJECT-MODEL.md index 8535bee..453dd36 100644 --- a/docs/OBJECT-MODEL.md +++ b/docs/OBJECT-MODEL.md @@ -229,7 +229,7 @@ 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 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). +- **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). - **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 3cfd895..c0e0efd 100644 --- a/packages/runtime/src/electron-confine.ts +++ b/packages/runtime/src/electron-confine.ts @@ -57,12 +57,17 @@ ${c.network === "loopback" ? '(deny network-outbound)\n(allow network-outbound ( export class ConfinementError extends Error {} /** Whether a program confined by `c` reads `file` (its content, exactly). */ -function readsUnder(c: Confinement, file: string, content: string): Promise { +function readsUnder( + c: Confinement, + file: string, + content: string, + signal?: AbortSignal, +): Promise { return new Promise((resolve) => { execFile( SANDBOX_EXEC, ["-p", seatbeltProfile(c), "/bin/cat", file], - { timeout: 5000 }, + { timeout: 5000, ...(signal !== undefined && { signal }) }, (error, stdout) => resolve(error === null && stdout === content), ) }) @@ -73,7 +78,11 @@ function readsUnder(c: Confinement, file: string, content: string): Promise { +export async function checkConfinement( + c: Confinement, + denied: string, + signal?: AbortSignal, +): Promise { if (!existsSync(SANDBOX_EXEC)) { throw new ConfinementError( "macOS's sandbox-exec is missing: Kiframe can't confine desktop apps", @@ -82,28 +91,33 @@ export async function checkConfinement(c: Confinement, denied: string): Promise< const allowed = join(c.sandbox, "canary.txt") let deniedRead: boolean let allowedRead: boolean + let missing: boolean try { writeFileSync(denied, "canary") writeFileSync(allowed, "canary") ;[deniedRead, allowedRead] = await Promise.all([ - readsUnder(c, denied, "canary"), - readsUnder(c, allowed, "canary"), + readsUnder(c, denied, "canary", signal), + readsUnder(c, allowed, "canary", signal), ]) - // Unread because it was gone (removed meanwhile) says nothing of the confinement. - if (!existsSync(denied)) throw new Error("its canary went missing") + missing = !existsSync(denied) } catch (error) { throw new ConfinementError( - `the confinement couldn't be checked (${(error as { code?: string }).code ?? String(error)})`, + `the confinement couldn't be checked (${(error as { code?: string }).code ?? "unwritable"})`, ) } finally { rmSync(allowed, { force: true }) rmSync(denied, { force: true }) } + signal?.throwIfAborted() if (deniedRead) { throw new ConfinementError( "the confinement didn't hold (a canary was read): desktop apps are off", ) } + // Unread because it was gone (removed meanwhile) says nothing of the confinement. + if (missing) { + throw new ConfinementError("the confinement couldn't be checked (its canary went missing)") + } if (!allowedRead) { throw new ConfinementError( "the confinement doesn't run programs (its own sandbox unread): desktop apps are off", @@ -172,6 +186,10 @@ export async function copyFiles(from: string, to: string, signal?: AbortSignal): throw new FilesError(`files/${r} isn't a file or a folder (a link?): only those are copied`) } } + // And still that folder once its entries are copied (they're reached through its path: one + // swapped for a link meanwhile, its entries came from elsewhere; every folder above checks + // the same after its own loop). + if (!same(await lstat(src), checked, "dir")) throw changed(rel === "" ? "" : `/${rel}`) } try { // files/ itself a real folder (a shared project's files/ linked to the user's documents: never @@ -184,7 +202,8 @@ export async function copyFiles(from: string, to: string, signal?: AbortSignal): } await walk(from, to, 0, "", root) } catch (error) { - if (error instanceof FilesError || signal?.aborted === true) throw error + if (signal?.aborted === true) throw signal.reason + if (error instanceof FilesError) throw error const code = (error as { code?: string }).code throw new FilesError( code === "ENOENT" && !existsSync(from) diff --git a/packages/runtime/src/electron.ts b/packages/runtime/src/electron.ts index 1479b9f..b28e6fa 100644 --- a/packages/runtime/src/electron.ts +++ b/packages/runtime/src/electron.ts @@ -1,7 +1,6 @@ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process" import { posix } from "node:path" import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs" -import { randomUUID } from "node:crypto" import { rm } from "node:fs/promises" import { userInfo } from "node:os" import { join, sep } from "node:path" @@ -286,8 +285,9 @@ export async function launchElectron(opts: ElectronLaunch): Promise n.startsWith("canary"))).toEqual([]) + // None left (each beside its sandbox, removed with it). + expect(readdirSync(join(own, "sandboxes")).filter((n) => n.endsWith(".canary"))).toEqual([]) }) it("never passes a canary that went missing for one that was refused", async () => { @@ -1047,8 +1049,8 @@ steps: // Polled on every turn of the loop: faster than a confined program starts. let watching = true const remove = () => { - for (const name of readdirSync(own)) { - if (name.startsWith("canary")) rmSync(join(own, name), { force: true }) + for (const name of readdirSync(join(own, "sandboxes"))) { + if (name.endsWith(".canary")) rmSync(join(own, "sandboxes", name), { force: true }) } if (watching) setImmediate(remove) } @@ -1141,37 +1143,90 @@ describe("a desktop app's files", { timeout: 60_000 }, () => { ) }) - // A files/ slow to copy (15,000 small files: seconds, measured on this machine first). - const slowFiles = async () => { - const files = filesWith({}) - for (let d = 0; d < 150; d++) { - mkdirSync(join(files, `d${d}`), { recursive: true }) - for (let f = 0; f < 100; f++) writeFileSync(join(files, `d${d}`, `f${f}`), "x") + // A files/ slower to copy than the app takes to start, by far (sized here, on this machine: + // its tests' timing holds wherever they run). Built once, removed after. + describe("slow to copy", { timeout: 300_000 }, () => { + let root: string + let files: string + let mirror: string + let copyMs = 0 + let budget = 0 + const limit = FILES_LIMITS.files + const copyTime = async () => { + const to = join(mkdtempSync(join(root, "copy-")), "files") + const t0 = Date.now() + await copyFiles(files, to) + const ms = Date.now() - t0 + rmSync(dirname(to), { recursive: true, force: true }) + return ms } - const t0 = Date.now() - await copyFiles(files, join(mkdtempSync(join(tmpdir(), "kiframe-el-copy-")), "files")) - return { files, copyMs: Date.now() - t0 } - } - it("gives the app its whole time to start, however long its files take to copy", async () => { - const { files, copyMs } = await slowFiles() - // Barely more than the copy: were the copy counted, the app would have no time left. - const target = await launch([], { files, timeoutMs: copyMs + 500 }) - expect(await target.page.title()).toBe("Fixture notes") - }, 120_000) + beforeAll(async () => { + // The app's own time to start, measured: it's given twice that and a second. + const t0 = Date.now() + await (await launch()).close() + budget = 2 * (Date.now() - t0) + 1000 + root = mkdtempSync(join(tmpdir(), "kiframe-el-slow-")) + files = join(root, "files") + FILES_LIMITS.files = 400_000 + // Grown until its copy takes well over the app's time (all of it under files/vault). + for (let batch = 0; copyMs < budget + 1000 && batch < 60; batch++) { + for (let d = 0; d < 50; d++) { + const dir = join(files, "vault", `d${batch}-${d}`) + mkdirSync(dir, { recursive: true }) + for (let f = 0; f < 100; f++) writeFileSync(join(dir, `f${f}`), "x") + } + copyMs = await copyTime() + } + expect(copyMs).toBeGreaterThan(budget + 1000) + // The same folders elsewhere (what a folder swapped for a link would show). + mirror = join(root, "mirror") + execFileSync("cp", [ + process.platform === "darwin" ? "-cR" : "-R", + join(files, "vault"), + mirror, + ]) + }, 300_000) + + afterAll(() => { + FILES_LIMITS.files = limit + if (root !== undefined) rmSync(root, { recursive: true, force: true }) + }) - it("never starts the app when stopped while its files copy", async () => { - const { files } = await slowFiles() - const before = sandboxCount() - const stopping = new AbortController() - const launching = launch([], { files, signal: stopping.signal }) - setTimeout(() => stopping.abort(), 300) - const t0 = Date.now() - await expect(launching).rejects.toThrow() - // Said at once (never once the app is up), its sandbox gone, no app left. - expect(Date.now() - t0).toBeLessThan(3000) - expect(sandboxCount()).toBe(before) - }, 120_000) + it("gives the app its whole time to start, however long its files take to copy", async () => { + // Were the copy counted, the app would have no time left. + const target = await launch([], { files, timeoutMs: budget }) + expect(await target.page.title()).toBe("Fixture notes") + }) + + it("never starts the app when stopped while its files copy", async () => { + const before = sandboxCount() + const stopping = new AbortController() + const launching = launch([], { files, signal: stopping.signal }) + setTimeout(() => stopping.abort(), 300) + const t0 = Date.now() + // The stop itself (never another error), said at once (never once the copy is done). + await expect(launching).rejects.toMatchObject({ name: "AbortError" }) + expect(Date.now() - t0).toBeLessThan(copyMs / 2) + expect(sandboxCount()).toBe(before) + }) + + it("refuses a folder swapped for a link while its files copy", async () => { + const vault = join(files, "vault") + const launching = launch([], { files }) + // Mid-copy, vault becomes a link to the same folders elsewhere: every entry still found. + setTimeout(() => { + renameSync(vault, join(root, "vault-moved")) + symlinkSync(mirror, vault) + }, 300) + try { + await expect(launching).rejects.toThrow(/files\/vault.* changed while it was copied/) + } finally { + rmSync(vault, { force: true }) + renameSync(join(root, "vault-moved"), vault) + } + }) + }) it("refuses what isn't a file or a folder in files/, and arguments outside it", async () => { const linked = filesWith({ "vault/note.md": "x" }) @@ -1235,6 +1290,8 @@ describe("the work area", () => { elsewhere: mkdtempSync(join(tmpdir(), "users-files-")), } for (const dir of [at.live, at.ended, at.reused, at.unowned]) mkdirSync(dir) + // Canaries beside their sandboxes, as a crash mid-check leaves them. + for (const dir of [at.live, at.ended]) writeFileSync(`${dir}.canary`, "canary") symlinkSync(at.elsewhere, at.link) return { own, at } } @@ -1246,9 +1303,9 @@ describe("the work area", () => { try { const { own, at } = workArea(other.pid!) process.env["LC_ALL"] = "ko_KR.UTF-8" - expect(await sweepWorkArea(own)).toBe(3) - expect([at.ended, at.reused, at.unowned].filter(existsSync)).toEqual([]) - expect(existsSync(at.live)).toBe(true) + expect(await sweepWorkArea(own)).toBe(4) + expect([at.ended, `${at.ended}.canary`, at.reused, at.unowned].filter(existsSync)).toEqual([]) + expect(existsSync(at.live) && existsSync(`${at.live}.canary`)).toBe(true) expect(existsSync(at.elsewhere)).toBe(true) expect(lstatSync(at.link).isSymbolicLink()).toBe(true) } finally {