Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 30 additions & 7 deletions apps/desktop/src/main/desktop-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,19 @@ const File = z.strictObject({
})
type File = z.infer<typeof File>

/** What a project opens with an app (its arguments and sites), as one digest. */
export function opensOf(app: Pick<ElectronApp, "args" | "origins">): 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<ElectronApp, "args" | "origins" | "terminals">): 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")
}

Expand Down Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<string> {
async add(owner: number, session: string, token: string, terminals = false): Promise<string> {
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")
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/desktop-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 33 additions & 2 deletions apps/desktop/src/renderer/src/components/apps-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import { useChat } from "../chat-store.ts"

type Card = Extract<DesktopPick, { card: unknown }>["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 ""
Expand All @@ -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<string | null>(null)
current.current = card?.token ?? null
Expand Down Expand Up @@ -114,6 +121,7 @@ export function AppsPanel({
setError(picked.refused)
return
}
setTerminals(false)
setCard(picked.card)
}

Expand All @@ -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)
Expand Down Expand Up @@ -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(", ")}
</p>
)}
{card.existing === undefined && (
<label className="app-card-option">
<input
type="checkbox"
checked={terminals}
disabled={adding}
onChange={(e) => setTerminals(e.target.checked)}
/>
It opens terminals (a terminal app, an editor’s terminal)
</label>
)}
{(card.existing === undefined ? terminals : card.opens?.terminals === true) && (
<p className="app-card-why app-card-warn" role="note">
{TERMINALS_WARNING}
</p>
)}
<p className="app-card-why">
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.
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/renderer/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
70 changes: 68 additions & 2 deletions apps/desktop/src/renderer/test/apps.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"/)
})
Expand Down Expand Up @@ -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,
Expand All @@ -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(<App />)
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(<App />)
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 () => {
Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/src/shared/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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). */
Expand Down
69 changes: 68 additions & 1 deletion apps/desktop/test/desktop-apps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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")
Expand All @@ -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() } })
Expand Down
Loading
Loading