From 15ce4e6853482e160f88f42a2aee38de3c068ea6 Mon Sep 17 00:00:00 2001
From: Le Ha Quang
Date: Sun, 11 Oct 2026 18:19:34 +0200
Subject: [PATCH 1/2] feat(runtime): let a project's desktop app open terminals
of its own
A confined app could make no pty (terminal apps failed with posix_spawnp),
and could print into every terminal the user had open. Now no app reaches a
terminal unless its project says terminals: true, ticked on its add card with
a warning and covered by its approval. Such a launch denies the user's ptys
open at launch every way (read, write, stat, chmod, ioctl, revoke), allows
new ones, and gives its shells a neutral prompt. A terminal the user opens
while it runs stays exposed (stated in BACKLOG).
---
apps/desktop/src/main/desktop-apps.ts | 37 +++-
apps/desktop/src/main/desktop-launch.ts | 1 +
apps/desktop/src/main/index.ts | 4 +-
.../renderer/src/components/apps-panel.tsx | 35 +++-
apps/desktop/src/renderer/src/styles.css | 8 +
apps/desktop/src/renderer/test/apps.test.tsx | 70 ++++++-
apps/desktop/src/shared/ipc.ts | 6 +-
apps/desktop/test/desktop-apps.test.ts | 69 ++++++-
apps/desktop/test/desktop-launch.test.ts | 25 +++
docs/BACKLOG.md | 1 +
docs/FAILURE-CATALOGUE.md | 1 +
docs/OBJECT-MODEL.md | 3 +-
packages/runtime/src/electron-confine.ts | 79 +++++++-
packages/runtime/src/electron.ts | 58 +++++-
packages/runtime/test/electron.test.ts | 172 +++++++++++++++++-
.../test/fixtures/electron-app/main.cjs | 59 ++++++
packages/schema/src/project.ts | 6 +
17 files changed, 611 insertions(+), 23 deletions(-)
diff --git a/apps/desktop/src/main/desktop-apps.ts b/apps/desktop/src/main/desktop-apps.ts
index f08319b..c8b2b6f 100644
--- a/apps/desktop/src/main/desktop-apps.ts
+++ b/apps/desktop/src/main/desktop-apps.ts
@@ -63,10 +63,19 @@ const File = z.strictObject({
})
type File = z.infer
-/** What a project opens with an app (its arguments and sites), as one digest. */
-export function opensOf(app: Pick): string {
+/**
+ * What a project opens with an app (its arguments, sites, terminals), as one digest: what its
+ * approval covers. Terminals only when on (an approval from before them keeps its digest).
+ */
+export function opensOf(app: Pick): string {
return createHash("sha256")
- .update(JSON.stringify({ args: app.args ?? [], origins: app.origins ?? [] }))
+ .update(
+ JSON.stringify({
+ args: app.args ?? [],
+ origins: app.origins ?? [],
+ ...(app.terminals === true && { terminals: true }),
+ }),
+ )
.digest("hex")
}
@@ -280,9 +289,10 @@ export interface DesktopCard {
existing: string | undefined
/**
* What that project opens with it, shown before it's allowed: its arguments (paths in files/,
- * not passed to the check) and the sites it shows as its own (the check runs with them).
+ * not passed to the check), the sites it shows as its own (the check runs with them), and
+ * whether it opens terminals (never in the check: a few seconds need no shell).
*/
- opens: { args: string[]; origins: string[] } | undefined
+ opens: { args: string[]; origins: string[]; terminals: boolean } | undefined
}
/**
@@ -408,7 +418,14 @@ export class DesktopAdds {
signer:
app.signer.kind === "team" ? { kind: "team", team: app.signer.team } : { kind: "pinned" },
existing,
- opens: entry === undefined ? undefined : { args: [...(entry.args ?? [])], origins: listed },
+ opens:
+ entry === undefined
+ ? undefined
+ : {
+ args: [...(entry.args ?? [])],
+ origins: listed,
+ terminals: entry.terminals === true,
+ },
}
} finally {
if (this.#picks.get(owner)?.generation === generation) this.#picks.delete(owner)
@@ -463,9 +480,14 @@ export class DesktopAdds {
* The tried app added to the project (or, named already, allowed here): its build checked again
* (the one tried), the project written (its changed-on-disk refusal), then approved. Once the
* project is written the add is done (an approval that fails then: said, allowed again later).
+ * `terminals`: a new app's, as the user chose on its card (an app the project names keeps the
+ * project's: shown on its card, never changed here).
*/
- async add(owner: number, session: string, token: string): Promise {
+ async add(owner: number, session: string, token: string, terminals = false): Promise {
const pending = this.#own(owner, session, token)
+ if (terminals && pending.existing !== undefined) {
+ throw refused("the project says whether it opens terminals: remove it to change that")
+ }
if (pending.trial !== undefined) throw refused("it's being checked: wait for it")
if (!pending.tried) throw refused("check it first: it's added once it ran here")
if (pending.adding) throw refused("it's being added already")
@@ -507,6 +529,7 @@ export class DesktopAdds {
kind: "electron",
bundleId: now.bundleId,
...(pending.origins.length > 0 && { origins: pending.origins }),
+ ...(terminals && { terminals: true }),
viewport: { width: 1440, height: 900, deviceScaleFactor: 1 },
}
save(opened, name, entry)
diff --git a/apps/desktop/src/main/desktop-launch.ts b/apps/desktop/src/main/desktop-launch.ts
index 8bb4d7c..821c3e4 100644
--- a/apps/desktop/src/main/desktop-launch.ts
+++ b/apps/desktop/src/main/desktop-launch.ts
@@ -139,6 +139,7 @@ export class DesktopLauncher {
...(existsSync(files) && { files }),
...(entry.args !== undefined && { args: entry.args }),
...(entry.origins !== undefined && { origins: entry.origins }),
+ ...(entry.terminals === true && { terminals: true }),
viewport: entry.viewport,
...(signal !== undefined && { signal }),
...(this.#deps.workDir !== undefined && { workDir: this.#deps.workDir }),
diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts
index 3d28f8f..226e3ac 100644
--- a/apps/desktop/src/main/index.ts
+++ b/apps/desktop/src/main/index.ts
@@ -598,9 +598,9 @@ function start(): void {
return { failed: message(e) }
}
},
- "apps:desktop-add": async ({ session, token }) => {
+ "apps:desktop-add": async ({ session, token, terminals }) => {
try {
- await adds.add(owner(), session, token)
+ await adds.add(owner(), session, token, terminals)
return null
} catch (e) {
return message(e)
diff --git a/apps/desktop/src/renderer/src/components/apps-panel.tsx b/apps/desktop/src/renderer/src/components/apps-panel.tsx
index dfabcbd..6c05162 100644
--- a/apps/desktop/src/renderer/src/components/apps-panel.tsx
+++ b/apps/desktop/src/renderer/src/components/apps-panel.tsx
@@ -16,6 +16,11 @@ import { useChat } from "../chat-store.ts"
type Card = Extract["card"]
+/** What an app allowed its own terminals is open to (said on its card before it's added). */
+const TERMINALS_WARNING =
+ "Its terminals work. While Kiframe runs it, a terminal you open (in any app) can be read and " +
+ "typed into by it: only for apps you trust, and review what Kif does with it."
+
/** A desktop app's status on this Mac, in words. */
function statusText(entry: DesktopStatusView["apps"][string] | undefined): string {
if (entry === undefined) return ""
@@ -41,6 +46,8 @@ export function AppsPanel({
const [checking, setChecking] = useState(false)
const [picking, setPicking] = useState(false)
const [adding, setAdding] = useState(false)
+ // A new app's terminals (off unless the user ticks it for this card).
+ const [terminals, setTerminals] = useState(false)
// The card a check's answer belongs to (an answer for a card given up: dropped).
const current = useRef(null)
current.current = card?.token ?? null
@@ -114,6 +121,7 @@ export function AppsPanel({
setError(picked.refused)
return
}
+ setTerminals(false)
setCard(picked.card)
}
@@ -139,7 +147,11 @@ export function AppsPanel({
addingNow.current = token
setAdding(true)
const refused = await api()
- .invoke("apps:desktop-add", { session: project.session, token })
+ .invoke("apps:desktop-add", {
+ session: project.session,
+ token,
+ terminals: card.existing === undefined && terminals,
+ })
.catch((e: unknown) => String(e))
if (addingNow.current === token) addingNow.current = null
setAdding(false)
@@ -231,14 +243,33 @@ export function AppsPanel({
This project names it already (as {card.existing}): checking it allows it here, with
what the project opens with it:{" "}
{card.opens === undefined ||
- (card.opens.args.length === 0 && card.opens.origins.length === 0)
+ (card.opens.args.length === 0 &&
+ card.opens.origins.length === 0 &&
+ !card.opens.terminals)
? "nothing more."
: [
...card.opens.origins.map((o) => `the site ${o} as its own`),
...card.opens.args.map((a) => `${a} (not opened by the check)`),
+ ...(card.opens.terminals ? ["terminals of its own"] : []),
].join(", ")}
Kiframe opens {card.name} confined (it can’t read or change your files, your own{" "}
{card.name}’s data or its preferences) for a few seconds, to check it works there.
diff --git a/apps/desktop/src/renderer/src/styles.css b/apps/desktop/src/renderer/src/styles.css
index af703e7..bf6e78c 100644
--- a/apps/desktop/src/renderer/src/styles.css
+++ b/apps/desktop/src/renderer/src/styles.css
@@ -1679,6 +1679,14 @@ ul.strip-cards > li {
color: var(--muted);
}
+.app-card-option {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 4px 0 8px;
+ font-size: 13px;
+}
+
.app-card-label {
font-style: italic;
}
diff --git a/apps/desktop/src/renderer/test/apps.test.tsx b/apps/desktop/src/renderer/test/apps.test.tsx
index ec3b981..65fc337 100644
--- a/apps/desktop/src/renderer/test/apps.test.tsx
+++ b/apps/desktop/src/renderer/test/apps.test.tsx
@@ -168,7 +168,11 @@ describe("adding a desktop app", () => {
token: card.token,
allowSite: true,
})
- expect(invoke).toHaveBeenCalledWith("apps:desktop-add", { session: "s1", token: card.token })
+ expect(invoke).toHaveBeenCalledWith("apps:desktop-add", {
+ session: "s1",
+ token: card.token,
+ terminals: false,
+ })
// The window named no path and no site: only its session, the token and a yes.
expect(JSON.stringify(invoke.mock.calls)).not.toMatch(/Applications|slack\.com"/)
})
@@ -200,7 +204,7 @@ describe("adding a desktop app", () => {
card: {
...card,
existing: "slack",
- opens: { args: ["files/vault"], origins: ["https://evil.example"] },
+ opens: { args: ["files/vault"], origins: ["https://evil.example"], terminals: false },
},
}),
"apps:desktop-cancel": () => undefined,
@@ -212,6 +216,68 @@ describe("adding a desktop app", () => {
const adding = await within(panel).findByRole("region", { name: "Adding Slack" })
expect(adding.textContent).toContain("the site https://evil.example as its own")
expect(adding.textContent).toContain("files/vault (not opened by the check)")
+ expect(within(adding).queryByRole("note")).toBeNull()
+ })
+
+ it("warns before allowing an app the project lets open terminals (never a choice made here)", async () => {
+ const { invoke } = stubApi({
+ "app:status": () => status({ hasKey: true, project }),
+ "apps:desktop-pick": () => ({
+ card: { ...card, existing: "minmux", opens: { args: [], origins: [], terminals: true } },
+ }),
+ "apps:desktop-check": () => ({ ok: true }),
+ "apps:desktop-add": () => null,
+ "apps:desktop-cancel": () => undefined,
+ })
+ render()
+ fireEvent.click(await screen.findByRole("button", { name: /app\.test/ }))
+ const panel = screen.getByRole("dialog", { name: "Apps" })
+ fireEvent.click(within(panel).getByRole("button", { name: "Add desktop app…" }))
+ const adding = await within(panel).findByRole("region", { name: "Adding Slack" })
+ expect(adding.textContent).toContain("terminals of its own")
+ expect(within(adding).getByRole("note").textContent).toMatch(/can be read and typed into/)
+ // The project's say, shown: no box to change it here.
+ expect(within(adding).queryByRole("checkbox")).toBeNull()
+ fireEvent.click(within(adding).getByRole("button", { name: "Check" }))
+ fireEvent.click(await within(adding).findByRole("button", { name: "Allow Slack here" }))
+ await act(async () => {
+ await Promise.resolve()
+ })
+ expect(invoke).toHaveBeenCalledWith("apps:desktop-add", {
+ session: "s1",
+ token: card.token,
+ terminals: false,
+ })
+ })
+
+ it("lets a new app open terminals only when ticked, warned of what that exposes", async () => {
+ const { invoke } = stubApi({
+ "app:status": () => status({ hasKey: true, project }),
+ "apps:desktop-pick": () => ({ card }),
+ "apps:desktop-check": () => ({ ok: true }),
+ "apps:desktop-add": () => null,
+ "apps:desktop-cancel": () => undefined,
+ })
+ render()
+ fireEvent.click(await screen.findByRole("button", { name: /app\.test/ }))
+ const panel = screen.getByRole("dialog", { name: "Apps" })
+ fireEvent.click(within(panel).getByRole("button", { name: "Add desktop app…" }))
+ const adding = await within(panel).findByRole("region", { name: "Adding Slack" })
+ const box = within(adding).getByRole("checkbox", { name: /It opens terminals/ })
+ expect((box as HTMLInputElement).checked).toBe(false)
+ expect(within(adding).queryByRole("note")).toBeNull()
+ fireEvent.click(box)
+ expect(within(adding).getByRole("note").textContent).toMatch(/can be read and typed into/)
+ fireEvent.click(within(adding).getByRole("button", { name: "Check" }))
+ fireEvent.click(await within(adding).findByRole("button", { name: "Add Slack" }))
+ await act(async () => {
+ await Promise.resolve()
+ })
+ expect(invoke).toHaveBeenCalledWith("apps:desktop-add", {
+ session: "s1",
+ token: card.token,
+ terminals: true,
+ })
})
it("never shows a check's answer on a card picked after it was given up", async () => {
diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts
index 9f4d45a..524ee02 100644
--- a/apps/desktop/src/shared/ipc.ts
+++ b/apps/desktop/src/shared/ipc.ts
@@ -313,7 +313,7 @@ export type DesktopPick =
/** The project names it already (a project from someone else): adding approves it here. */
existing: string | undefined
/** What that project opens with it (shown before it's allowed). */
- opens: { args: string[]; origins: string[] } | undefined
+ opens: { args: string[]; origins: string[]; terminals: boolean } | undefined
}
}
| { refused: string }
@@ -443,7 +443,9 @@ export const invokeArgs = {
z.strictObject({ session: z.string().max(64), token: z.uuid(), allowSite: z.boolean() }),
]),
/** The tried app added to the open project (or, named already, approved here). */
- "apps:desktop-add": z.tuple([z.strictObject({ session: z.string().max(64), token: z.uuid() })]),
+ "apps:desktop-add": z.tuple([
+ z.strictObject({ session: z.string().max(64), token: z.uuid(), terminals: z.boolean() }),
+ ]),
/** The add given up (its trial ended). */
"apps:desktop-cancel": z.tuple([]),
/** The open project's desktop apps as this Mac has them (static: nothing launches). */
diff --git a/apps/desktop/test/desktop-apps.test.ts b/apps/desktop/test/desktop-apps.test.ts
index 2e5ba20..f1278d3 100644
--- a/apps/desktop/test/desktop-apps.test.ts
+++ b/apps/desktop/test/desktop-apps.test.ts
@@ -196,6 +196,16 @@ describe("a desktop app's status (static: nothing launches)", () => {
expect((await status(at, approved, opens)).status).toBe("opens-changed")
})
+ it("asks again for an app the project now lets open terminals; off is as never said", async () => {
+ const at = { "/Applications/Notes.app": notes() }
+ expect((await status(at, approved, entry({ terminals: true }))).status).toBe("opens-changed")
+ expect((await status(at, approved, entry({ terminals: false }))).status).toBe("ready")
+ // Approvals from before terminals keep their digest (never all asked again by an update).
+ expect(opensOf(entry())).toBe(
+ "be954a12116b1369a8f5d36bdb9f9c4482b0bc8cb3465685b11bbf6106b7584b",
+ )
+ })
+
it("keeps each project's tried build: another's approval of an update changes nothing here", async () => {
const approvals = new DesktopApprovals(dir())
approvals.approve(notes(), "folder-b", opensOf(entry()))
@@ -318,6 +328,23 @@ describe("adding a desktop app", () => {
).toMatchObject({ status: "ready" })
})
+ it("lets it open terminals only as its card was ticked, approved with them", async () => {
+ for (const terminals of [false, true]) {
+ const h = harness()
+ const card = await h.pick()
+ await h.adds.check(1, "s1", card.token, false)
+ // Its check never opens terminals (a few seconds need no shell).
+ expect(h.fake.trials[0]).not.toHaveProperty("terminals")
+ expect(await h.adds.add(1, "s1", card.token, terminals)).toBe("notes")
+ const written = openProject(h.opened.dir).project.apps["notes"] as ElectronApp
+ // Written only when on (never `terminals: false`).
+ expect("terminals" in written, `terminals: ${terminals}`).toBe(terminals)
+ expect(await desktopStatus(written, "folder-a", h.approvals, h.fake.looks)).toMatchObject({
+ status: "ready",
+ })
+ }
+ })
+
it("allows a wrapper's site only as the trial named it, tried before it's added", async () => {
const h = harness(project(), undefined, (o) =>
(o.origins ?? []).length > 0 ? { ok: true } : { site: "https://app.slack.com" },
@@ -485,7 +512,11 @@ describe("adding an app a project already names", () => {
const h = harness(named())
const card = await h.pick()
expect(card.existing).toBe("desk")
- expect(card.opens).toEqual({ args: ["files/vault"], origins: ["https://a.example"] })
+ expect(card.opens).toEqual({
+ args: ["files/vault"],
+ origins: ["https://a.example"],
+ terminals: false,
+ })
await h.adds.check(1, "s1", card.token, false)
expect(h.fake.trials[0]?.origins).toEqual(["https://a.example"])
expect(await h.adds.add(1, "s1", card.token)).toBe("desk")
@@ -510,6 +541,42 @@ describe("adding an app a project already names", () => {
await expect(h.adds.add(1, "s1", card.token)).rejects.toThrow(/the project changed meanwhile/)
})
+ it("shows its terminals before it's allowed, and never turns them on from the card", async () => {
+ const opened = project()
+ saveProject(opened, {
+ ...opened.project,
+ apps: { ...opened.project.apps, desk: entry({ terminals: true }) },
+ })
+ const h = harness(opened)
+ const card = await h.pick()
+ expect(card.opens?.terminals).toBe(true)
+ await h.adds.check(1, "s1", card.token, false)
+ expect(await h.adds.add(1, "s1", card.token)).toBe("desk")
+ expect(h.approvals.copyFor("com.example.Notes", "folder-a")?.scopes["folder-a"]?.opens).toBe(
+ opensOf(entry({ terminals: true })),
+ )
+ // One it names without them: the card's say never adds them.
+ const plain = harness(named())
+ const off = await plain.pick()
+ await plain.adds.check(1, "s1", off.token, false)
+ await expect(plain.adds.add(1, "s1", off.token, true)).rejects.toThrow(/remove it to change/)
+ expect(openProject(plain.opened.dir).project.apps["desk"]).not.toHaveProperty("terminals")
+ expect(plain.approvals.copyFor("com.example.Notes", "folder-a")).toBeUndefined()
+ })
+
+ it("never approves terminals turned on since the pick", async () => {
+ const h = harness(named())
+ const card = await h.pick()
+ await h.adds.check(1, "s1", card.token, false)
+ const desk = h.opened.project.apps["desk"] as ElectronApp
+ saveProject(h.opened, {
+ ...h.opened.project,
+ apps: { ...h.opened.project.apps, desk: { ...desk, terminals: true } },
+ })
+ await expect(h.adds.add(1, "s1", card.token)).rejects.toThrow(/the project changed meanwhile/)
+ expect(h.approvals.copyFor("com.example.Notes", "folder-a")).toBeUndefined()
+ })
+
it("writes a site allowed in its check to the project, and approves that", async () => {
const opened = project()
saveProject(opened, { ...opened.project, apps: { ...opened.project.apps, desk: entry() } })
diff --git a/apps/desktop/test/desktop-launch.test.ts b/apps/desktop/test/desktop-launch.test.ts
index c60f458..4212199 100644
--- a/apps/desktop/test/desktop-launch.test.ts
+++ b/apps/desktop/test/desktop-launch.test.ts
@@ -121,6 +121,31 @@ describe("a desktop app launched for the agent", () => {
expect(state.launches).toEqual([])
})
+ it("opens terminals only as approved here: a project that turned them on is asked again", async () => {
+ // Never when the project doesn't say so.
+ const plain = setup()
+ await plain.launcher.launch("notes")
+ expect(plain.state.launches[0]?.terminals).toBeUndefined()
+ // Turned on (a teammate's push, a hand edit), an update waiting too: refused, never tried.
+ for (const installed of [notes(), notes({ version: "1.1" })]) {
+ const { launcher, state, approvals, opened } = setup(installed)
+ saveProject(opened, {
+ ...opened.project,
+ apps: { ...opened.project.apps, notes: entry({ terminals: true }) },
+ })
+ await expect(launcher.launch("notes")).rejects.toThrow(/changed what it opens/)
+ expect(state.launches).toEqual([])
+ expect(state.trials).toBe(0)
+ expect(approvals.copyFor("com.example.Notes", "folder-a")?.scopes["folder-a"]?.opens).toBe(
+ opensOf(entry()),
+ )
+ // Allowed again here (the card said it): launched with them.
+ approvals.approve(installed, "folder-a", opensOf(entry({ terminals: true })))
+ await launcher.launch("notes")
+ expect(state.launches[0]?.terminals).toBe(true)
+ }
+ })
+
it("tries an update quietly, then approves that build for the project and launches it", async () => {
const { launcher, state, approvals } = setup(notes({ version: "1.1" }))
await launcher.launch("notes")
diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md
index 9428843..c32f267 100644
--- a/docs/BACKLOG.md
+++ b/docs/BACKLOG.md
@@ -243,6 +243,7 @@ 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).
+- **Terminals in a desktop app's demo (decided 2026-10-11):** an app its project lets open terminals (`terminals: true`, chosen when it's added, warned, approved with it) gets new ptys; the user's terminals open at launch are never reached (read, written, chmod, revoked). Stated gaps: a terminal the user opens while a launch lives (any app's) can be read (keystrokes, passwords), written and revoked by it, since Seatbelt can't tell its ptys from the user's: a pty broker (Kiframe opens them, the app gets descriptors) would close it. Closing a terminal that was open at launch can make the app's next pty fail (its number is reused, still denied). Helpers some apps run through a pty without terminals (askpass, some language servers) fail. An app the project already names keeps its project's setting: remove and add it again to change it. Its checks (adding it, a quiet trial of an update) run without terminals: an app that quits without a pty at start can't be added yet.
- **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).
diff --git a/docs/FAILURE-CATALOGUE.md b/docs/FAILURE-CATALOGUE.md
index ba09b0d..b3b69e4 100644
--- a/docs/FAILURE-CATALOGUE.md
+++ b/docs/FAILURE-CATALOGUE.md
@@ -29,3 +29,4 @@
| 21 | Every snapshot ref refused | Cal.com (desktop app, 2026-10-03, acting by ref) | Right after a snapshot, `run_steps` said "ref e23: it isn't on the page anymore" twice; the agent went back to writing locators. Playwright's `elementHandle()` on an `aria-ref` waits about 1.6 s on Cal.com's login page (count is 1 at once), past the 1 s the studio gave it | — | Done: a ref's element is looked up with `elementHandles()` (no wait: a fresh snapshot just saw it), right before its step |
| 22 | Content past the snapshot's cut | Wikipedia (desktop app, 2026-10-05) | "Search Messi, then go to his WC26 campaign": the article's snapshot is 1.14 M characters, the agent reads the first 20 k. It never saw the 2026 part: ~35 snapshots (25 failed `within` guesses: Wikipedia's sections have no names), the search page, the API, the printable page, until the 12 min ran out ($0.11). The user saw it as "scroll not working". With `find` (rerun, traced) it scrolled to the 2026 part in 3 min, then took a snapshot "to check where the page is now": a snapshot starts at the page's top whatever the scroll, and it thought 283 s about why nothing moved. Model time 704 s of 730 (tools 26 s) | — | Done: `snapshot` takes `find` (the elements that mention a text, with their ancestors and refs, searched in the scrubbed snapshot); the cut says to use it; every snapshot says where the view is (how far down, the section) and that it covers the whole page. The thinking itself: capped next (its own PR) |
| 23 | The recording fails where the replay passed | Wikipedia (desktop app, 2026-10-05/06) | "Search Messi": `save_scene`'s replay passed, `record_scene` failed with "locator.press: Timeout 15000ms exceeded" on the search step, every run (the agent worked around it, minutes each time). Typed at a person's pace, Wikipedia swaps its search box for a combobox mid-typing: the text still goes to the focused new field, but `submit` pressed Enter on the target's locator (a "Search Wikipedia" searchbox, gone). The replay typed at once, before the swap | — | Done: when the focused text field holds the typed text and the target is gone (or doesn't hold it: a modal took the keys), Enter goes where the text is; otherwise the target, as before. `save_scene` replays with the recording's typing and settling (pointer at once) and pixel ratio, so what a person's typing changes fails before the recording |
+| 24 | A terminal app's shell refused by the confinement | minmux (desktop app, confined, 2026-10-11) | Every terminal pane said "posix_spawnp failed": Seatbelt denied /dev/ptmx, so no pty could be made; Kif spent the run on diagnostics. Measuring it found the old rule let any confined app print into every terminal the user had open (input injection refused by macOS) | Per app, opt-in (user decision): `terminals: true` allows new ptys and denies the user's terminals open at launch (every way: read, write, chmod, ioctl, revoke); every other app gets no terminal at all | Done (stated gap: terminals opened while it runs, BACKLOG) |
diff --git a/docs/OBJECT-MODEL.md b/docs/OBJECT-MODEL.md
index 3dc6e05..e2ddbeb 100644
--- a/docs/OBJECT-MODEL.md
+++ b/docs/OBJECT-MODEL.md
@@ -223,13 +223,14 @@ One scene, three phases. The agent's own instructions say the same (`packages/st
**Desktop apps.** The same `apps` entry with `kind: electron` (launched and attached over its debugging port: `--remote-debugging-port` + `connectOverCDP`, measured working on a hardened packaged app in Phase 0, F4) or `kind: tauri` (WebKit on macOS: no CDP, partial support, APPROACHES §6b). The format is ready for them; driving them is v0.1 work.
-**Electron apps (designed 2026-10-08, design reviewed; moved into v0).** `{ kind: "electron", bundleId, args?, origins?, viewport }`:
+**Electron apps (designed 2026-10-08, design reviewed; moved into v0).** `{ kind: "electron", bundleId, args?, origins?, terminals?, 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 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); the network on, for runs and trials alike (the app's backend, a wrapper's site: an updater can't touch the real app, nothing outside the sandbox being written; what an app could send is only what it can read here: its bundle, the system's files, its own sandbox). Chromium's own sandbox can't nest in it (`--no-sandbox`): the guard and this profile replace it. A canary before every launch, before the files are copied (a read the profile refuses must fail, one of its sandbox succeed: a confinement that runs nothing never passes); no `sandbox-exec`, or a canary failed: no launch (never unconfined). Off macOS: desktop apps don't launch (CI's tests only, unconfined). Measured with a probe app: every escape above `EPERM` (a hard link or a symlink to an outside file too), the app driven as usual. This replaces the trial's after-the-fact isolation check, rejected by review (an app that leaks does its harm as it starts). **What it doesn't stop (stated):** an app can still ask macOS to open a file or a URL (LaunchServices: no app starts without reaching it, measured), and what opens runs outside the confinement; it reaches services on this machine (localhost: a dev build's own server needs it). The app's own pages are its bundle and what it writes in its sandbox's home, profile and temp folders (a page it builds there from remote content counts as its own, as its cache already does); never the files/ copy. The confinement keeps an app from the user's data by accident (the minmux incident), not an app built to escape (one the user runs unconfined every day anyway).
+- **Terminals, per app (2026-10-11, design reviewed twice, measured):** no app reaches a terminal (no pty, none of the user's ttys read or written) unless its project says `terminals: true` (a terminal app, an editor's terminal): ticked on its add card, which says what it exposes; part of what its approval covers (in the opens digest only when on: approvals from before keep theirs), so a project that turns it on is asked again, and an app the project names already shows it before it's allowed (never changed from the card). Such an app's launch lists the user's terminals open just before it starts (ptys a user owns: a free pty's leftover node is root's, left for the app's own next pty; one in use by root is closed to it by its mode) and denies each every way (read, write, chmod, ioctl, revoke), allowing new ptys; that profile is checked to load before the app starts. Stated: a terminal the user opens while it runs is exposed to it (BACKLOG). Its checks run without terminals. Its shells' prompt shows the folder only (user decision 2026-10-11: a `.zshrc`, `.bashrc`, `.bash_profile` and fish config in its fresh home; never the user's login or Mac's name on camera).
- **Adding a desktop app (PR 3b, design reviewed 2026-10-09):** inspected first, statically (`electron-inspect.ts`: only `plutil` and `codesign` run): refused outside where an app can stay (a disk image, a temporary translocated copy, the Trash, inside another app), not Electron (its framework; no version gate: Figma reports 13.6.1), an executable that isn't its own file, a Mac App Store build (App Sandbox), a developer signature that doesn't hold (`codesign --verify --strict` against its team and signed identifier). Kept: its id, name, Electron's version, and its signer: a developer (team id) or, ad hoc and unsigned (the user's own builds), pinned to a digest of the whole bundle (a link leading outside it refused; at most 4 GB and 200,000 entries). Then **tried confined** (`electron-trial.ts`): launched with the network as in a run (an updater can't touch the real app: nothing outside the sandbox is written; a wrapper's offline page can't hide its site), its main window attached, closed; a wrapper's site is named (even unreached: the error page's unreachable URL, over CDP) and allowed by the user in a second trial. A main window that never loaded its own page (offline, a dev server not running) is refused at launch, said as such: never a window driven on Chromium's error page. An attach that hangs as the first page fails (measured 1 in 5; cause not found) is tried again within the launch's time (stated). A trial runs only the build that was picked (its developer's signature, or its pinned digest, checked first; a change between that check and the launch is the user's own build tool's: holding every launch to its approval is PR 4's, with runs). codesign that fails or times out refuses the app (never read as unsigned or unsandboxed). Apps that allow one copy (Slack, VS Code, Discord) need Chromium's temp folder in the sandbox (`MAC_CHROMIUM_TMPDIR`): its lock's socket otherwise goes to the user's /var/folders, denied, and the app quits.
- **Approvals (PR 3b-2, user decisions 2026-10-09):** added from the Apps panel ("Add desktop app…", macOS): main shows the picker (Applications) and inspects what was picked; the window gets a card and a token, never the app's path, and never names a site to allow (the trial's, kept in main). Tried confined, then added (the build that was tried is approved: one updated since says "updated"; `project.json`, its changed-on-disk refusal) and approved in app data (`desktop-apps.json`, 0600, written whole; one that doesn't read is set aside and said, never overwritten). An approval is per copy of the app on this Mac (where it was picked: a release and a dev build of one bundle id kept apart, a project using one of them; at most 20 copies of one app kept, the oldest let go) and per project folder (the registry's scope) and covers the app (its developer's team, or the exact build of an unsigned one) **and what the project opens with it** (its `args` and `origins`, as a digest): a pull that changes either asks again. Statuses (static, opening the panel launches nothing): ready; updated (same developer, a build this project hasn't tried: tried confined before its next run, PR 4, which records it); allow (approved for another project, or never on this Mac: pick it to allow it here, without adding it twice); opens-changed; changed (another developer, another unsigned build); not found. Removing the app drops this project's approval. Off macOS: nothing offered. A trial in progress ends with its add (another pick, the project closed, a quit); what a crash left is swept at the start.
- **Launching an approved app (PR 4, design reviewed 2026-10-10):** main launches a project's desktop app only from its approval for this project folder and what it opens (`desktop-launch.ts`): the copy approved, inspected once (its status); `ready` launches it (the copy's executable and bundle; the project's `files/` when it has one, its `args`, `origins`, viewport); `updated` (the same developer's new build) is tried confined first, quietly (one trial per app and project at a time), then that build approved for the project compare-and-set (the same copy, developer and opens, the project still the open one: else refused); anything else is refused for the user to settle in the Apps panel (`needsUser`: the agent says so, never retries). A take of a desktop app keeps its build (`appBuild`: its version and what it opened; never in the take's key, never a reason to record again: user decision 2026-10-10; an older Kiframe can't read such a take: its meta is strict). Kif starting ends an add's check in progress.
diff --git a/packages/runtime/src/electron-confine.ts b/packages/runtime/src/electron-confine.ts
index b17070a..97e59d1 100644
--- a/packages/runtime/src/electron-confine.ts
+++ b/packages/runtime/src/electron-confine.ts
@@ -28,6 +28,11 @@ export interface Confinement {
* they are (a home outside /Users on a managed Mac).
*/
private: readonly string[]
+ /**
+ * Its own terminals allowed (the project's app opens them, approved as such): the user's open at
+ * launch (`openTerminals`) never reached. Absent: no terminal at all.
+ */
+ terminals?: { existing: readonly string[] }
}
/**
@@ -36,13 +41,84 @@ export interface Confinement {
*/
export const quoted = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
+/** What `openTerminals` reads of /dev (the tests' own). */
+export interface DevView {
+ list: () => Promise
+ /** A node's owner; undefined when it's gone (closed meanwhile). */
+ owner: (path: string) => Promise
+}
+
+const DEV: DevView = {
+ list: () => readdir("/dev"),
+ owner: async (path) => {
+ try {
+ return (await lstat(path)).uid
+ } catch (error) {
+ if ((error as { code?: string }).code === "ENOENT") return undefined
+ throw error
+ }
+ },
+}
+
+/**
+ * The user's terminals there now (ptys owned by a user: a pty in use belongs to whoever opened
+ * it), never reached by an app allowed its own. A free pty's node stays, owned by root (measured
+ * 2026-10-11): never listed, the app's own next pty may take its number; one in use by root is
+ * closed to the app by its mode already. Seatbelt can't tell a pty the app opens later from one
+ * the user opens: those stay exposed (stated in BACKLOG). Unreadable: refused (never unlisted).
+ */
+export async function openTerminals(dev: DevView = DEV): Promise {
+ try {
+ const paths = (await dev.list()).filter((n) => /^ttys[0-9]+$/.test(n)).map((n) => `/dev/${n}`)
+ const owners = await Promise.all(paths.map((path) => dev.owner(path)))
+ return paths.filter((_, i) => owners[i] !== undefined && owners[i] !== 0)
+ } catch {
+ throw new ConfinementError("the open terminals couldn't be listed: the app isn't launched")
+ }
+}
+
+/**
+ * The profile with an app's terminals parses (it isn't the one the canary ran): a macOS that
+ * refused it would otherwise read as an app that quit at once.
+ */
+export function checkProfile(profile: string, signal?: AbortSignal): Promise {
+ return new Promise((resolve, reject) => {
+ execFile(
+ SANDBOX_EXEC,
+ ["-p", profile, "/usr/bin/true"],
+ { timeout: 5000, ...(signal !== undefined && { signal }) },
+ (error) =>
+ error === null
+ ? resolve()
+ : reject(new ConfinementError("the confinement for its terminals didn't load")),
+ )
+ })
+}
+
+/**
+ * Every way to reach a terminal: read, written, its mode changed, its ioctls, revoked (killed),
+ * its times watched (when the user types).
+ */
+const TERMINAL_OPS = "file-read-data file-read-metadata file-write* file-ioctl file-revoke"
+
/** 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 ${quoted(p)})`).join(" ")
+ // Its own ptys (new ones), or none; never the user's (open at launch), serial lines, BSD ptys.
+ const ptys =
+ c.terminals === undefined ? "" : ' (literal "/dev/ptmx") (regex #"^/dev/ttys[0-9]+$")'
+ // Every tty but its own new ptys (ttysNNN) and /dev/tty; the user's ptys, each.
+ const terminals =
+ c.terminals === undefined
+ ? '(regex #"^/dev/tty.") (literal "/dev/ptmx")'
+ : [
+ '(regex #"^/dev/tty[^s]")',
+ ...c.terminals.existing.map((t) => `(literal ${quoted(t)})`),
+ ].join(" ")
return `(version 1)
(allow default)
(deny file-write*)
-(allow file-write* (subpath ${quoted(c.sandbox)}) (literal "/dev/null") (literal "/dev/zero") (literal "/dev/dtracehelper") (regex #"^/dev/tty") (regex #"^/dev/fd/"))
+(allow file-write* (subpath ${quoted(c.sandbox)}) (literal "/dev/null") (literal "/dev/zero") (literal "/dev/dtracehelper") (literal "/dev/tty")${ptys} (regex #"^/dev/fd/"))
(deny file-read-data (subpath "/Users") (subpath "/Volumes") (subpath "/private/var/folders") (subpath "/private/tmp") (subpath "/var/folders") (subpath "/tmp")${c.private.map((p) => ` (subpath ${quoted(p)})`).join("")})
(allow file-read-data ${readable})
(deny network-outbound (remote unix-socket))
@@ -50,6 +126,7 @@ export function seatbeltProfile(c: Confinement): string {
(deny appleevent-send)
(deny process-exec* (subpath ${quoted(c.sandbox)}) (literal "/usr/bin/open") (literal "/usr/bin/osascript") (literal "/bin/launchctl"))
(deny mach-lookup (global-name "com.apple.cfprefsd.agent") (global-name "com.apple.SecurityServer") (global-name "com.apple.pasteboard.1"))
+(deny ${TERMINAL_OPS} (regex #"^/dev/cu\\.") (regex #"^/dev/pty") ${terminals})
`
}
diff --git a/packages/runtime/src/electron.ts b/packages/runtime/src/electron.ts
index c2d85c2..ab16449 100644
--- a/packages/runtime/src/electron.ts
+++ b/packages/runtime/src/electron.ts
@@ -1,6 +1,14 @@
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 {
+ accessSync,
+ constants,
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ realpathSync,
+ writeFileSync,
+} from "node:fs"
import { rm } from "node:fs/promises"
import { userInfo } from "node:os"
import { join, sep } from "node:path"
@@ -10,8 +18,11 @@ import {
CONFINED_SWITCHES,
argumentIn,
checkConfinement,
+ checkProfile,
+ type Confinement,
copyFiles,
FilesError,
+ openTerminals,
SANDBOX_EXEC,
seatbeltProfile,
} from "./electron-confine.ts"
@@ -176,6 +187,11 @@ export interface ElectronLaunch {
viewport: Pick
/** The https sites a wrapper app shows as its own (the project's `origins`). */
origins?: readonly string[]
+ /**
+ * It opens terminals of its own (the project's, approved): new ptys allowed, the user's open at
+ * launch never reached. Off: no terminal at all.
+ */
+ terminals?: boolean
signal?: AbortSignal
/**
* How long its windows must stay as they are before its main window is chosen (default 1.5 s: a
@@ -351,6 +367,24 @@ async function makeSandbox(work: string): Promise<{
}
}
+/**
+ * Its shells' setup (user decision 2026-10-11): a prompt with no login or machine name (a demo
+ * shows it), the folder only. Its home is fresh at each launch: these are all it holds.
+ */
+function neutralShell(home: string, env: NodeJS.ProcessEnv): void {
+ writeFileSync(join(home, ".zshrc"), "PROMPT='%1~ %# '\n")
+ for (const file of [".bashrc", ".bash_profile"]) {
+ writeFileSync(join(home, file), "PS1='\\W \\$ '\n")
+ }
+ mkdirSync(join(home, ".config", "fish"), { recursive: true })
+ writeFileSync(
+ join(home, ".config", "fish", "config.fish"),
+ "function fish_prompt; echo -n (prompt_pwd)' > '; end\n",
+ )
+ // macOS's bash says zsh is the default at every start: never on camera.
+ env.BASH_SILENCE_DEPRECATION_WARNING = "1"
+}
+
/** How long the app's windows must stay as they are before its main window is chosen. */
const SETTLE_MS = 1500
@@ -390,10 +424,11 @@ export async function launchElectronWith(
// Its files and its confinement ready before it starts (said, its sandbox removed, if not).
let args: string[]
let profile: string | undefined
+ let confinement: Confinement | undefined
try {
// The confinement first (cheap): a broken one never costs the files' copy.
if (confined) {
- const confinement = {
+ confinement = {
sandbox: sandbox.root,
readable: [...bundles.map(realpathOr), ...(hooks.readable ?? []).map(realpathOr)],
// The home and the work area wherever they are, and the one temp folder left (/var/tmp).
@@ -402,7 +437,6 @@ export async function launchElectronWith(
// Its own canary, beside its sandbox (never inside: that's readable) and named after it:
// launches at once never touch each other's, and a crash's is swept with its sandbox.
await checkConfinement(confinement, `${sandbox.root}.canary`, opts.signal)
- profile = seatbeltProfile(confinement)
}
if (opts.files !== undefined) await copyFiles(opts.files, sandbox.files, opts.signal)
args = (opts.args ?? []).map((a) => argumentIn(a, sandbox.files))
@@ -411,6 +445,24 @@ export async function launchElectronWith(
throw new ElectronLaunchError(`${arg.slice(sandbox.files.length + 1)} isn't in files/`)
}
}
+ if (opts.terminals === true) {
+ // Its profile's shape loads (checked first: the listing below stays the last step).
+ if (confinement !== undefined) {
+ await checkProfile(
+ seatbeltProfile({ ...confinement, terminals: { existing: [] } }),
+ opts.signal,
+ )
+ }
+ neutralShell(sandbox.home, sandbox.env)
+ }
+ // Last before it starts: the user's terminals listed as late as they can be.
+ if (confinement !== undefined) {
+ const existing = opts.terminals === true ? await openTerminals() : undefined
+ profile = seatbeltProfile({
+ ...confinement,
+ ...(existing !== undefined && { terminals: { existing } }),
+ })
+ }
// Stopped meanwhile: never started.
opts.signal?.throwIfAborted()
} catch (error) {
diff --git a/packages/runtime/test/electron.test.ts b/packages/runtime/test/electron.test.ts
index fba3ffe..1af4a98 100644
--- a/packages/runtime/test/electron.test.ts
+++ b/packages/runtime/test/electron.test.ts
@@ -14,7 +14,7 @@ import {
writeFileSync,
} from "node:fs"
import { createRequire } from "node:module"
-import { tmpdir, userInfo } from "node:os"
+import { hostname, tmpdir, userInfo } from "node:os"
import { dirname, join } from "node:path"
import { parseProjectYaml, parseScenarioYaml } from "@kiframe/schema"
import { errors } from "playwright"
@@ -36,7 +36,13 @@ import {
seatbeltProfile,
sweepWorkArea,
} from "../src/index.ts"
-import { checkConfinement, copyFiles } from "../src/electron-confine.ts"
+import {
+ checkConfinement,
+ checkProfile,
+ ConfinementError,
+ copyFiles,
+ openTerminals,
+} from "../src/electron-confine.ts"
import {
attach,
type ElectronLaunch,
@@ -174,6 +180,7 @@ const launch = async (
timeoutMs?: number
signal?: AbortSignal
origins?: string[]
+ terminals?: boolean
} = {},
) => {
const target = await launchElectron({
@@ -1241,6 +1248,167 @@ steps:
}
})
+ /** A terminal of the user's (a pty the test opens, a shell in it): what's printed into it, seen. */
+ const userTerminal = async () => {
+ const shell = spawn(
+ "/usr/bin/script",
+ ["-q", "/dev/null", "/bin/sh", "-c", "tty; exec sleep 120"],
+ {
+ stdio: ["ignore", "pipe", "ignore"],
+ },
+ )
+ let seen = ""
+ shell.stdout.on("data", (d: Buffer) => (seen += d.toString()))
+ expect(await waitFor(() => /\/dev\/ttys[0-9]+/.test(seen), 10_000)).toBe(true)
+ const path = /\/dev\/ttys[0-9]+/.exec(seen)![0]
+ return { path, seen: () => seen, mode: () => statSync(path).mode, close: () => shell.kill() }
+ }
+ const terminalsSeen = async (target: ElectronTarget, name = "terminals.json") => {
+ const file = join(target.sandbox, "profile", name)
+ expect(await waitFor(() => existsSync(file), 15_000)).toBe(true)
+ return JSON.parse(readFileSync(file, "utf8")) as Record
+ }
+
+ it("never reaches the user's terminals; opens its own only when its project allows it", async () => {
+ const user = await userTerminal()
+ const mode = user.mode()
+ try {
+ for (const terminals of [false, true]) {
+ const target = await launch(["terminals", `tty=${user.path}`], { terminals })
+ const seen = await terminalsSeen(target)
+ // Never the user's: not printed into, not read (keystrokes), its mode kept.
+ for (const escape of [
+ "tty-write-outside",
+ "tty-read-outside",
+ "tty-chmod-outside",
+ "tty-stat-outside",
+ ]) {
+ expect(seen[escape], `${escape} (terminals: ${terminals})`).toBe("DENIED EPERM")
+ }
+ // Its own pty (a terminal app's shell): only when allowed.
+ expect(seen["pty-shell"], `terminals: ${terminals}`).toMatch(
+ terminals ? /^OK true/ : /^DENIED/,
+ )
+ // Its shells' prompts: the folder only, never the user's login or Mac's name.
+ if (terminals) {
+ expect(seen["zsh-prompt"]).toMatch(/^OK \S+ %$/)
+ expect(seen["bash-prompt"]).toBe("OK \\W \\$")
+ for (const prompt of [seen["zsh-prompt"], seen["bash-prompt"]]) {
+ expect(prompt).not.toContain(userInfo().username)
+ expect(prompt).not.toContain(hostname().split(".")[0])
+ }
+ }
+ await target.close()
+ }
+ expect(user.seen()).not.toContain("INJECTED")
+ expect(user.mode()).toBe(mode)
+ } finally {
+ user.close()
+ }
+ })
+
+ it("can read a terminal opened while it runs, when allowed its own (the stated gap)", async () => {
+ // Seatbelt can't tell a pty the app opens from one the user opens after the launch: pinned
+ // here, so a fix (a pty broker) flips it knowingly. Without terminals: never.
+ for (const terminals of [true, false]) {
+ const target = await launch(["terminals"], { terminals })
+ await terminalsSeen(target)
+ const user = await userTerminal()
+ try {
+ writeFileSync(join(target.sandbox, "profile", "later.txt"), user.path)
+ const later = await terminalsSeen(target, "later.json")
+ expect(later["tty-read-later"], `terminals: ${terminals}`).toBe(
+ terminals ? "OK" : "DENIED EPERM",
+ )
+ } finally {
+ user.close()
+ await target.close()
+ }
+ }
+ })
+
+ it("lists the user's terminals last, every way to reach one denied; none for an app without", () => {
+ const base = { sandbox: "/s", readable: [], private: [] }
+ const without = seatbeltProfile(base)
+ const last = (p: string) => p.trim().split("\n").at(-1)!
+ expect(last(without)).toMatch(
+ /^\(deny file-read-data file-read-metadata file-write\* file-ioctl file-revoke /,
+ )
+ expect(last(without)).toContain('(regex #"^/dev/tty.") (literal "/dev/ptmx")')
+ expect(without).not.toContain('(regex #"^/dev/ttys[0-9]+$")')
+ const withOwn = seatbeltProfile({
+ ...base,
+ terminals: { existing: ["/dev/ttys001", "/dev/ttys004"] },
+ })
+ expect(withOwn).toContain('(literal "/dev/ptmx") (regex #"^/dev/ttys[0-9]+$")')
+ expect(last(withOwn)).toMatch(
+ /^\(deny file-read-data file-read-metadata file-write\* file-ioctl file-revoke /,
+ )
+ // Every other tty (serial lines, old BSD ptys), then the user's ptys, each.
+ expect(last(withOwn)).toContain(
+ '(regex #"^/dev/tty[^s]") (literal "/dev/ttys001") (literal "/dev/ttys004")',
+ )
+ // Never every terminal writable (the rule that let an app print into the user's).
+ for (const p of [without, withOwn]) expect(p).not.toContain('(regex #"^/dev/tty")')
+ })
+
+ it("lists the user's ptys only: a free one's node (root's) is the app's to take", async () => {
+ const nodes: Record = {
+ "/dev/ttys001": 501,
+ // Free (closed since boot): its node stays, root's; the app's next pty may take it.
+ "/dev/ttys003": 0,
+ // Closed while listed.
+ "/dev/ttys005": undefined,
+ "/dev/tty.usb": 501,
+ "/dev/cu.serial": 0,
+ }
+ const view = {
+ list: () => Promise.resolve([...Object.keys(nodes), "tty", "null"].map((p) => p.slice(5))),
+ owner: (p: string) => Promise.resolve(nodes[p]),
+ }
+ expect(await openTerminals(view)).toEqual(["/dev/ttys001"])
+ // Unreadable: refused (never a launch with them unlisted).
+ const eacces = () => Promise.reject(Object.assign(new Error("EACCES"), { code: "EACCES" }))
+ for (const broken of [
+ { ...view, list: eacces },
+ { ...view, owner: eacces },
+ ]) {
+ await expect(openTerminals(broken)).rejects.toThrow(ConfinementError)
+ }
+ // On this Mac: the test's own terminal is listed (its user's), a root-owned node never (one
+ // closed since: gone, nothing to say of it).
+ const user = await userTerminal()
+ try {
+ const listed = await openTerminals()
+ expect(listed).toContain(user.path)
+ for (const path of listed) {
+ const owner = (() => {
+ try {
+ return statSync(path).uid
+ } catch {
+ return undefined
+ }
+ })()
+ if (owner !== undefined) expect(owner).not.toBe(0)
+ }
+ } finally {
+ user.close()
+ }
+ })
+
+ it("refuses a terminals profile macOS won't load (never read as an app that quit)", async () => {
+ const profile = seatbeltProfile({
+ sandbox: realpathSync(tmpdir()),
+ readable: [],
+ private: [],
+ terminals: { existing: [] },
+ })
+ await expect(checkProfile(profile)).resolves.toBeUndefined()
+ await expect(checkProfile(profile + "(deny no-such-operation)\n")).rejects.toThrow(
+ /the confinement for its terminals didn't load/,
+ )
+ })
+
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-"))
diff --git a/packages/runtime/test/fixtures/electron-app/main.cjs b/packages/runtime/test/fixtures/electron-app/main.cjs
index a663db1..d4c15bd 100644
--- a/packages/runtime/test/fixtures/electron-app/main.cjs
+++ b/packages/runtime/test/fixtures/electron-app/main.cjs
@@ -114,6 +114,65 @@ if (process.argv.includes("touch-arg") && opened !== undefined) {
app.whenReady().then(() => writeFileSync(join(opened, "note.md"), "edited by the app"))
}
+// "terminals": the user's terminal (`tty=`, opened before the launch: never reached) and a pty of
+// its own (a shell run through /usr/bin/script: what a terminal app does), tried and written down.
+// Then the terminal the test opens after the launch (`later.txt` in its data folder), if given.
+if (process.argv.includes("terminals")) {
+ app.whenReady().then(async () => {
+ const fs = require("node:fs")
+ const { execFileSync } = require("node:child_process")
+ const tty = process.argv.find((a) => a.startsWith("tty="))?.slice(4) ?? "/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))
+ }
+ }
+ const openRead = (path) =>
+ fs.closeSync(fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK))
+ tryIt("tty-write-outside", () => fs.writeFileSync(tty, "INJECTED"))
+ tryIt("tty-read-outside", () => openRead(tty))
+ tryIt("tty-chmod-outside", () => fs.chmodSync(tty, 0o666))
+ // Its times (they change as the user types): never watched.
+ tryIt("tty-stat-outside", () => fs.statSync(tty).mtimeMs)
+ tryIt("pty-shell", () =>
+ execFileSync("/usr/bin/script", ["-q", "/dev/null", "/bin/echo", "pty-ok"], {
+ stdio: ["ignore", "pipe", "pipe"],
+ timeout: 5000,
+ })
+ .toString()
+ .includes("pty-ok"),
+ )
+ // What its shells' prompts show (a demo films them).
+ const shell = (program, script) =>
+ execFileSync(program, ["-i", "-c", script], {
+ stdio: ["ignore", "pipe", "ignore"],
+ timeout: 5000,
+ })
+ .toString()
+ .trim()
+ tryIt("zsh-prompt", () => shell("/bin/zsh", 'print -rP -- "$PS1"'))
+ tryIt("bash-prompt", () => shell("/bin/bash", 'echo "$PS1"'))
+ const data = app.getPath("userData")
+ fs.writeFileSync(join(data, "terminals.json"), JSON.stringify(out))
+ const asked = join(data, "later.txt")
+ for (let i = 0; i < 200 && !fs.existsSync(asked); i++)
+ await new Promise((r) => setTimeout(r, 50))
+ if (!fs.existsSync(asked)) return
+ const later = {}
+ const path = fs.readFileSync(asked, "utf8")
+ try {
+ openRead(path)
+ later["tty-read-later"] = "OK"
+ } catch (e) {
+ later["tty-read-later"] = "DENIED " + e.code
+ }
+ fs.writeFileSync(join(data, "later.json"), JSON.stringify(later))
+ })
+}
+
// "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")) {
diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts
index ca4c997..f39ae0b 100644
--- a/packages/schema/src/project.ts
+++ b/packages/schema/src/project.ts
@@ -92,6 +92,12 @@ export const ElectronApp = z.strictObject({
.optional(),
/** The sites a wrapper app shows as its own (Slack's app.slack.com): its pages, never another's. */
origins: z.array(HttpsOrigin).max(10).optional(),
+ /**
+ * It opens terminals of its own (a terminal app, an editor's terminal; decided 2026-10-11): its
+ * new ptys allowed, the user's open at launch never reached; a terminal the user opens while it
+ * runs is exposed to it (Seatbelt can't tell them apart). Approved on each Mac with the app.
+ */
+ terminals: z.boolean().optional(),
/** The size its takes are recorded at (emulated: the window itself is never moved). */
viewport: Viewport.prefault({ width: 1440, height: 900 }),
})
From 54fd5fa970e45f221f79cc7389e9d1b851dd5980 Mon Sep 17 00:00:00 2001
From: Le Ha Quang
Date: Sun, 11 Oct 2026 18:19:34 +0200
Subject: [PATCH 2/2] feat(scripts): add a desktop app to real-app runs
--desktop name=/path/App.app adds it through the Apps panel (picked, checked
confined, allowed), with --desktop-arg, --files and --desktop-terminals.
---
scripts/real-apps/drive.ts | 104 +++++++++++++++++++++++++++++++++++++
1 file changed, 104 insertions(+)
diff --git a/scripts/real-apps/drive.ts b/scripts/real-apps/drive.ts
index c5b3af7..a489b51 100644
--- a/scripts/real-apps/drive.ts
+++ b/scripts/real-apps/drive.ts
@@ -10,7 +10,9 @@
// Build the app first (pnpm --filter @kiframe/desktop build). Keys and secrets come from the root
// `.env` (never printed). Risky steps are approved: throwaway accounts only.
import {
+ appendFileSync,
copyFileSync,
+ cpSync,
existsSync,
mkdirSync,
mkdtempSync,
@@ -92,6 +94,15 @@ const { values } = parseArgs({
input: { type: "string", multiple: true },
// A file attached to the brief in the composer (its picker, as the user would).
attach: { type: "string", multiple: true },
+ // A desktop app of the project, `name=/Applications/App.app`: written to project.json (with
+ // --desktop-arg), then added through the Apps panel (picked, checked confined, approved).
+ desktop: { type: "string" },
+ // What the desktop app opens: a path in the project's files/ (`files/demo`).
+ "desktop-arg": { type: "string", multiple: true },
+ // A folder copied into the project's files/ (what a desktop app opens).
+ files: { type: "string" },
+ // The desktop app opens terminals of its own (its card's warning shown before it's allowed).
+ "desktop-terminals": { type: "boolean" },
},
})
function usage(why: string): never {
@@ -100,6 +111,13 @@ function usage(why: string): never {
)
process.exit(2)
}
+// The desktop app's options only with it (never silently ignored).
+if (
+ values.desktop === undefined &&
+ (values["desktop-arg"] !== undefined || values["desktop-terminals"] === true)
+) {
+ usage("--desktop-arg and --desktop-terminals need --desktop")
+}
// Any other app: its address and the request to send, both; never with --app (which one would run).
let custom: AppRun | undefined
if (values.url !== undefined) {
@@ -213,6 +231,78 @@ async function exportVideo(status: unknown, file: string): Promise {
log(done.status === 0 ? `exported: ${resolve(file)}` : `export failed (${done.status ?? "?"})`)
}
+/**
+ * A desktop app added as the user would: its entry (what it opens) in the project's file, the
+ * project opened again, then the Apps panel: picked (main's dialog answered with the bundle),
+ * checked confined, approved. Each stage logged, its time too.
+ */
+async function addDesktop(spec: string, dir: string): Promise {
+ const [name, bundle] = spec.split(/=(.*)/s)
+ if (name === undefined || bundle === undefined) throw new Error("--desktop name=/path/App.app")
+ // As the add card reads it (its name, its bundle id): what the panel shows.
+ const { inspectDesktopApp } = await import("@kiframe/runtime")
+ const { bundleId, name: shown } = await inspectDesktopApp(bundle)
+ const literal = shown.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+ // Closed from the project's menu, as the user would.
+ await page.getByRole("button", { name: run.name }).first().click()
+ await page.getByRole("menuitem", { name: "Close project" }).click()
+ await page.getByRole("button", { name: "Open a project…" }).waitFor({ timeout: 30_000 })
+ const file = join(dir, "project.json")
+ const project = JSON.parse(readFileSync(file, "utf8")) as { apps: Record }
+ if (name in project.apps)
+ throw new Error(`--desktop ${name}: the project names an app ${name} already`)
+ project.apps[name] = {
+ kind: "electron",
+ bundleId,
+ ...(values["desktop-arg"] !== undefined && { args: values["desktop-arg"] }),
+ ...(values["desktop-terminals"] === true && { terminals: true }),
+ }
+ writeFileSync(file, JSON.stringify(project, null, 2) + "\n")
+ await app.evaluate(({ dialog }, picked) => {
+ dialog.showOpenDialog = () => Promise.resolve({ canceled: false, filePaths: [picked] })
+ }, dir)
+ // Opened again from the menu, as the user would (the window's view of it made fresh).
+ await page.getByRole("button", { name: "Open a project…" }).click()
+ await page.getByRole("region", { name: "Scenes" }).waitFor({ timeout: 30_000 })
+ // The title bar's chip: the first app's host (the Apps panel).
+ await page.locator("header button.chip-button").click()
+ const panel = page.getByRole("dialog", { name: "Apps" })
+ await app.evaluate(({ dialog }, picked) => {
+ dialog.showOpenDialog = () => Promise.resolve({ canceled: false, filePaths: [picked] })
+ }, bundle)
+ await panel.getByRole("button", { name: "Add desktop app…" }).click()
+ const adding = panel.getByRole("region", { name: `Adding ${shown}` })
+ await adding.waitFor({ timeout: 30_000 })
+ log(`adding ${shown}: ${(await adding.textContent()) ?? ""}`)
+ if (values["desktop-terminals"] === true) {
+ const note = adding.getByRole("note")
+ log(`terminals warning: ${(await note.count()) > 0 ? await note.textContent() : "(none!)"}`)
+ }
+ const t0 = Date.now()
+ await adding.getByRole("button", { name: "Check" }).click()
+ const status = adding.getByRole("status")
+ await page.waitForFunction(
+ (el) => /runs confined|couldn|can't|isn't|refused|failed/i.test(el?.textContent ?? ""),
+ await status.elementHandle(),
+ { timeout: 120_000 },
+ )
+ const took = Math.round((Date.now() - t0) / 100) / 10
+ log(`checked in ${took} s: ${(await status.textContent()) ?? ""}`)
+ await shot("desktop-checked")
+ // Added, or allowed here when the project names it already (as here).
+ await adding
+ .getByRole("button", { name: new RegExp(`^(Add ${literal}|Allow ${literal} here)$`) })
+ .click()
+ const list = panel.getByRole("list", { name: "The project's apps" })
+ await page.waitForFunction(
+ (el) => /Ready on this Mac/.test(el?.textContent ?? ""),
+ await list.elementHandle(),
+ { timeout: 30_000 },
+ )
+ log(`app ${name} added: ${(await list.textContent()) ?? ""}`)
+ await page.keyboard.press("Escape")
+}
+
/** The complete takes the app's take store holds (its own reading: leftovers and bad ones skipped). */
function completeTakes(data: string): number {
const store = new TakeStore(data)
@@ -251,6 +341,11 @@ const env = Object.fromEntries(
env.KIFRAME_TEST_KEYCHAIN = "memory"
// The agent's every event, timed, next to the report (what it called, with what, how long).
env.KIFRAME_TRACE = join(out, "trace.jsonl")
+// A desktop app's sandboxes in the run's own work area (never the user's ~/.kiframe); its launch's
+// timings and failures' details on main's stderr, kept next to the report.
+env.KIFRAME_WORK_DIR = join(out, "work")
+env.KIFRAME_ELECTRON_TIMING = "1"
+env.KIFRAME_ELECTRON_DEBUG = "1"
const profile = mkdtempSync(join(tmpdir(), "kiframe-real-"))
const usageBefore = await credits()
const started = Date.now()
@@ -259,6 +354,7 @@ const app = await electron.launch({
cwd: join(root, "apps", "desktop"),
env,
})
+app.process().stderr?.on("data", (chunk: Buffer) => appendFileSync(join(out, "main.log"), chunk))
const page = await app.firstWindow()
await page.setViewportSize({ width: 1440, height: 900 }).catch(() => undefined)
const shot = (name: string) =>
@@ -300,6 +396,14 @@ try {
log(`app ${name} added`)
}
+ // A desktop app: in the project's file first (what it opens), as a shared project has it; then
+ // added through the Apps panel as the user would (its picker answered with the bundle).
+ if (values.files !== undefined) {
+ cpSync(values.files, join(dir, "files", basename(values.files)), { recursive: true })
+ log(`files/${basename(values.files)} added`)
+ }
+ if (values.desktop !== undefined) await addDesktop(values.desktop, dir)
+
for (const secret of run.secrets) {
const value = process.env[secret.env]
if (value === undefined || value === "") throw new Error(`${secret.env} isn't set (root .env)`)