From 7e9bfd6336c61447885324a3dc1cf796765893d0 Mon Sep 17 00:00:00 2001 From: David McKay Date: Sat, 12 Sep 2026 15:18:55 -0700 Subject: [PATCH 1/7] fix(desktop): restore standard quit shortcut and trim browser images --- .github/workflows/publish-release.yml | 18 +++++------ Dockerfile | 23 +++++++++----- agent-computer/Dockerfile | 23 ++++++++++---- desktop/src-tauri/src/main.rs | 15 +++++++-- docs/deployment.md | 12 +++++-- tests/compose.test.ts | 46 +++++++++++++++++++++++++++ 6 files changed, 108 insertions(+), 29 deletions(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index e8f0a3413..c56ddffcb 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -208,15 +208,15 @@ jobs: # the tag meant. # # zstd, not gzip. A pull is already a compressed transfer, so this is not about compressing - # something that was not; it is a better algorithm for the same job. Measured on - # `agent-computer`, the image that matters: 962 MB becomes 886 MB, and it inflates several - # times faster, which is worth more than the 8% on 2 GB of Chromium. `force-compression` - # is what reaches the layers that came from somebody else's registry, which is where almost - # all of the bytes are; without it only our own thin layers change and the saving rounds to - # nothing. The cost is that these images no longer share layers with a gzip pull of the same - # base, and that a client which cannot read zstd cannot read them, which is why this is here - # and not on the `openbot` image above: that one is pulled by servers with whatever they have, - # and these are pulled by an installer that ships Podman. + # something that was not; it is a better algorithm for the same job. `force-compression` + # reaches layers that came from somebody else's registry; without it only our own thin layers + # change and the saving rounds to nothing. The cost is that these images no longer share layers + # with a gzip pull of the same base, and that a client which cannot read zstd cannot read them, + # which is why this is here and not on the `openbot` image above: that one is pulled by servers + # with whatever they have, and these are pulled by an installer that ships Podman. After the + # Chromium-only `agent-computer` Dockerfile change, while keeping Node/npm/npx from the + # official Node 24.18.1 image, local zstd OCI layer descriptors measured 535.0 MiB on arm64 + # and 518.1 MiB on amd64. - id: push uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: diff --git a/Dockerfile b/Dockerfile index 674ce392d..863c736af 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,23 +14,30 @@ # no serverless container platform permits. Without it every Bot shares the browser below, exactly # as they do on a laptop with no supervisor configured. Per-Bot isolation is A6. # -# THE BASE IS PLAYWRIGHT'S, not Bun's, because Chromium and its system libraries have to stay -# matched and that image is the only place that is guaranteed. The tag must move with the -# `playwright` dependency in `agent-computer/package.json`. Bump both or neither. +# Chromium comes from Playwright's own installer, but the final image is not Playwright's all-browser +# image. Keep this version matched to `agent-computer/package.json`: bump both or neither. -FROM mcr.microsoft.com/playwright:v1.62.1-noble AS base +FROM node:24.18.1-bookworm-slim AS node-toolchain + +FROM ubuntu:24.04 AS base -# unzip is not in the Playwright image and bun's installer needs it. # Bun is pinned. The installer takes whatever is newest otherwise, so the runtime drifts from the # one the lockfile was resolved against and an image built next month is not the image built today. ARG BUN_VERSION=1.3.14 +ARG PLAYWRIGHT_VERSION=1.62.1 # Into /usr/local rather than /root/.bun, because the runtime stage runs as `pwuser` and cannot read # root's home. Set before the install, or the installer has already chosen the wrong directory. ENV BUN_INSTALL=/usr/local ENV PATH="/usr/local/bin:${PATH}" -RUN apt-get update && apt-get install -y --no-install-recommends unzip xz-utils \ - && rm -rf /var/lib/apt/lists/* \ - && curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" +ENV DEBIAN_FRONTEND=noninteractive +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +COPY --from=node-toolchain /usr/local /usr/local +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl unzip xz-utils \ + && curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" \ + && bunx --bun "playwright@${PLAYWRIGHT_VERSION}" install --with-deps chromium \ + && rm -rf /root/.cache /tmp/* /var/lib/apt/lists/* \ + && useradd --create-home --shell /bin/bash pwuser FROM base AS deps diff --git a/agent-computer/Dockerfile b/agent-computer/Dockerfile index fd15ec094..405b0ed89 100644 --- a/agent-computer/Dockerfile +++ b/agent-computer/Dockerfile @@ -1,14 +1,23 @@ -# The Bot's computer uses Playwright's image so Chromium and its system libraries stay matched. +# The Bot's computer installs only the Playwright browser it launches. # # The image tag and Playwright dependency must be pinned to the same exact version. Bump both or # neither. -FROM mcr.microsoft.com/playwright:v1.62.1-noble +FROM node:24.18.1-bookworm-slim AS node-toolchain -# unzip is not in the Playwright image and bun's installer needs it. -RUN apt-get update && apt-get install -y --no-install-recommends unzip \ - && rm -rf /var/lib/apt/lists/* \ - && curl -fsSL https://bun.sh/install | bash -ENV PATH="/root/.bun/bin:${PATH}" +FROM ubuntu:24.04 + +ARG BUN_VERSION=1.3.14 +ARG PLAYWRIGHT_VERSION=1.62.1 +ENV BUN_INSTALL=/usr/local +ENV PATH="/usr/local/bin:${PATH}" +ENV DEBIAN_FRONTEND=noninteractive +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +COPY --from=node-toolchain /usr/local /usr/local +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl unzip xz-utils \ + && curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" \ + && bunx --bun "playwright@${PLAYWRIGHT_VERSION}" install --with-deps chromium \ + && rm -rf /root/.cache /tmp/* /var/lib/apt/lists/* WORKDIR /app # The lockfile as well as the manifest: `--frozen-lockfile` with no lockfile present resolves afresh diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 5d20282ba..3b58bdfa9 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -16,6 +16,7 @@ use openbot_desktop_lib::{ }; const QUIT_CLEANUP_NOTICE_FILE: &str = ".openbot-quit-cleanup-notice"; +const QUIT_MENU_ACCELERATOR: &str = "CmdOrCtrl+KeyQ"; const QUIT_CLEANUP_NOTICE_LIMIT: usize = 16 * 1024; use serde::{Deserialize, Serialize}; use tauri::{Emitter, Manager}; @@ -2799,6 +2800,10 @@ fn chose(app: &tauri::AppHandle, item: &str) { } } +fn quit_menu_accelerator() -> Option<&'static str> { + Some(QUIT_MENU_ACCELERATOR) +} + fn main() { tauri::Builder::default() // A second launch is somebody looking for the window they already have, not a request for a @@ -2872,7 +2877,7 @@ fn main() { let open = MenuItem::with_id(app, "open", "Open OpenBot", true, None::<&str>)?; let stop = MenuItem::with_id(app, "stop", "Stop OpenBot", true, None::<&str>)?; - let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "Quit", true, quit_menu_accelerator())?; let menu = Menu::with_items(app, &[&open, &stop, &quit])?; TrayIconBuilder::with_id("openbot") @@ -2893,7 +2898,8 @@ fn main() { use tauri::menu::Submenu; let window_open = MenuItem::with_id(app, "open", "Open OpenBot", true, None::<&str>)?; let window_stop = MenuItem::with_id(app, "stop", "Stop OpenBot", true, None::<&str>)?; - let window_quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let window_quit = + MenuItem::with_id(app, "quit", "Quit", true, quit_menu_accelerator())?; // A submenu, because a top-level entry in a menu bar has to be one to open at all. let openbot = Submenu::with_items( app, @@ -3005,6 +3011,11 @@ mod tests { include!("stop_ipc_tests.rs"); + #[test] + fn quit_menu_uses_the_standard_quit_shortcut() { + assert_eq!(quit_menu_accelerator(), Some("CmdOrCtrl+KeyQ")); + } + #[test] fn responsive_quit_returns_while_cleanup_is_blocked_then_exits_in_order() { use std::sync::{mpsc, Arc}; diff --git a/docs/deployment.md b/docs/deployment.md index 204e2fce1..c8e64e239 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -208,9 +208,15 @@ which makes them the shortest path from nothing to a running deployment. ## Known costs -**The image is 1.4 GB**, and 595 MB of that is Firefox and WebKit, which the Playwright base ships -alongside the Chromium we use and nothing here ever launches. Deleting them afterwards does not help, because the bytes still ship in the layer -below. Building Chromium-only onto a slim base would cut this substantially and is not done yet. +**The browser images carry only the Chromium browser family.** The all-in-one Dockerfile and the +published `agent-computer` Dockerfile both build from Ubuntu and run Playwright's pinned +`install --with-deps chromium` path, so Firefox and WebKit are never introduced into the final image +layers. Keep that Playwright version matched to `agent-computer/package.json`; changing one without +the other can make the browser protocol and executable revision diverge. The images keep the +baseline Node command-line tools (`node`, `npm`, and `npx`) from the official Node 24.18.1 image. +Measured as local zstd OCI layer descriptors against the previous Playwright-base `agent-computer` +image, the compressed desktop image fell from 884.2 MiB to 535.0 MiB on arm64 and from 894.6 MiB to +518.1 MiB on amd64. **A strict content-security-policy needs a hash or a nonce.** `app/index.html` runs a small inline script that decides the theme before the first paint. Nothing in this repo sends a CSP header, so it diff --git a/tests/compose.test.ts b/tests/compose.test.ts index 6dfe2b25c..270f0475c 100644 --- a/tests/compose.test.ts +++ b/tests/compose.test.ts @@ -17,6 +17,17 @@ function composeFile() { ); } +function rootDockerfile() { + return readFileSync(join(import.meta.dir, "..", "Dockerfile"), "utf8"); +} + +function agentComputerDockerfile() { + return readFileSync( + join(import.meta.dir, "..", "agent-computer", "Dockerfile"), + "utf8", + ); +} + function runLangGraphAguiModelProbe( openaiBaseUrl: string | undefined, options: { @@ -418,6 +429,41 @@ test("runs migrations after PostgreSQL becomes healthy", () => { expect(compose).toContain('"drizzle-kit", "migrate"'); }); +test("builds the deployment image with Playwright's Chromium payload only", () => { + for (const dockerfile of [rootDockerfile(), agentComputerDockerfile()]) { + expect(dockerfile).toContain( + "FROM node:24.18.1-bookworm-slim AS node-toolchain", + ); + expect(dockerfile).toContain("FROM ubuntu:24.04"); + expect(dockerfile).not.toContain("mcr.microsoft.com/playwright"); + expect(dockerfile).toContain("ARG PLAYWRIGHT_VERSION=1.62.1"); + expect(dockerfile).toContain("ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright"); + expect(dockerfile).toContain( + "COPY --from=node-toolchain /usr/local /usr/local", + ); + expect(dockerfile).toContain( + 'bunx --bun "playwright@' + + "$" + + "{PLAYWRIGHT_VERSION}" + + '" install --with-deps chromium', + ); + expect(dockerfile).not.toMatch(/\bnodejs\b|\bnpm\b/); + expect(dockerfile).not.toMatch( + /\binstall(?:\s+--with-deps)?\s+(firefox|webkit)\b/, + ); + } + + const compose = composeFile(); + expect(compose).toContain( + [ + "agent-computer:", + " build:", + " context: .", + " dockerfile: agent-computer/Dockerfile", + ].join("\n"), + ); +}); + /** * Per-Bot egress reaches the processes that read it. * From 908c47eae262f3f2e54e705a9d175ec8c3b19f65 Mon Sep 17 00:00:00 2001 From: David McKay Date: Sat, 12 Sep 2026 17:47:20 -0700 Subject: [PATCH 2/7] feat(desktop): add approved folder tools in isolated containers --- app/src/lib/computers/host-access.ts | 103 + app/src/routes/_authed/admin/computers.tsx | 274 ++- app/tests/host-access.test.ts | 101 + desktop/src-tauri/Cargo.lock | 68 + desktop/src-tauri/Cargo.toml | 1 + .../src-tauri/gen/schemas/acl-manifests.json | 2 +- .../src-tauri/gen/schemas/desktop-schema.json | 66 + .../src-tauri/gen/schemas/macOS-schema.json | 66 + desktop/src-tauri/src/desktop_host_access.rs | 126 ++ desktop/src-tauri/src/host_access.rs | 1763 +++++++++++++++++ desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/main.rs | 60 +- desktop/src-tauri/src/stack.rs | 33 +- docs/deployment.md | 16 + server/src/agents/callback-token.ts | 9 +- server/src/app.ts | 53 +- server/src/host-access/broker.ts | 349 ++++ server/src/host-access/routes.ts | 165 ++ server/src/host-access/schema.ts | 96 + server/src/host-access/tools.ts | 255 +++ server/src/index.ts | 46 +- server/tests/agent-callback-token.test.ts | 2 + server/tests/host-access-broker.test.ts | 361 ++++ .../tests/host-access-callback-route.test.ts | 117 ++ server/tests/host-access-routes.test.ts | 116 ++ server/tests/host-access-tools.test.ts | 236 +++ 26 files changed, 4470 insertions(+), 15 deletions(-) create mode 100644 app/src/lib/computers/host-access.ts create mode 100644 app/tests/host-access.test.ts create mode 100644 desktop/src-tauri/src/desktop_host_access.rs create mode 100644 desktop/src-tauri/src/host_access.rs create mode 100644 server/src/host-access/broker.ts create mode 100644 server/src/host-access/routes.ts create mode 100644 server/src/host-access/schema.ts create mode 100644 server/src/host-access/tools.ts create mode 100644 server/tests/host-access-broker.test.ts create mode 100644 server/tests/host-access-callback-route.test.ts create mode 100644 server/tests/host-access-routes.test.ts create mode 100644 server/tests/host-access-tools.test.ts diff --git a/app/src/lib/computers/host-access.ts b/app/src/lib/computers/host-access.ts new file mode 100644 index 000000000..b40344d2b --- /dev/null +++ b/app/src/lib/computers/host-access.ts @@ -0,0 +1,103 @@ +import { + mutationOptions, + queryOptions, + type QueryClient, +} from "@tanstack/react-query"; +import { client } from "@/lib/client"; + +export type HostFolderGrant = { + id: string; + botId: string; + actorId: string; + displayName: string; + revoked?: boolean; + ownerName?: string; + ownerEmail?: string; +}; + +export type HostAccessPendingOperation = { + operationId: string; + kind?: + | "choose_folder" + | "list_files" + | "read_file" + | "write_file" + | "run_command" + | "cancel" + | "stop"; + botId: string; + actorId?: string; + displayName?: string; + writable?: boolean; + ownerName?: string; + ownerEmail?: string; + status?: "pending" | "approved" | "refused"; +}; + +export type HostAccessStatus = { + connected: boolean; + grants: HostFolderGrant[]; + pending: HostAccessPendingOperation[]; +}; + +export const hostAccessKeys = { + all: ["host-access"] as const, + status: () => ["host-access", "status"] as const, +}; + +function invalidateHostAccess(queryClient: QueryClient) { + return queryClient.invalidateQueries({ queryKey: hostAccessKeys.all }); +} + +export function hostAccessQueryOptions() { + return queryOptions({ + queryKey: hostAccessKeys.status(), + refetchInterval: 2_000, + queryFn: (): Promise => + client("/api/host-access", { + fallback: "Folder access could not be loaded.", + }).then((response) => response.json()), + }); +} + +export function requestHostFolderGrantMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: (variables: { botId: string }): Promise => + client("/api/host-access/grants", { + method: "POST", + body: { botId: variables.botId }, + fallback: "The desktop app could not open the folder chooser.", + }).then((response) => response.json()), + onSuccess: () => invalidateHostAccess(queryClient), + onError: () => invalidateHostAccess(queryClient), + }); +} + +export function revokeHostFolderGrantMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (variables: { grantId: string }) => { + await client( + `/api/host-access/grants/${encodeURIComponent(variables.grantId)}`, + { + method: "DELETE", + fallback: "The host folder grant could not be revoked.", + }, + ); + }, + onSuccess: () => invalidateHostAccess(queryClient), + }); +} + +export function stopHostAccessMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async () => { + await client("/api/host-access/stop", { + method: "POST", + fallback: "Host folder access could not be stopped.", + }); + }, + onSuccess: () => invalidateHostAccess(queryClient), + }); +} diff --git a/app/src/routes/_authed/admin/computers.tsx b/app/src/routes/_authed/admin/computers.tsx index 9d41bb715..56e6db008 100644 --- a/app/src/routes/_authed/admin/computers.tsx +++ b/app/src/routes/_authed/admin/computers.tsx @@ -1,4 +1,4 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute, Link } from "@tanstack/react-router"; import { useState } from "react"; import { @@ -26,9 +26,17 @@ import { } from "@/components/ui/item"; import { Separator } from "@/components/ui/separator"; import { useBotNames } from "@/lib/agents/bot-names"; +import { agentListQueryOptions } from "@/lib/agents/queries"; import { setComputerStateMutationOptions } from "@/lib/computers/mutations"; +import { + hostAccessQueryOptions, + requestHostFolderGrantMutationOptions, + revokeHostFolderGrantMutationOptions, + stopHostAccessMutationOptions, + type HostFolderGrant, + type HostAccessPendingOperation, +} from "@/lib/computers/host-access"; import { computerFleetQueryOptions } from "@/lib/computers/queries"; -import { queryClient } from "@/query-client"; export const Route = createFileRoute("/_authed/admin/computers")({ component: ComputersPage, @@ -39,10 +47,22 @@ function ComputersPage() { const [busy, setBusy] = useState(null); /** Reset deletes the browser profile, so it requires confirmation. */ const [confirming, setConfirming] = useState(null); + const queryClient = useQueryClient(); const nameFor = useBotNames(); const fleet = useQuery(computerFleetQueryOptions()); + const hostAccess = useQuery(hostAccessQueryOptions()); + const agents = useQuery(agentListQueryOptions()); const setState = useMutation(setComputerStateMutationOptions(queryClient)); + const requestGrant = useMutation( + requestHostFolderGrantMutationOptions(queryClient), + ); + const revokeGrant = useMutation( + revokeHostFolderGrantMutationOptions(queryClient), + ); + const stopHostAccess = useMutation( + stopHostAccessMutationOptions(queryClient), + ); const computers = fleet.data?.computers ?? null; const isolation = fleet.data?.isolation ?? null; @@ -55,6 +75,15 @@ function ComputersPage() { : setState.error ? setState.error.message : null; + const hostProblem = hostAccess.error + ? hostAccess.error.message + : requestGrant.error + ? requestGrant.error.message + : revokeGrant.error + ? revokeGrant.error.message + : stopHostAccess.error + ? stopHostAccess.error.message + : null; const run = (botId: string, action: "stop" | "reset") => { setBusy(botId); @@ -92,6 +121,30 @@ function ComputersPage() {

) : null} + agent.id)} + loading={hostAccess.isPending || agents.isPending} + nameFor={nameFor} + onGrant={(botId) => requestGrant.mutate({ botId })} + onRevoke={(grantId) => revokeGrant.mutate({ grantId })} + onStop={() => stopHostAccess.mutate()} + pending={hostAccess.data?.pending ?? []} + problem={hostProblem} + requestingBotId={ + requestGrant.isPending + ? (requestGrant.variables?.botId ?? null) + : null + } + revokingGrantId={ + revokeGrant.isPending + ? (revokeGrant.variables?.grantId ?? null) + : null + } + stopping={stopHostAccess.isPending} + /> + {computers === null && problem ? ( The list could not be loaded. @@ -203,3 +256,220 @@ function ComputersPage() { ); } + +type HostFoldersSectionProps = { + botIds: string[]; + connected: boolean; + grants: HostFolderGrant[]; + loading: boolean; + nameFor: (botId: string) => string; + onGrant: (botId: string) => void; + onRevoke: (grantId: string) => void; + onStop: () => void; + pending: HostAccessPendingOperation[]; + problem: string | null; + requestingBotId: string | null; + revokingGrantId: string | null; + stopping: boolean; +}; + +function HostFoldersSection({ + botIds: knownBotIds, + connected, + grants, + loading, + nameFor, + onGrant, + onRevoke, + onStop, + pending, + problem, + requestingBotId, + revokingGrantId, + stopping, +}: HostFoldersSectionProps) { + const botIds = Array.from( + new Set([ + ...knownBotIds, + ...grants.map((grant) => grant.botId), + ...pending.map((one) => one.botId), + ]), + ).sort((left, right) => nameFor(left).localeCompare(nameFor(right))); + + return ( + + {problem ? ( +

+ {problem} +

+ ) : null} + + {!connected && !loading ? ( +

+ The desktop app is offline. Folders can only be approved from the + desktop app, and command access stays unavailable while it is offline. +

+ ) : null} + + {loading ? null : botIds.length === 0 ? ( + + No Bots yet. Create a Bot before choosing folders. + + ) : ( + + {botIds.map((botId, index) => ( + + + + {nameFor(botId)} + + {summaryFor(botId, grants, pending)} + + + + + + + + {index !== botIds.length - 1 && } + + ))} + + )} + +
+

+ Grants are read-only by default. Writes are requested from the native + desktop prompt when a Bot needs them. +

+
+ + + View audit + +
+
+
+ ); +} + +function FolderGrantList({ + botId, + grants, + onRevoke, + pending, + revokingGrantId, +}: { + botId: string; + grants: HostFolderGrant[]; + onRevoke: (grantId: string) => void; + pending: HostAccessPendingOperation[]; + revokingGrantId: string | null; +}) { + const botGrants = grants.filter( + (grant) => grant.botId === botId && !grant.revoked, + ); + const botPending = pending.filter((request) => request.botId === botId); + + if (botGrants.length === 0 && botPending.length === 0) return null; + + return ( +
+ {botGrants.map((grant) => ( +
+
+

{grant.displayName}

+

+ {ownerLabel(grant)} · Read-only +

+
+ +
+ ))} + {botPending.map((request) => ( +
+ {pendingLabel(request)} from {ownerLabel(request)} + {request.displayName ? ` for ${request.displayName}` : ""}. +
+ ))} +
+ ); +} + +function summaryFor( + botId: string, + grants: HostFolderGrant[], + pending: HostAccessPendingOperation[], +) { + const active = grants.filter( + (grant) => grant.botId === botId && !grant.revoked, + ).length; + const waiting = pending.filter((request) => request.botId === botId).length; + const parts = []; + if (active > 0) + parts.push(`${active} read-only ${active === 1 ? "folder" : "folders"}`); + if (waiting > 0) + parts.push(`${waiting} pending ${waiting === 1 ? "request" : "requests"}`); + return parts.length > 0 ? parts.join(" · ") : "No folders approved."; +} + +function ownerLabel(grant: { ownerName?: string; ownerEmail?: string }) { + return grant.ownerName ?? grant.ownerEmail ?? "the desktop owner"; +} + +function pendingLabel(request: HostAccessPendingOperation) { + switch (request.kind) { + case "write_file": + return "File change awaiting approval or completion"; + case "run_command": + return "Command awaiting approval or completion"; + case "list_files": + return "Listing files"; + case "read_file": + return "Reading a file"; + case "cancel": + case "stop": + return "Stopping folder work"; + default: + return "Folder access awaiting approval"; + } +} diff --git a/app/tests/host-access.test.ts b/app/tests/host-access.test.ts new file mode 100644 index 000000000..52c085cbe --- /dev/null +++ b/app/tests/host-access.test.ts @@ -0,0 +1,101 @@ +import { afterEach, expect, test } from "bun:test"; +import type { QueryClient } from "@tanstack/react-query"; +import { + hostAccessKeys, + hostAccessQueryOptions, + requestHostFolderGrantMutationOptions, + revokeHostFolderGrantMutationOptions, + stopHostAccessMutationOptions, +} from "../src/lib/computers/host-access"; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +type SeenRequest = { url: string; init: RequestInit | undefined }; + +function capturingFetch(body: unknown = {}) { + const seen: SeenRequest[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + seen.push({ url: String(url), init }); + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + return seen; +} + +function invalidationRecorder() { + const invalidated: unknown[] = []; + const queryClient = { + invalidateQueries: async (filter: unknown) => { + invalidated.push(filter); + }, + } as unknown as QueryClient; + return { invalidated, queryClient }; +} + +function mutationContext(queryClient: QueryClient) { + return { client: queryClient, meta: undefined }; +} + +test("host access status has its own stable query key", async () => { + const seen = capturingFetch({ connected: false, grants: [], pending: [] }); + const options = hostAccessQueryOptions(); + + expect([...options.queryKey]).toEqual(["host-access", "status"]); + const queryFn = options.queryFn; + if (!queryFn) throw new Error("host access query is missing its fetcher"); + await expect(queryFn({} as never)).resolves.toEqual({ + connected: false, + grants: [], + pending: [], + }); + expect(seen[0]?.url).toBe("/api/host-access"); +}); + +test("requesting a host folder sends only the Bot id", async () => { + const seen = capturingFetch({ connected: true, grants: [], pending: [] }); + const { invalidated, queryClient } = invalidationRecorder(); + const options = requestHostFolderGrantMutationOptions(queryClient); + + await options.mutationFn?.( + { botId: "research" }, + mutationContext(queryClient), + ); + await options.onSuccess?.( + { connected: true, grants: [], pending: [] }, + { botId: "research" }, + undefined as never, + undefined as never, + ); + + expect(seen[0]?.url).toBe("/api/host-access/grants"); + expect(seen[0]?.init?.method).toBe("POST"); + expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({ + botId: "research", + }); + expect(invalidated).toEqual([{ queryKey: hostAccessKeys.all }]); +}); + +test("revoking and stopping host access use their dedicated endpoints", async () => { + const seen = capturingFetch(); + const { queryClient } = invalidationRecorder(); + + const revoke = revokeHostFolderGrantMutationOptions(queryClient); + await revoke.mutationFn?.( + { grantId: "grant/with/slash" }, + mutationContext(queryClient), + ); + + const stop = stopHostAccessMutationOptions(queryClient); + await stop.mutationFn?.(undefined, mutationContext(queryClient)); + + expect(seen.map((request) => [request.url, request.init?.method])).toEqual([ + ["/api/host-access/grants/grant%2Fwith%2Fslash", "DELETE"], + ["/api/host-access/stop", "POST"], + ]); +}); diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 5cb5a307f..db296f65f 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2396,6 +2396,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2501,6 +2502,7 @@ dependencies = [ "tar", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-opener", "tauri-plugin-shell", "tauri-plugin-single-instance", @@ -3139,6 +3141,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -3977,6 +4003,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61854a36651aa48381e5e209f69a01273b77f3f9f91f0c430b1b98d33bd47229" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.20", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.20", + "toml 1.1.5+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.5" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index e2569db8c..ddf423cb8 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -31,6 +31,7 @@ tar = "0.4" tauri-plugin-single-instance = "2.0.0-rc.5" portable-pty = "0.9.0" tauri-plugin-opener = "2.5.5" +tauri-plugin-dialog = "2" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/desktop/src-tauri/gen/schemas/acl-manifests.json b/desktop/src-tauri/gen/schemas/acl-manifests.json index f688caf03..0c6142857 100644 --- a/desktop/src-tauri/gen/schemas/acl-manifests.json +++ b/desktop/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}}} \ No newline at end of file +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}}} \ No newline at end of file diff --git a/desktop/src-tauri/gen/schemas/desktop-schema.json b/desktop/src-tauri/gen/schemas/desktop-schema.json index 847cd9c71..214b840f5 100644 --- a/desktop/src-tauri/gen/schemas/desktop-schema.json +++ b/desktop/src-tauri/gen/schemas/desktop-schema.json @@ -2570,6 +2570,72 @@ "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." }, + { + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "dialog:default", + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + }, + { + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." + }, { "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "type": "string", diff --git a/desktop/src-tauri/gen/schemas/macOS-schema.json b/desktop/src-tauri/gen/schemas/macOS-schema.json index 847cd9c71..214b840f5 100644 --- a/desktop/src-tauri/gen/schemas/macOS-schema.json +++ b/desktop/src-tauri/gen/schemas/macOS-schema.json @@ -2570,6 +2570,72 @@ "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." }, + { + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "dialog:default", + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + }, + { + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." + }, { "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "type": "string", diff --git a/desktop/src-tauri/src/desktop_host_access.rs b/desktop/src-tauri/src/desktop_host_access.rs new file mode 100644 index 000000000..81ed6bcd4 --- /dev/null +++ b/desktop/src-tauri/src/desktop_host_access.rs @@ -0,0 +1,126 @@ +//! Owner approval is collected by native dialogs, never by an app/tool-provided answer. +use openbot_desktop_lib::host_access::{ + ApprovedFolder, ChooseFolderPrompt, CommandPrompt, HostAccessError, HostAccessResult, + HostApprovalUi, WritePrompt, +}; +use tauri::Manager; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; + +pub struct NativeApproval(pub tauri::AppHandle); + +fn refused(message: impl Into) -> HostAccessError { + HostAccessError::Denied(message.into()) +} + +// A native message dialog is deliberately small. Refuse content that cannot be reviewed here; +// truncating it would authorize bytes the person never saw. +fn reviewable(value: &str) -> HostAccessResult<()> { + if value.chars().count() > 2_000 || value.lines().count() > 24 || value.contains('\0') { + return Err(refused("This operation is too large for native approval. Ask the Bot for a smaller edit or command.")); + } + Ok(()) +} + +fn bot_label(name: Option<&str>, id: &str) -> String { + name.unwrap_or(id) + .chars() + .map(|ch| if ch.is_control() { ' ' } else { ch }) + .take(120) + .collect() +} + +impl NativeApproval { + fn confirm(&self, title: &str, message: String) -> HostAccessResult<()> { + let window = self + .0 + .get_webview_window("main") + .ok_or_else(|| refused("The local OpenBot window is closed."))?; + window.show().map_err(|error| refused(error.to_string()))?; + window + .set_focus() + .map_err(|error| refused(error.to_string()))?; + let allowed = self + .0 + .dialog() + .message(message) + .title(title) + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom( + "Allow once".into(), + "Deny".into(), + )) + .parent(&window) + .blocking_show(); + if allowed { + Ok(()) + } else { + Err(refused("The local owner denied this operation.")) + } + } +} + +impl HostApprovalUi for NativeApproval { + fn choose_folder(&self, request: &ChooseFolderPrompt) -> HostAccessResult { + let bot = bot_label(request.bot_name.as_deref(), &request.bot_id); + let window = self + .0 + .get_webview_window("main") + .ok_or_else(|| refused("The local OpenBot window is closed."))?; + window.show().map_err(|error| refused(error.to_string()))?; + window + .set_focus() + .map_err(|error| refused(error.to_string()))?; + let picked = self + .0 + .dialog() + .file() + .set_title(format!("Choose a folder for {bot} to read")) + .set_parent(&window) + .blocking_pick_folder() + .ok_or_else(|| refused("No folder was approved."))?; + let root = picked + .into_path() + .map_err(|error| refused(error.to_string()))?; + self.confirm("Allow folder access?", format!( + "Bot: {bot}\nRequested by: {}\n\nFolder: {}\n\nAllow this Bot to read this folder for this OpenBot session? Each edit and command asks separately. You can revoke access in Computers.", + request.actor_id, root.display() + ))?; + Ok(ApprovedFolder { root }) + } + + fn confirm_write(&self, request: &WritePrompt) -> HostAccessResult<()> { + reviewable(&request.content)?; + let bot = bot_label(request.bot_name.as_deref(), &request.bot_id); + self.confirm("Allow this file change?", format!( + "Bot: {bot}\nFolder: {}\nFile: {}\n\nNew content:\n{}\n\nAllow this exact change once? OpenBot keeps the previous contents when replacing a file.", + request.root.display(), request.relative_path, request.content + )) + } + + fn confirm_command(&self, request: &CommandPrompt) -> HostAccessResult<()> { + reviewable(&request.command)?; + let bot = bot_label(request.bot_name.as_deref(), &request.bot_id); + let access = if request.writable { + "This command may edit or delete files in the approved folder. Commands cannot be undone automatically." + } else { + "The approved folder stays read-only. The command can write only to its temporary workspace." + }; + self.confirm("Allow this command?", format!( + "Bot: {bot}\nFolder: {}\nWorking folder: {}\n\nCommand:\n{}\n\n{access}\nNetwork access is disabled. Allow this command once?", + request.root.display(), request.working_directory.as_deref().unwrap_or("/workspace"), request.command + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn approval_never_silently_truncates_requested_changes() { + assert!(reviewable("hello\nworld").is_ok()); + assert!(reviewable(&"x".repeat(2_001)).is_err()); + assert!(reviewable(&"x\n".repeat(25)).is_err()); + assert!(reviewable("hello\0hidden").is_err()); + } +} diff --git a/desktop/src-tauri/src/host_access.rs b/desktop/src-tauri/src/host_access.rs new file mode 100644 index 000000000..40e75231c --- /dev/null +++ b/desktop/src-tauri/src/host_access.rs @@ -0,0 +1,1763 @@ +//! Native host-access broker. +//! +//! The server may decide which Bot is offered host tools, but the desktop process owns every local +//! path decision and every execution boundary. There is deliberately no unsandboxed fallback: a +//! missing runtime, image, approval, grant, path check, or stop cleanup is an error. + +use std::collections::{HashMap, HashSet}; +use std::ffi::OsStr; +use std::fs; +use std::io::{Read, Write}; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; + +use crate::engine::{Address, Engine}; +use crate::quiet::said as command_said; + +#[cfg(test)] +const DEFAULT_IMAGE: &str = "openbot-agent-computer:s10-chromium-arm64"; +const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(1); +const DEFAULT_OPERATION_TIMEOUT: Duration = Duration::from_secs(30); +const DEFAULT_OUTPUT_LIMIT: usize = 64 * 1024; +const DEFAULT_MEMORY: &str = "512m"; +const DEFAULT_CPUS: &str = "1"; +const DEFAULT_PIDS_LIMIT: &str = "128"; +const MAX_ACTIVE_OPERATIONS: usize = 4; +const MAX_OPERATION_AGE_MS: u128 = 120_000; +const APPROVED_MOUNT: &str = "/approved"; +const WORKSPACE_MOUNT: &str = "/workspace"; + +#[derive(Clone, Debug)] +pub struct HostAccessConfig { + pub base_url: String, + pub token: String, + pub engine: Address, + pub image: String, + pub forbidden_paths: Vec, + pub poll_interval: Duration, + pub operation_timeout: Duration, + pub output_limit: usize, + pub memory: String, + pub cpus: String, + pub pids_limit: String, +} + +impl HostAccessConfig { + pub fn new( + base_url: impl Into, + token: impl Into, + engine: Address, + image: impl Into, + forbidden_paths: Vec, + ) -> Self { + Self { + base_url: base_url.into(), + token: token.into(), + engine, + image: image.into(), + forbidden_paths, + poll_interval: DEFAULT_POLL_INTERVAL, + operation_timeout: DEFAULT_OPERATION_TIMEOUT, + output_limit: DEFAULT_OUTPUT_LIMIT, + memory: DEFAULT_MEMORY.into(), + cpus: DEFAULT_CPUS.into(), + pids_limit: DEFAULT_PIDS_LIMIT.into(), + } + } +} + +#[derive(Clone)] +pub struct HostAccess { + inner: Arc, +} + +struct Inner { + config: HostAccessConfig, + approval: Arc, + instance_label: String, + state: Mutex, + effect_lock: Mutex<()>, +} + +struct State { + stopped: bool, + thread: Option>, + grants: HashMap, + running: HashMap, + completed: HashSet, + canceled_operations: HashSet, + active_by_operation: HashSet, + active_by_bot: HashSet, +} + +#[derive(Clone, Debug)] +struct LocalGrant { + id: String, + bot_id: String, + actor_id: String, + bot_name: Option, + root: PathBuf, + revoked: bool, +} + +#[derive(Clone, Debug)] +struct RunningContainer { + name: String, + operation_id: String, + actor_id: String, + grant_id: Option, +} + +#[derive(Debug)] +pub enum HostAccessError { + InvalidConfig(String), + Runtime(String), + Http(String), + Denied(String), + Io(String), +} + +impl std::fmt::Display for HostAccessError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HostAccessError::InvalidConfig(message) + | HostAccessError::Runtime(message) + | HostAccessError::Http(message) + | HostAccessError::Denied(message) + | HostAccessError::Io(message) => f.write_str(message), + } + } +} + +impl std::error::Error for HostAccessError {} + +pub type HostAccessResult = Result; + +pub trait HostApprovalUi: Send + Sync + 'static { + fn choose_folder(&self, request: &ChooseFolderPrompt) -> HostAccessResult; + fn confirm_write(&self, request: &WritePrompt) -> HostAccessResult<()>; + fn confirm_command(&self, request: &CommandPrompt) -> HostAccessResult<()>; +} + +pub struct DenyAllApprovalUi; + +impl HostApprovalUi for DenyAllApprovalUi { + fn choose_folder(&self, _: &ChooseFolderPrompt) -> HostAccessResult { + Err(HostAccessError::Denied( + "Native folder approval is not wired.".into(), + )) + } + + fn confirm_write(&self, _: &WritePrompt) -> HostAccessResult<()> { + Err(HostAccessError::Denied( + "Native write approval is not wired.".into(), + )) + } + + fn confirm_command(&self, _: &CommandPrompt) -> HostAccessResult<()> { + Err(HostAccessError::Denied( + "Native command approval is not wired.".into(), + )) + } +} + +#[derive(Clone, Debug)] +pub struct ChooseFolderPrompt { + pub operation_id: String, + pub bot_id: String, + pub actor_id: String, + pub bot_name: Option, + pub writable_requested: bool, +} + +#[derive(Clone, Debug)] +pub struct ApprovedFolder { + pub root: PathBuf, +} + +#[derive(Clone, Debug)] +pub struct WritePrompt { + pub operation_id: String, + pub bot_id: String, + pub bot_name: Option, + pub root: PathBuf, + pub relative_path: String, + pub content: String, + pub writable: bool, +} + +#[derive(Clone, Debug)] +pub struct CommandPrompt { + pub operation_id: String, + pub bot_id: String, + pub bot_name: Option, + pub root: PathBuf, + pub working_directory: Option, + pub command: String, + pub writable: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DesktopOperation { + operation_id: String, + kind: HostOperationKind, + bot_id: String, + actor_id: String, + bot_name: Option, + grant_id: Option, + target_operation_id: Option, + relative_path: Option, + content: Option, + command: Option, + writable: Option, + expires_at: Option, + #[serde(skip, default = "now_millis")] + received_at_ms: u128, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum HostOperationKind { + ChooseFolder, + ListFiles, + ReadFile, + WriteFile, + RunCommand, + Cancel, + Stop, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DesktopPollResponse { + operations: Vec, + #[serde(rename = "leaseMs")] + _lease_ms: u64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DesktopResult { + operation_id: String, + ok: bool, + result: Option, + grant: Option, + error: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DesktopGrantResult { + grant_id: String, + display_name: String, + writable: bool, +} + +struct OperationSuccess { + output: Option, + grant: Option, +} + +struct OperationGrant { + grant_id: String, + display_name: String, + writable: bool, +} + +impl OperationSuccess { + fn output(output: String) -> Self { + Self { + output: Some(output), + grant: None, + } + } + + fn empty() -> Self { + Self { + output: None, + grant: None, + } + } +} + +impl HostAccess { + pub fn start( + base_url: impl Into, + token: impl Into, + engine: Address, + image: impl Into, + forbidden_paths: Vec, + ) -> HostAccessResult { + Self::start_with_approval( + HostAccessConfig::new(base_url, token, engine, image, forbidden_paths), + Arc::new(DenyAllApprovalUi), + ) + } + + pub fn start_with_approval( + config: HostAccessConfig, + approval: Arc, + ) -> HostAccessResult { + validate_config(&config)?; + let inner = Arc::new(Inner { + config, + approval, + instance_label: fresh_id("host-instance"), + effect_lock: Mutex::new(()), + state: Mutex::new(State { + stopped: false, + thread: None, + grants: HashMap::new(), + running: HashMap::new(), + completed: HashSet::new(), + canceled_operations: HashSet::new(), + active_by_operation: HashSet::new(), + active_by_bot: HashSet::new(), + }), + }); + let thread_inner = inner.clone(); + let handle = thread::Builder::new() + .name("openbot-host-access".into()) + .spawn(move || broker_loop(thread_inner)) + .map_err(|error| { + HostAccessError::Runtime(format!("Could not start host access broker: {error}")) + })?; + inner.state.lock().expect("host state poisoned").thread = Some(handle); + Ok(Self { inner }) + } + + pub fn stop(&self) -> HostAccessResult<()> { + let _effect = self + .inner + .effect_lock + .lock() + .expect("host effect lock poisoned"); + let running = { + let mut state = self.inner.state.lock().expect("host state poisoned"); + state.stopped = true; + for grant in state.grants.values_mut() { + grant.revoked = true; + } + state.running.values().cloned().collect::>() + }; + let mut failed = Vec::new(); + for container in &running { + if let Err(error) = self.inner.remove_container(&container.name) { + failed.push(format!("{}: {error}", container.name)); + } + } + if failed.is_empty() { + self.inner + .state + .lock() + .expect("host state poisoned") + .running + .clear(); + self.inner.verify_no_owned_containers() + } else { + Err(HostAccessError::Runtime(format!( + "Could not remove host containers: {}", + failed.join(", ") + ))) + } + } +} + +impl Drop for HostAccess { + fn drop(&mut self) { + let _ = self.stop(); + } +} + +fn broker_loop(inner: Arc) { + let client = match Client::builder().timeout(Duration::from_secs(15)).build() { + Ok(client) => client, + Err(error) => { + eprintln!("host access broker could not create HTTP client: {error}"); + return; + } + }; + while !inner.is_stopped() { + match inner.next_operation(&client) { + Ok(Some(operation)) => inner.spawn_operation(client.clone(), operation), + Ok(None) => thread::sleep(inner.config.poll_interval), + Err(error) => { + eprintln!("host access broker poll failed and revoked local leases: {error}"); + if let Err(cleanup) = inner.revoke_all_and_stop() { + eprintln!("host access broker cleanup after poll failure failed: {cleanup}"); + } + thread::sleep(inner.config.poll_interval); + } + } + } +} + +impl Inner { + fn is_stopped(&self) -> bool { + self.state.lock().expect("host state poisoned").stopped + } + + fn revoke_all_and_stop(&self) -> HostAccessResult<()> { + let _effect = self.effect_lock.lock().expect("host effect lock poisoned"); + let running = { + let mut state = self.state.lock().expect("host state poisoned"); + for grant in state.grants.values_mut() { + grant.revoked = true; + } + state.running.values().cloned().collect::>() + }; + let mut failed = Vec::new(); + for container in &running { + if let Err(error) = self.remove_container(&container.name) { + failed.push(format!("{}: {error}", container.name)); + } + } + if failed.is_empty() { + let mut state = self.state.lock().expect("host state poisoned"); + for container in running { + state.running.remove(&container.operation_id); + } + Ok(()) + } else { + Err(HostAccessError::Runtime(format!( + "Could not remove host containers: {}", + failed.join(", ") + ))) + } + } + + fn next_operation(&self, client: &Client) -> HostAccessResult> { + let url = endpoint(&self.config.base_url, "/api/host-access/desktop/next"); + let response = client + .get(url) + .bearer_auth(&self.config.token) + .send() + .map_err(|error| { + HostAccessError::Http(format!("Could not poll host operation: {error}")) + })?; + if !response.status().is_success() { + return Err(HostAccessError::Http(format!( + "Host operation poll failed with HTTP {}", + response.status() + ))); + } + let poll = response.json::().map_err(|error| { + HostAccessError::Http(format!( + "Host operation poll returned invalid JSON: {error}" + )) + })?; + Ok(poll.operations.into_iter().next()) + } + + fn spawn_operation(self: &Arc, client: Client, operation: DesktopOperation) { + if matches!( + operation.kind, + HostOperationKind::Cancel | HostOperationKind::Stop + ) { + self.handle_and_post(&client, operation); + return; + } + let accepted = { + let mut state = self.state.lock().expect("host state poisoned"); + if state.stopped || state.canceled_operations.contains(&operation.operation_id) { + Err("Host access is stopped.".to_string()) + } else if state.active_by_operation.len() >= MAX_ACTIVE_OPERATIONS { + Err("Too many host operations are already waiting.".to_string()) + } else if state.active_by_bot.contains(&operation.bot_id) { + Err("That Bot already has a host operation in progress.".to_string()) + } else { + state + .active_by_operation + .insert(operation.operation_id.clone()); + state.active_by_bot.insert(operation.bot_id.clone()); + Ok(()) + } + }; + if let Err(error) = accepted { + self.post_error(&client, &operation.operation_id, error); + return; + } + let inner = self.clone(); + let operation_id = operation.operation_id.clone(); + let bot_id = operation.bot_id.clone(); + let worker_operation_id = operation_id.clone(); + let worker_bot_id = bot_id.clone(); + let worker_client = client.clone(); + if let Err(error) = thread::Builder::new() + .name("openbot-host-operation".into()) + .spawn(move || { + inner.handle_and_post(&worker_client, operation); + let mut state = inner.state.lock().expect("host state poisoned"); + state.active_by_operation.remove(&worker_operation_id); + state.active_by_bot.remove(&worker_bot_id); + }) + { + let mut state = self.state.lock().expect("host state poisoned"); + state.active_by_operation.remove(&operation_id); + state.active_by_bot.remove(&bot_id); + drop(state); + self.post_error( + &client, + &operation_id, + format!("Could not start host operation worker: {error}"), + ); + } + } + + fn post_error(&self, client: &Client, operation_id: &str, error: String) { + let body = DesktopResult { + operation_id: operation_id.to_string(), + ok: false, + result: None, + grant: None, + error: Some(error), + }; + let url = endpoint(&self.config.base_url, "/api/host-access/desktop/result"); + if let Err(error) = client + .post(url) + .bearer_auth(&self.config.token) + .json(&body) + .send() + { + eprintln!("host access broker could not post refusal: {error}"); + } + } + + fn handle_and_post(&self, client: &Client, operation: DesktopOperation) { + let result = self.handle_operation(&operation); + let body = match result { + Ok(success) => DesktopResult { + operation_id: operation.operation_id.clone(), + ok: true, + result: success.output.map(serde_json::Value::String), + grant: success.grant.map(|grant| DesktopGrantResult { + grant_id: grant.grant_id, + display_name: grant.display_name, + writable: grant.writable, + }), + error: None, + }, + Err(error) => { + eprintln!( + "host access operation {} failed: {error}", + operation.operation_id + ); + DesktopResult { + operation_id: operation.operation_id.clone(), + ok: false, + result: None, + grant: None, + error: Some(public_error_message(&error)), + } + } + }; + let url = endpoint(&self.config.base_url, "/api/host-access/desktop/result"); + if let Err(error) = client + .post(url) + .bearer_auth(&self.config.token) + .json(&body) + .send() + { + eprintln!("host access broker could not post result: {error}"); + } + } + + fn operation_was_canceled(&self, operation_id: &str) -> bool { + let state = self.state.lock().expect("host state poisoned"); + state.stopped || state.canceled_operations.contains(operation_id) + } + + fn operation_expired(&self, operation: &DesktopOperation) -> bool { + let now = now_millis(); + now.saturating_sub(operation.received_at_ms) > MAX_OPERATION_AGE_MS + || operation + .expires_at + .is_some_and(|expires_at| u128::from(expires_at) <= now) + } + + fn ensure_operation_fresh(&self, operation: &DesktopOperation) -> HostAccessResult<()> { + if self.operation_expired(operation) { + return Err(HostAccessError::Denied( + "Host operation approval expired.".into(), + )); + } + Ok(()) + } + + fn handle_operation(&self, operation: &DesktopOperation) -> HostAccessResult { + if matches!( + operation.kind, + HostOperationKind::Cancel | HostOperationKind::Stop + ) { + return self.cancel_or_stop(operation); + } + { + let mut state = self.state.lock().expect("host state poisoned"); + if self.operation_expired(operation) { + return Err(HostAccessError::Denied( + "Host operation approval expired.".into(), + )); + } + if state.canceled_operations.contains(&operation.operation_id) { + return Err(HostAccessError::Denied( + "Host operation was canceled.".into(), + )); + } + if state.completed.contains(&operation.operation_id) { + return Err(HostAccessError::Denied( + "Host operation replay was refused.".into(), + )); + } + state.completed.insert(operation.operation_id.clone()); + } + match operation.kind { + HostOperationKind::ChooseFolder => self.choose_folder(operation), + HostOperationKind::ListFiles => self.list_files(operation), + HostOperationKind::ReadFile => self.read_file(operation), + HostOperationKind::WriteFile => self.write_file(operation), + HostOperationKind::RunCommand => self.run_command(operation), + HostOperationKind::Cancel | HostOperationKind::Stop => unreachable!(), + } + } + + fn choose_folder(&self, operation: &DesktopOperation) -> HostAccessResult { + let approved = self.approval.choose_folder(&ChooseFolderPrompt { + operation_id: operation.operation_id.clone(), + bot_id: operation.bot_id.clone(), + actor_id: operation.actor_id.clone(), + bot_name: operation.bot_name.clone(), + writable_requested: operation.writable == Some(true), + })?; + let root = validate_grant_root(&approved.root, &self.config.forbidden_paths)?; + self.ensure_operation_fresh(operation)?; + if self.operation_was_canceled(&operation.operation_id) { + return Err(HostAccessError::Denied( + "Host operation was canceled before the folder was granted.".into(), + )); + } + let grant_id = fresh_id("host-grant"); + let display_name = display_name_for(&root); + let grant = LocalGrant { + id: grant_id.clone(), + bot_id: operation.bot_id.clone(), + actor_id: operation.actor_id.clone(), + bot_name: operation.bot_name.clone(), + root, + revoked: false, + }; + self.state + .lock() + .expect("host state poisoned") + .grants + .insert(grant_id.clone(), grant); + Ok(OperationSuccess { + output: None, + grant: Some(OperationGrant { + grant_id, + display_name, + writable: false, + }), + }) + } + + fn list_files(&self, operation: &DesktopOperation) -> HostAccessResult { + let grant = self.require_grant(operation)?; + let directory = resolve_relative( + &grant.root, + operation.relative_path.as_deref().unwrap_or("."), + )?; + if !directory.is_dir() { + return Err(HostAccessError::Denied( + "Host path is not a directory.".into(), + )); + } + let relative = host_relative(&grant.root, &directory)?; + let script = format!( + "find {} -maxdepth 1 -mindepth 1 -printf '%f\\n' | sort | head -200", + shell_quote(container_path(&relative)) + ); + self.ensure_operation_still_allowed(operation, &grant)?; + self.run_container(&operation.operation_id, &grant, false, &script) + .map(OperationSuccess::output) + } + + fn read_file(&self, operation: &DesktopOperation) -> HostAccessResult { + let grant = self.require_grant(operation)?; + let relative_path = operation + .relative_path + .as_deref() + .ok_or_else(|| HostAccessError::Denied("Host read missing a relative path.".into()))?; + let path = resolve_relative(&grant.root, relative_path)?; + if !path.is_file() { + return Err(HostAccessError::Denied("Host path is not a file.".into())); + } + let relative = host_relative(&grant.root, &path)?; + let script = format!("cat -- {}", shell_quote(container_path(&relative))); + self.ensure_operation_still_allowed(operation, &grant)?; + self.run_container(&operation.operation_id, &grant, false, &script) + .map(OperationSuccess::output) + } + + fn write_file(&self, operation: &DesktopOperation) -> HostAccessResult { + let grant = self.require_grant(operation)?; + let relative_path = operation + .relative_path + .as_deref() + .ok_or_else(|| HostAccessError::Denied("Host write missing a relative path.".into()))?; + let content = operation + .content + .clone() + .ok_or_else(|| HostAccessError::Denied("Host write missing content.".into()))?; + let target = resolve_relative_for_write(&grant.root, relative_path)?; + self.approval.confirm_write(&WritePrompt { + operation_id: operation.operation_id.clone(), + bot_id: operation.bot_id.clone(), + bot_name: grant.bot_name.clone(), + root: grant.root.clone(), + relative_path: relative_path.into(), + content: content.clone(), + writable: true, + })?; + self.ensure_operation_still_allowed(operation, &grant)?; + let relative = host_relative_for_write(&grant.root, &target)?; + let target_path = container_path(&relative); + let backup_name = backup_name_for(&relative); + let backup_path = format!("{APPROVED_MOUNT}/.openbot-backups/{backup_name}"); + let script = format!( + "mkdir -p -- {backup_dir} && if [ -e {target} ]; then cp -- {target} {backup}; fi && cat > {target}", + backup_dir = shell_quote(format!("{APPROVED_MOUNT}/.openbot-backups")), + target = shell_quote(target_path), + backup = shell_quote(backup_path.clone()), + ); + self.run_container_with_stdin( + &operation.operation_id, + &grant, + true, + &script, + content.as_bytes(), + )?; + Ok(OperationSuccess::output(format!( + "Wrote file. Backup, if the file existed, is .openbot-backups/{backup_name}" + ))) + } + + fn run_command(&self, operation: &DesktopOperation) -> HostAccessResult { + let grant = self.require_grant(operation)?; + let command = operation + .command + .as_deref() + .ok_or_else(|| HostAccessError::Denied("Host command missing command text.".into()))?; + if command.trim().is_empty() { + return Err(HostAccessError::Denied("Host command was empty.".into())); + } + let writable = operation.writable == Some(true); + let working_directory = match operation.relative_path.as_deref() { + Some(value) if !value.trim().is_empty() => Some(resolve_relative(&grant.root, value)?), + _ => None, + }; + self.approval.confirm_command(&CommandPrompt { + operation_id: operation.operation_id.clone(), + bot_id: operation.bot_id.clone(), + bot_name: grant.bot_name.clone(), + root: grant.root.clone(), + working_directory: operation.relative_path.clone(), + command: command.into(), + writable, + })?; + self.ensure_operation_still_allowed(operation, &grant)?; + let command_directory = if let Some(directory) = working_directory { + let relative = host_relative(&grant.root, &directory)?; + container_path(&relative) + } else { + APPROVED_MOUNT.into() + }; + let script = format!("cd -- {} && {command}", shell_quote(command_directory)); + self.run_container(&operation.operation_id, &grant, writable, &script) + .map(OperationSuccess::output) + } + + fn cancel_or_stop(&self, operation: &DesktopOperation) -> HostAccessResult { + let target_operation_id = operation + .target_operation_id + .as_deref() + .unwrap_or(&operation.operation_id) + .to_string(); + let running = { + let mut state = self.state.lock().expect("host state poisoned"); + state + .canceled_operations + .insert(target_operation_id.clone()); + if let Some(grant_id) = operation.grant_id.as_deref() { + if let Some(grant) = state.grants.get_mut(grant_id) { + if grant.actor_id == operation.actor_id || operation.actor_id == "*" { + grant.revoked = true; + } + } + } + if matches!(operation.kind, HostOperationKind::Stop) { + for grant in state.grants.values_mut() { + if operation.actor_id == "*" || grant.actor_id == operation.actor_id { + grant.revoked = true; + } + } + } + state + .running + .values() + .filter(|container| { + matches!(operation.kind, HostOperationKind::Stop) + && (operation.actor_id == "*" || container.actor_id == operation.actor_id) + || operation + .grant_id + .as_ref() + .is_some_and(|grant_id| container.grant_id.as_ref() == Some(grant_id)) + || container.operation_id == target_operation_id + }) + .cloned() + .collect::>() + }; + for container in &running { + self.remove_container(&container.name)?; + } + let mut state = self.state.lock().expect("host state poisoned"); + for container in running { + state.running.remove(&container.operation_id); + } + Ok(OperationSuccess::empty()) + } + + fn require_grant(&self, operation: &DesktopOperation) -> HostAccessResult { + let grant_id = required_grant_id(operation)?; + let state = self.state.lock().expect("host state poisoned"); + let grant = state.grants.get(grant_id).ok_or_else(|| { + HostAccessError::Denied("Host grant is unknown on this device.".into()) + })?; + if grant.revoked { + return Err(HostAccessError::Denied("Host grant was revoked.".into())); + } + if grant.bot_id != operation.bot_id || grant.actor_id != operation.actor_id { + return Err(HostAccessError::Denied( + "Host grant is bound to a different Bot or actor.".into(), + )); + } + Ok(grant.clone()) + } + fn ensure_operation_still_allowed( + &self, + operation: &DesktopOperation, + grant: &LocalGrant, + ) -> HostAccessResult<()> { + let state = self.state.lock().expect("host state poisoned"); + if state.stopped || state.canceled_operations.contains(&operation.operation_id) { + return Err(HostAccessError::Denied( + "Host access was stopped before execution.".into(), + )); + } + drop(state); + self.ensure_operation_fresh(operation)?; + let state = self.state.lock().expect("host state poisoned"); + match state.grants.get(&grant.id) { + Some(current) + if !current.revoked + && current.bot_id == operation.bot_id + && current.actor_id == operation.actor_id => + { + Ok(()) + } + _ => Err(HostAccessError::Denied( + "Host grant was revoked before execution.".into(), + )), + } + } + + fn run_container( + &self, + operation_id: &str, + grant: &LocalGrant, + writable: bool, + script: &str, + ) -> HostAccessResult { + self.run_container_with_stdin(operation_id, grant, writable, script, &[]) + } + + fn run_container_with_stdin( + &self, + operation_id: &str, + grant: &LocalGrant, + writable: bool, + script: &str, + stdin: &[u8], + ) -> HostAccessResult { + let name = fresh_container_name(operation_id); + { + let _effect = self.effect_lock.lock().expect("host effect lock poisoned"); + self.ensure_image_exists()?; + let mut create = self.config.engine.command(); + append_container_create_args( + &mut create, + &self.config, + &self.instance_label, + &name, + &grant.root, + writable, + script, + ); + let output = create.output().map_err(|error| { + HostAccessError::Runtime(format!("Could not create host container: {error}")) + })?; + if !output.status.success() { + return Err(HostAccessError::Runtime(format!( + "Could not create host container: {}", + command_said(&output.stderr) + ))); + } + let mut state = self.state.lock().expect("host state poisoned"); + if state.stopped + || state + .grants + .get(&grant.id) + .map(|g| g.revoked) + .unwrap_or(true) + { + drop(state); + let _ = self.remove_container(&name); + return Err(HostAccessError::Denied( + "Host operation was stopped before it could start.".into(), + )); + } + state.running.insert( + operation_id.into(), + RunningContainer { + name: name.clone(), + operation_id: operation_id.into(), + actor_id: grant.actor_id.clone(), + grant_id: Some(grant.id.clone()), + }, + ); + } + let result = self.start_attach_wait(&name, stdin); + let remove_result = self.remove_container(&name); + if remove_result.is_ok() { + self.state + .lock() + .expect("host state poisoned") + .running + .remove(operation_id); + } + remove_result?; + result + } + + fn start_attach_wait(&self, name: &str, stdin: &[u8]) -> HostAccessResult { + let mut start = self.config.engine.command(); + start.args(["start", "-a"]); + if !stdin.is_empty() { + start.arg("-i"); + start.stdin(Stdio::piped()); + } + start.arg(name); + start.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = start.spawn().map_err(|error| { + HostAccessError::Runtime(format!("Could not start host container: {error}")) + })?; + if !stdin.is_empty() { + if let Some(mut pipe) = child.stdin.take() { + pipe.write_all(stdin).map_err(|error| { + HostAccessError::Runtime(format!("Could not write host input: {error}")) + })?; + } + } + let stdout = child + .stdout + .take() + .ok_or_else(|| HostAccessError::Runtime("Could not capture host stdout.".into()))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| HostAccessError::Runtime("Could not capture host stderr.".into()))?; + let output_limit = self.config.output_limit; + let stdout_reader = thread::spawn(move || read_limited(stdout, output_limit)); + let stderr_reader = thread::spawn(move || read_limited(stderr, output_limit)); + let deadline = Instant::now() + self.config.operation_timeout + Duration::from_secs(2); + loop { + if child + .try_wait() + .map_err(|error| { + HostAccessError::Runtime(format!("Could not inspect host process: {error}")) + })? + .is_some() + { + break; + } + if Instant::now() >= deadline { + let _ = self.remove_container(name); + let _ = child.kill(); + return Err(HostAccessError::Runtime( + "Host operation timed out and was stopped.".into(), + )); + } + thread::sleep(Duration::from_millis(100)); + } + let status = child.wait().map_err(|error| { + HostAccessError::Runtime(format!("Could not reap host process: {error}")) + })?; + let mut combined = stdout_reader + .join() + .map_err(|_| HostAccessError::Runtime("Host stdout reader panicked.".into()))??; + combined.extend( + stderr_reader + .join() + .map_err(|_| HostAccessError::Runtime("Host stderr reader panicked.".into()))??, + ); + let text = limit_output(&combined, self.config.output_limit)?; + if !status.success() { + let inspect = self.run_engine(["inspect", "-f", "{{.State.ExitCode}}", name])?; + let exit = String::from_utf8_lossy(&inspect.stdout).trim().to_string(); + return Err(HostAccessError::Runtime(format!( + "Host operation exited with code {exit}: {text}" + ))); + } + Ok(text) + } + + fn verify_no_owned_containers(&self) -> HostAccessResult<()> { + let filter = format!("label=openbot.host-access.instance={}", self.instance_label); + let output = self + .config + .engine + .command() + .args(["ps", "-a", "--filter", &filter, "--format", "{{.Names}}"]) + .output() + .map_err(|error| { + HostAccessError::Runtime(format!( + "Could not verify host containers stopped: {error}" + )) + })?; + if !output.status.success() { + return Err(HostAccessError::Runtime(format!( + "Could not verify host containers stopped: {}", + command_said(&output.stderr) + ))); + } + let remaining = String::from_utf8_lossy(&output.stdout); + if remaining.trim().is_empty() { + Ok(()) + } else { + Err(HostAccessError::Runtime(format!( + "Host containers are still present: {}", + remaining.trim() + ))) + } + } + + fn run_engine(&self, args: [&str; N]) -> HostAccessResult { + self.config + .engine + .command() + .args(args) + .output() + .map_err(|error| { + HostAccessError::Runtime(format!("Could not run container engine: {error}")) + }) + } + + fn remove_container(&self, name: &str) -> HostAccessResult<()> { + let output = self.run_engine(["rm", "-f", name])?; + if !output.status.success() { + let said = command_said(&output.stderr); + let lower = said.to_lowercase(); + if lower.contains("no such container") || lower.contains("no container with name") { + return Ok(()); + } + return Err(HostAccessError::Runtime(format!( + "Could not remove host container: {said}" + ))); + } + Ok(()) + } + + fn ensure_image_exists(&self) -> HostAccessResult<()> { + let output = self + .config + .engine + .command() + .args(["image", "inspect", &self.config.image]) + .output() + .map_err(|error| { + HostAccessError::Runtime(format!("Could not inspect host image: {error}")) + })?; + if output.status.success() { + Ok(()) + } else { + Err(HostAccessError::Runtime(format!( + "Required host sandbox image is unavailable: {}", + self.config.image + ))) + } + } +} + +fn validate_config(config: &HostAccessConfig) -> HostAccessResult<()> { + if config.base_url.trim().is_empty() { + return Err(HostAccessError::InvalidConfig( + "Host access base URL is required.".into(), + )); + } + if config.token.trim().is_empty() { + return Err(HostAccessError::InvalidConfig( + "Host access token is required.".into(), + )); + } + if config.image.trim().is_empty() { + return Err(HostAccessError::InvalidConfig( + "Host access container image is required.".into(), + )); + } + Ok(()) +} + +fn public_error_message(error: &HostAccessError) -> String { + match error { + HostAccessError::InvalidConfig(_) => "Local host access is not configured.".into(), + HostAccessError::Http(_) => "The desktop lost its host access connection.".into(), + HostAccessError::Runtime(message) if message.contains("timed out") => { + "The host operation timed out and was stopped.".into() + } + HostAccessError::Runtime(message) if message.contains("output exceeded") => { + "The host operation produced too much output and was stopped.".into() + } + HostAccessError::Runtime(_) => "The local sandbox could not complete the operation.".into(), + HostAccessError::Io(_) => "The local file operation failed.".into(), + HostAccessError::Denied(message) if message.contains("approved folder") => { + "The requested path is outside the approved folder.".into() + } + HostAccessError::Denied(message) if message.contains("unavailable") => { + "The requested path is unavailable.".into() + } + HostAccessError::Denied(message) if message.contains("cannot be granted") => { + "That folder cannot be granted to a Bot.".into() + } + HostAccessError::Denied(message) => message.clone(), + } +} + +fn now_millis() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +fn endpoint(base: &str, path: &str) -> String { + format!("{}{}", base.trim_end_matches('/'), path) +} + +fn required_grant_id(operation: &DesktopOperation) -> HostAccessResult<&str> { + operation + .grant_id + .as_deref() + .ok_or_else(|| HostAccessError::Denied("Host operation missing grant id.".into())) +} + +fn append_container_create_args( + command: &mut Command, + config: &HostAccessConfig, + instance_label: &str, + name: &str, + root: &Path, + writable: bool, + script: &str, +) { + let (uid, gid) = owner_ids(); + command.args([ + "create", + "--name", + name, + "--pull", + "never", + "--network", + "none", + "--read-only", + "--interactive", + "--tmpfs", + ]); + match config.engine.engine { + Engine::Docker => { + command.arg(format!( + "/tmp:rw,nosuid,nodev,noexec,size=64m,uid={uid},gid={gid},mode=700" + )); + command.args(["--tmpfs"]); + command.arg(format!( + "{WORKSPACE_MOUNT}:rw,nosuid,nodev,exec,size=128m,uid={uid},gid={gid},mode=700" + )); + } + Engine::Podman => { + command.arg("/tmp:rw,nosuid,nodev,noexec,size=64m,mode=1777"); + command.args(["--tmpfs"]); + command.arg(format!( + "{WORKSPACE_MOUNT}:rw,nosuid,nodev,exec,size=128m,mode=1777" + )); + command.args(["--read-only-tmpfs=false"]); + } + } + command.args(["--mount"]); + let bind_recursion = match config.engine.engine { + Engine::Docker => "bind-recursive=disabled", + Engine::Podman => "bind-nonrecursive=true", + }; + let readonly = if writable { "" } else { ",readonly" }; + command.arg(format!( + "type=bind,src={},dst={APPROVED_MOUNT}{readonly},{bind_recursion}", + root.display() + )); + command.arg("--user"); + command.arg(format!("{uid}:{gid}")); + command.args([ + "--workdir", + WORKSPACE_MOUNT, + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges=true", + "--pids-limit", + ]); + command.arg(&config.pids_limit); + command.arg("--memory"); + command.arg(&config.memory); + command.arg("--cpus"); + command.arg(&config.cpus); + command.args(["--label", "openbot.host-access=true"]); + command.arg("--label"); + command.arg(format!("openbot.host-access.instance={instance_label}")); + command.args(["--entrypoint", "/usr/bin/env"]); + command.arg(&config.image); + command.args([ + "-i", + "PATH=/usr/local/bin:/usr/bin:/bin", + "HOME=/workspace", + "timeout", + "--signal=TERM", + "--kill-after=1s", + ]); + command.arg(format!("{}s", config.operation_timeout.as_secs().max(1))); + command.args(["sh", "-lc", script]); +} + +fn owner_ids() -> (u32, u32) { + #[cfg(unix)] + unsafe { + (libc::getuid(), libc::getgid()) + } + #[cfg(not(unix))] + { + (1000, 1000) + } +} + +struct ForbiddenPaths { + exact_or_ancestor_only: Vec, + protected_subtrees: Vec, +} + +fn validate_grant_root(path: &Path, configured_forbidden: &[PathBuf]) -> HostAccessResult { + let root = canonical(path)?; + if root.parent().is_none() { + return Err(HostAccessError::Denied( + "The filesystem root cannot be granted.".into(), + )); + } + let forbidden = forbidden_paths(configured_forbidden); + for denied in &forbidden.exact_or_ancestor_only { + if &root == denied || path_contains(&root, denied) { + return Err(HostAccessError::Denied( + "That folder cannot be granted to a Bot.".into(), + )); + } + } + for denied in &forbidden.protected_subtrees { + if path_contains(&root, denied) || path_contains(denied, &root) { + return Err(HostAccessError::Denied( + "That folder cannot be granted to a Bot.".into(), + )); + } + } + Ok(root) +} + +fn forbidden_paths(configured: &[PathBuf]) -> ForbiddenPaths { + let mut exact_or_ancestor_only = Vec::new(); + let mut protected_subtrees = Vec::new(); + protected_subtrees.extend(configured.iter().filter_map(|path| canonical(path).ok())); + if let Some(home) = home_dir().and_then(|path| canonical(&path).ok()) { + exact_or_ancestor_only.push(home.clone()); + for relative in [ + ".ssh", + ".gnupg", + ".aws", + ".config/gcloud", + ".docker", + ".kube", + "Library/Application Support/OpenBot", + "Library/Application Support/Google/Chrome", + "Library/Application Support/BraveSoftware", + "Library/Application Support/Firefox", + "Library/Keychains", + "AppData/Roaming/OpenBot", + "AppData/Local/Google/Chrome", + "AppData/Local/BraveSoftware", + "AppData/Roaming/Mozilla/Firefox", + "AppData/Roaming/Microsoft/Windows/PowerShell", + ] { + protected_subtrees.push(home.join(relative)); + } + } + for path in [ + "/System", + "/Library", + "/Applications", + "/private/etc", + "/etc", + "/var/run", + "C:\\Windows", + "C:\\Program Files", + "C:\\Program Files (x86)", + "C:\\ProgramData", + ] { + if let Ok(path) = canonical(Path::new(path)) { + protected_subtrees.push(path); + } + } + ForbiddenPaths { + exact_or_ancestor_only, + protected_subtrees, + } +} + +fn home_dir() -> Option { + std::env::var_os("HOME").map(PathBuf::from).or({ + #[cfg(windows)] + { + std::env::var_os("USERPROFILE").map(PathBuf::from) + } + #[cfg(not(windows))] + { + None + } + }) +} + +fn resolve_relative(root: &Path, relative: &str) -> HostAccessResult { + reject_unsafe_relative(relative)?; + let root = canonical(root)?; + let target = canonical(&root.join(relative))?; + if path_contains(&root, &target) { + Ok(target) + } else { + Err(HostAccessError::Denied( + "Host path escaped the approved folder.".into(), + )) + } +} + +fn resolve_relative_for_write(root: &Path, relative: &str) -> HostAccessResult { + reject_unsafe_relative(relative)?; + let root = canonical(root)?; + let raw = root.join(relative); + let parent = raw + .parent() + .ok_or_else(|| HostAccessError::Denied("Host write has no parent directory.".into()))?; + let parent = canonical(parent)?; + if !path_contains(&root, &parent) { + return Err(HostAccessError::Denied( + "Host write escaped the approved folder.".into(), + )); + } + if raw.exists() { + let existing = canonical(&raw)?; + if !path_contains(&root, &existing) { + return Err(HostAccessError::Denied( + "Host write target escaped through a link.".into(), + )); + } + } + Ok(raw) +} + +fn reject_unsafe_relative(relative: &str) -> HostAccessResult<()> { + let path = Path::new(relative); + if path.is_absolute() { + return Err(HostAccessError::Denied( + "Host path must be relative.".into(), + )); + } + for component in path.components() { + if matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) { + return Err(HostAccessError::Denied( + "Host path cannot traverse upward.".into(), + )); + } + } + Ok(()) +} + +fn canonical(path: &Path) -> HostAccessResult { + fs::canonicalize(path).map_err(|_| HostAccessError::Denied("Host path is unavailable.".into())) +} + +fn path_contains(root: &Path, candidate: &Path) -> bool { + let root = normalize_for_compare(root); + let candidate = normalize_for_compare(candidate); + candidate == root || candidate.starts_with(&root) +} + +fn normalize_for_compare(path: &Path) -> PathBuf { + #[cfg(windows)] + { + PathBuf::from(path.to_string_lossy().to_lowercase()) + } + #[cfg(not(windows))] + { + path.to_path_buf() + } +} + +fn host_relative(root: &Path, path: &Path) -> HostAccessResult { + path.strip_prefix(root) + .map(PathBuf::from) + .map_err(|_| HostAccessError::Denied("Host path escaped the approved folder.".into())) +} + +fn host_relative_for_write(root: &Path, path: &Path) -> HostAccessResult { + path.strip_prefix(root) + .map(PathBuf::from) + .map_err(|_| HostAccessError::Denied("Host write escaped the approved folder.".into())) +} + +fn container_path(relative: &Path) -> String { + let suffix = relative + .components() + .filter_map(|component| match component { + Component::Normal(value) => Some(value.to_string_lossy().into_owned()), + _ => None, + }) + .collect::>() + .join("/"); + if suffix.is_empty() { + APPROVED_MOUNT.into() + } else { + format!("{APPROVED_MOUNT}/{suffix}") + } +} + +fn shell_quote(value: String) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn backup_name_for(relative: &Path) -> String { + let safe = relative + .components() + .filter_map(|component| match component { + Component::Normal(value) => Some(value.to_string_lossy()), + _ => None, + }) + .collect::>() + .join("__") + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') { + ch + } else { + '_' + } + }) + .collect::(); + format!( + "{}-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + if safe.is_empty() { "file".into() } else { safe } + ) +} + +fn display_name_for(root: &Path) -> String { + root.file_name() + .and_then(OsStr::to_str) + .filter(|name| !name.is_empty()) + .unwrap_or("Selected folder") + .to_string() +} + +fn fresh_id(prefix: &str) -> String { + format!( + "{prefix}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ) +} + +fn fresh_container_name(operation_id: &str) -> String { + let safe: String = operation_id + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' }) + .take(32) + .collect(); + format!("openbot-host-{safe}-{}", std::process::id()) +} + +fn read_limited(mut reader: R, limit: usize) -> HostAccessResult> { + let mut output = Vec::new(); + let mut buffer = [0_u8; 8192]; + loop { + let read = reader.read(&mut buffer).map_err(|error| { + HostAccessError::Runtime(format!("Could not read host output: {error}")) + })?; + if read == 0 { + return Ok(output); + } + output.extend_from_slice(&buffer[..read]); + if output.len() > limit { + return Err(HostAccessError::Runtime(format!( + "Host operation output exceeded {limit} bytes." + ))); + } + } +} + +fn limit_output(bytes: &[u8], limit: usize) -> HostAccessResult { + if bytes.len() > limit { + return Err(HostAccessError::Runtime(format!( + "Host operation output exceeded {limit} bytes." + ))); + } + String::from_utf8(bytes.to_vec()) + .map_err(|_| HostAccessError::Runtime("Host operation returned non-UTF-8 output.".into())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::{Address, Engine}; + use crate::quiet::command as quiet_command; + + fn temp_root(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "{name}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&path).unwrap(); + path + } + + #[test] + fn grant_root_rejects_forbidden_ancestors_and_children() { + let root = temp_root("host-access-root"); + let forbidden = root.join("private"); + fs::create_dir_all(&forbidden).unwrap(); + assert!(validate_grant_root(&root, std::slice::from_ref(&forbidden)).is_err()); + assert!(validate_grant_root(&forbidden, std::slice::from_ref(&root)).is_err()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn relative_paths_cannot_escape_through_dotdot_or_symlink() { + let root = temp_root("host-access-relative"); + let outside = temp_root("host-access-outside"); + fs::write(root.join("inside.txt"), "ok").unwrap(); + fs::write(outside.join("secret.txt"), "nope").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(outside.join("secret.txt"), root.join("link")).unwrap(); + assert!(resolve_relative(&root, "inside.txt").is_ok()); + assert!(resolve_relative(&root, "../outside").is_err()); + #[cfg(unix)] + assert!(resolve_relative(&root, "link").is_err()); + fs::remove_dir_all(root).unwrap(); + fs::remove_dir_all(outside).unwrap(); + } + + #[test] + fn docker_args_are_offline_readonly_nonroot_and_have_no_engine_socket() { + let root = temp_root("host-access-args"); + let config = HostAccessConfig::new( + "http://127.0.0.1:3001", + "token", + Address::new(Engine::Docker, None), + DEFAULT_IMAGE, + vec![], + ); + let mut command = quiet_command("docker"); + append_container_create_args( + &mut command, + &config, + "test-instance", + "case", + &root, + false, + "cat /approved/a", + ); + let args: Vec<_> = command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + assert!(args.windows(2).any(|pair| pair == ["--network", "none"])); + assert!(args.iter().any(|arg| arg == "--read-only")); + assert!(args.windows(2).any(|pair| pair == ["--cap-drop", "ALL"])); + assert!(args + .windows(2) + .any(|pair| pair == ["--security-opt", "no-new-privileges=true"])); + assert!(args + .windows(2) + .any(|pair| pair[0] == "--user" && !pair[1].starts_with('0'))); + assert!(args.iter().any(|arg| arg.contains("readonly"))); + assert!(args.iter().any(|arg| arg == "timeout")); + assert!(!args + .iter() + .any(|arg| arg.contains("docker.sock") || arg.contains("podman.sock"))); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn podman_args_use_supported_tmpfs_and_nonrecursive_bind_flags() { + let root = temp_root("host-access-podman-args"); + let config = HostAccessConfig::new( + "http://127.0.0.1:3001", + "token", + Address::new(Engine::Podman, Some("openbot".into())), + DEFAULT_IMAGE, + vec![], + ); + let mut command = quiet_command("podman"); + append_container_create_args( + &mut command, + &config, + "test-instance", + "case", + &root, + false, + "true", + ); + let args: Vec<_> = command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + assert!(args.iter().any(|arg| arg == "--read-only-tmpfs=false")); + assert!(args + .iter() + .any(|arg| arg.contains("bind-nonrecursive=true"))); + assert!(!args + .iter() + .any(|arg| arg.contains("uid=") || arg.contains("gid="))); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn grant_binding_refuses_wrong_bot_or_actor() { + let config = HostAccessConfig::new( + "http://127.0.0.1:3001", + "token", + Address::new(Engine::Docker, None), + DEFAULT_IMAGE, + vec![], + ); + let inner = Inner { + config, + approval: Arc::new(DenyAllApprovalUi), + instance_label: "test-instance".into(), + effect_lock: Mutex::new(()), + state: Mutex::new(State { + stopped: false, + thread: None, + grants: HashMap::from([( + "grant".into(), + LocalGrant { + id: "grant".into(), + bot_id: "bot-a".into(), + actor_id: "actor".into(), + bot_name: None, + root: temp_root("host-access-grant"), + revoked: false, + }, + )]), + running: HashMap::new(), + completed: HashSet::new(), + canceled_operations: HashSet::new(), + active_by_operation: HashSet::new(), + active_by_bot: HashSet::new(), + }), + }; + let operation = DesktopOperation { + operation_id: "op1".into(), + kind: HostOperationKind::ReadFile, + bot_id: "bot-b".into(), + actor_id: "actor".into(), + bot_name: None, + grant_id: Some("grant".into()), + target_operation_id: None, + relative_path: Some("file.txt".into()), + content: None, + command: None, + writable: None, + expires_at: None, + received_at_ms: now_millis(), + }; + assert!(inner.require_grant(&operation).is_err()); + } + + #[test] + fn ordinary_child_folder_under_home_is_allowed_but_home_itself_is_not() { + let Some(home) = home_dir() else { + return; + }; + let child = home.join(format!("openbot-host-access-{}", std::process::id())); + fs::create_dir_all(&child).unwrap(); + assert!(validate_grant_root(&child, &[]).is_ok()); + assert!(validate_grant_root(&home, &[]).is_err()); + fs::remove_dir_all(child).unwrap(); + } + + #[test] + fn container_paths_are_posix_even_for_windows_style_components() { + assert_eq!( + container_path(Path::new("nested/file.txt")), + "/approved/nested/file.txt" + ); + assert_eq!( + container_path(Path::new("nested\\file.txt")), + "/approved/nested\\file.txt" + ); + } + + #[test] + fn bounded_reader_rejects_output_above_the_cap() { + let data = vec![b'x'; DEFAULT_OUTPUT_LIMIT + 1]; + assert!(read_limited(std::io::Cursor::new(data), DEFAULT_OUTPUT_LIMIT).is_err()); + } + + #[test] + fn public_errors_do_not_leak_host_paths_or_engine_stderr() { + let raw = HostAccessError::Runtime("docker: /Users/example/secret failed".into()); + let said = public_error_message(&raw); + assert!(!said.contains("/Users/example")); + let raw = HostAccessError::Denied("Host path /Users/example/.ssh is unavailable".into()); + let said = public_error_message(&raw); + assert!(!said.contains("/Users/example")); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 48950481d..5d5409e12 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ pub mod deployment_release; pub mod engine; pub mod env; pub mod harness; +pub mod host_access; pub mod install; pub mod intelligence; pub mod plan; diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 3b58bdfa9..4aa03c4a2 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -4,14 +4,15 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; +mod desktop_host_access; mod desktop_telemetry; #[cfg(test)] mod test_support; use openbot_desktop_lib::{ - acquire, deployment, deployment_release, engine, env as openbot_env, harness, install, - problem::Problem, provider, pull_metrics, quiet, stack, supervise, telemetry, tray, + acquire, deployment, deployment_release, engine, env as openbot_env, harness, host_access, + install, problem::Problem, provider, pull_metrics, quiet, stack, supervise, telemetry, tray, windows as win, }; @@ -24,6 +25,8 @@ use tauri::{Emitter, Manager}; /// What the shell is running, so the window and the tray say the same thing. #[derive(Default)] struct Shell { + /// Session-only local folder authority; shutdown retires it before stopping the API server. + host_access: Mutex>, /// Named, because a restart policy that cannot say which process died cannot start it again. children: Mutex>, /// Which run is the current one. @@ -879,7 +882,7 @@ async fn start_stack_inner( ), })?; - let (logs, bun, secrets) = { + let (logs, bun, mut secrets) = { let _startup = attempt.lock_current()?; // Belt and braces: a fetch that reported success and left something out is still not a @@ -1101,6 +1104,13 @@ async fn start_stack_inner( } report(&app, "dependencies", true, "installed"); + // Never persisted or passed to Compose. Only the server process receives this credential; + // model workers and frontend processes cannot impersonate the native approval transport. + use base64::Engine as _; + let host_token = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>()); + secrets.insert("OPENBOT_DESKTOP_HOST_TOKEN".into(), host_token.clone()); + let logs_for_wait = logs.clone(); let generation = start_host_processes( &attempt, @@ -1126,6 +1136,26 @@ async fn start_stack_inner( // Stop must not finish between accepting readiness and reporting a successful Start. let _startup = attempt.lock_current()?; // Only a stack that answered successfully acquires a restart policy. + let address = shell + .containers + .lock() + .unwrap() + .as_ref() + .map(|owned| owned.address.clone()) + .ok_or_else(|| Problem::plain("The local container runtime is unavailable."))?; + let config = host_access::HostAccessConfig::new( + format!("http://127.0.0.1:{}", openbot_env::Ports::default().server), + host_token, + address, + deployment::reference(&root, "agent-computer")?, + vec![root.clone()], + ); + let broker = host_access::HostAccess::start_with_approval( + config, + std::sync::Arc::new(desktop_host_access::NativeApproval(app.clone())), + ) + .map_err(|error| Problem::with("Local folder access could not start.", error.to_string()))?; + *shell.host_access.lock().unwrap() = Some(broker); supervise_host_processes(app.clone(), root, logs, bun, secrets, generation); report(&app, "answering", true, "the API and the app are answering"); @@ -1346,6 +1376,18 @@ fn cleanup_host_state(shell: &Shell, root: &Path, cleanup: C) -> Result Result, { + // Also runs for startup replacement and failure recovery, so a retired authorization session + // cannot leave a job running beside a new server. Keep failed cleanup owned for a later Stop. + let host_access_result = { + let mut slot = shell.host_access.lock().unwrap(); + let result = slot.as_ref().map_or(Ok(()), |broker| { + broker.stop().map_err(|error| error.to_string()) + }); + if result.is_ok() { + *slot = None; + } + result + }; let mut children = shell.children.lock().unwrap(); let selected = shell .root @@ -1354,6 +1396,17 @@ where .clone() .unwrap_or_else(|| root.to_path_buf()); let result = cleanup_host_children(&selected, &mut children, cleanup); + let result = match (host_access_result, result) { + (Ok(()), result) => result, + (Err(error), Ok(_)) => Err(Problem::with( + "OpenBot could not stop a folder operation.", + error, + )), + (Err(error), Err(problem)) => Err(Problem::with( + "OpenBot could not finish stopping its work.", + format!("{error}\n{}", problem_detail(problem)), + )), + }; if result.is_ok() { *shell.root.lock().unwrap() = None; } @@ -2814,6 +2867,7 @@ fn main() { })) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) .manage(Shell::default()) .invoke_handler(tauri::generate_handler![ record_setup_event, diff --git a/desktop/src-tauri/src/stack.rs b/desktop/src-tauri/src/stack.rs index b4a41dee7..56a4cff27 100644 --- a/desktop/src-tauri/src/stack.rs +++ b/desktop/src-tauri/src/stack.rs @@ -718,6 +718,15 @@ fn write_host_pid_file(root: &Path, value: &T) -> Result<(), Probl Ok(()) } +/// The desktop approval transport belongs only to the API server. In particular it must never +/// reach the worker running model tools or the frontend development server. +fn configure_host_process_env(command: &mut Command, name: &str, secrets: &Secrets) { + command.envs(secrets); + if name != "server" { + command.env_remove("OPENBOT_DESKTOP_HOST_TOKEN"); + } +} + pub fn spawn_host_process( process: &HostProcess, root: &Path, @@ -738,7 +747,7 @@ pub fn spawn_host_process( * the file either way, so a machine still holding an older run's copy is overridden rather than * fought with. */ - command.envs(secrets); + configure_host_process_env(&mut command, process.name, secrets); if process.script.is_empty() { command.args(["run", process.package_script]); } else { @@ -2550,6 +2559,28 @@ mod tests { use super::*; use crate::test_support::temp_root; + #[test] + fn desktop_approval_transport_credential_reaches_only_the_server() { + let secrets = Secrets::from([ + ("OPENBOT_DESKTOP_HOST_TOKEN".into(), "fixture-only".into()), + ("INTELLIGENCE_API_KEY".into(), "other-fixture".into()), + ]); + for name in ["server", "worker", "app"] { + let mut command = Command::new("unused"); + configure_host_process_env(&mut command, name, &secrets); + let vars: std::collections::BTreeMap<_, _> = command.get_envs().collect(); + assert_eq!( + vars[std::ffi::OsStr::new("OPENBOT_DESKTOP_HOST_TOKEN")], + (name == "server").then_some(std::ffi::OsStr::new("fixture-only")), + "approval credential exposure to {name}" + ); + assert_eq!( + vars[std::ffi::OsStr::new("INTELLIGENCE_API_KEY")], + Some(std::ffi::OsStr::new("other-fixture")) + ); + } + } + #[cfg(unix)] fn unix_fixture(pid: u32, parent: u32) -> UnixProcess { UnixProcess { diff --git a/docs/deployment.md b/docs/deployment.md index c8e64e239..f3b599010 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -206,6 +206,22 @@ are capped at 350 per instance on the basic tier. **Railway, Render, Fly.io.** All run this image directly and all provision PostgreSQL in a click, which makes them the shortest path from nothing to a running deployment. +## Desktop folder access + +In the desktop app, open **Admin → Computers → Folders on this computer** and choose a folder for +one Bot. The local owner confirms it in a native folder picker. Access belongs to that Bot and +person for the current session; a web page or model response cannot approve it. + +Folders start read-only. Each file change and shell command requires a separate native confirmation. +Commands use Linux tools in an isolated container, with networking disabled and a temporary writable +workspace. They cannot run the Mac or Windows applications installed on the host. A command granted +write access can change or delete files in its approved folder; commands are not automatically undoable. + +Use **Revoke** or **Stop folder access** to cancel folder work. **Stop OpenBot** and **Quit** also +terminate the isolated jobs. If the desktop loses its server connection, its folder grants expire. +Protected system, credential and browser-profile directories cannot be selected. Folder access requires +the local desktop app and its container engine; it is unavailable on a standalone web deployment. + ## Known costs **The browser images carry only the Chromium browser family.** The all-in-one Dockerfile and the diff --git a/server/src/agents/callback-token.ts b/server/src/agents/callback-token.ts index 8f95f38cd..ee33d69d6 100644 --- a/server/src/agents/callback-token.ts +++ b/server/src/agents/callback-token.ts @@ -213,7 +213,7 @@ function readInitiator(value: unknown): AuditInitiator { } export type CallVerdict = - | { ok: true; botId: string; actorId: string } + | { ok: true; botId: string; actorId: string; initiator?: AuditInitiator } | { ok: false; status: 401 | 403; reason: string }; /** @@ -280,5 +280,10 @@ export async function authoriseAgentCall(options: { }; } - return { ok: true, botId: assertion.botId, actorId: assertion.actorId }; + return { + ok: true, + botId: assertion.botId, + actorId: assertion.actorId, + initiator: assertion.initiator, + }; } diff --git a/server/src/app.ts b/server/src/app.ts index 44e205ade..2fb23149b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -10,6 +10,7 @@ import { createAgentRoutes } from "./agents/routes"; import { AuditQueryError, type AuditEventType, + type AuditInitiator, type AuditReader, type AuditStore, auditQueryFromUrl, @@ -45,6 +46,8 @@ import { createComputerRoutes } from "./computer/routes"; import { configuredAuthProviders, type DeploymentConfig } from "./config"; import type { CredentialAdminService, CredentialInput } from "./credentials"; import type { Database } from "./db/client"; +import type { HostAccessBroker } from "./host-access/broker"; +import { createHostAccessRoutes } from "./host-access/routes"; import { createIntelligenceClient } from "./intelligence-client"; import type { OnboardingStore } from "./people/onboarding"; import type { PeopleStore } from "./people/store"; @@ -101,6 +104,14 @@ export const UPLOAD_BODY_LIMIT_BYTES = * The address is on the row rather than only the user id, because the id means nothing to a person * reading the trail a year later and the user row may be gone by then. */ +export type DeploymentToolCaller = (input: { + name: string; + args: Record; + botId: string; + actorId: string; + initiator?: AuditInitiator; +}) => Promise<{ text: string; isError: boolean } | null>; + async function recordPersonEvent( auditStore: AuditStore | undefined, context: { var: AppVariables }, @@ -272,6 +283,12 @@ export function createApp( * database has no door for this at all, not a locked one. */ attachmentDatabase?: Database, + /** Session-only broker for native owner-approved host folder access. */ + hostAccessBroker?: HostAccessBroker, + /** Fresh desktop-only bearer token for the native host worker poll/result channel. */ + desktopHostToken?: string, + /** Server-owned tools that are not MCP but use the same signed agent callback route. */ + deploymentToolCaller?: DeploymentToolCaller, ) { const app = new Hono<{ Variables: AppVariables }>(); @@ -1016,6 +1033,23 @@ export function createApp( ); } + if (hostAccessBroker) { + app.route( + "/api/host-access", + createHostAccessRoutes({ + broker: hostAccessBroker, + desktopToken: desktopHostToken, + requireUser, + canUseBot, + auditStore, + botName: agentProfileStore + ? async (botId, actor) => + (await agentProfileStore.get(actor, botId))?.name ?? null + : undefined, + }), + ); + } + if (agentProfileStore) { app.route( "/api/agents", @@ -1216,7 +1250,7 @@ export function createApp( * no person behind it. Absent secret means the route does not exist: a deployment that has not * configured this refuses rather than accepting anybody who can reach the port. */ - if (pluginStore) { + if (pluginStore || deploymentToolCaller) { const legacyToken = config.agentToolToken ?? ""; app.post("/api/agent-tools/call", async (context) => { /* @@ -1284,6 +1318,22 @@ export function createApp( } try { + const deploymentResult = await deploymentToolCaller?.({ + name: body.name, + args: body.args ?? {}, + botId: verdict.botId, + actorId: verdict.actorId, + initiator: verdict.initiator, + }); + if (deploymentResult) return context.json(deploymentResult); + + if (!pluginStore) { + return context.json({ + text: `${REFUSAL_MARKER} That tool is not registered in this deployment.`, + isError: true, + }); + } + const result = await pluginStore.callTool({ // The model is offered `mcp__server__tool`; the store speaks `server/tool`. ref: body.name.replace(/^mcp__/, "").replace("__", "/"), @@ -1291,6 +1341,7 @@ export function createApp( botId: verdict.botId, // From the assertion, never the body: this is the name the audit row will carry. actorId: verdict.actorId, + ...(verdict.initiator ? { initiator: verdict.initiator } : {}), }); return context.json({ text: result.text, isError: result.isError }); } catch (error) { diff --git a/server/src/host-access/broker.ts b/server/src/host-access/broker.ts new file mode 100644 index 000000000..b7a17b8e6 --- /dev/null +++ b/server/src/host-access/broker.ts @@ -0,0 +1,349 @@ +import { randomUUID } from "node:crypto"; +import { + HOST_ACCESS_DESKTOP_LEASE_MS, + type HostAccessDesktopOperation, + type HostAccessDesktopPollResponse, + type HostAccessDesktopResult, + type HostAccessGrant, + type HostAccessStatus, +} from "./schema"; + +export class HostAccessRefusedError extends Error { + constructor(message: string) { + super(message); + this.name = "HostAccessRefusedError"; + } +} + +type TimerHandle = ReturnType; + +type OperationState = { + operation: HostAccessDesktopOperation; + actorId: string; + botId: string; + grantId?: string; + leasedUntil: number | null; + expiresAt: number | null; + expiryTimer: TimerHandle | null; + resolve: (value: unknown) => void; + reject: (error: Error) => void; + settled: boolean; +}; + +type HostAccessBrokerOptions = { + desktopLeaseMs?: number; + operationTtlMs?: number; +}; + +export type HostAccessBroker = ReturnType; + +export function createHostAccessBroker( + now: () => number = Date.now, + options: HostAccessBrokerOptions = {}, +) { + const desktopLeaseMs = options.desktopLeaseMs ?? HOST_ACCESS_DESKTOP_LEASE_MS; + const operationTtlMs = options.operationTtlMs ?? 120_000; + const grants = new Map(); + const operations = new Map(); + let desktopConnectedUntil: number | null = null; + let desktopLeaseTimer: TimerHandle | null = null; + + function unrefTimer(timer: TimerHandle) { + if (typeof timer === "object" && "unref" in timer) { + (timer as { unref: () => void }).unref(); + } + } + + function publicGrant(grant: HostAccessGrant): HostAccessGrant { + return { ...grant }; + } + + function queueCancelFor(state: OperationState) { + const cancelOperation: HostAccessDesktopOperation = { + operationId: randomUUID(), + targetOperationId: state.operation.operationId, + kind: "cancel", + botId: state.botId, + actorId: state.actorId, + ...(state.grantId ? { grantId: state.grantId } : {}), + }; + operations.set(cancelOperation.operationId, { + operation: cancelOperation, + actorId: state.actorId, + botId: state.botId, + grantId: state.grantId, + leasedUntil: null, + expiresAt: null, + expiryTimer: null, + resolve: () => {}, + reject: () => {}, + settled: false, + }); + } + + function failOperation(state: OperationState, reason: string) { + if (state.settled) return; + state.settled = true; + if (state.expiryTimer) clearTimeout(state.expiryTimer); + operations.delete(state.operation.operationId); + if (state.operation.kind !== "cancel" && state.operation.kind !== "stop") { + queueCancelFor(state); + } + state.reject(new HostAccessRefusedError(reason)); + } + + function expireDesktopLeaseIfNeeded() { + if (desktopConnectedUntil === null || desktopConnectedUntil > now()) return; + if (desktopLeaseTimer) { + clearTimeout(desktopLeaseTimer); + desktopLeaseTimer = null; + } + desktopConnectedUntil = null; + for (const grant of grants.values()) { + grant.revoked = true; + } + const affected = [...operations.values()].filter( + (state) => + state.operation.kind !== "cancel" && state.operation.kind !== "stop", + ); + for (const state of affected) { + failOperation( + state, + "The native host worker disconnected before the operation finished.", + ); + } + } + + function scheduleDesktopLeaseExpiry() { + if (desktopLeaseTimer) clearTimeout(desktopLeaseTimer); + desktopLeaseTimer = setTimeout(() => { + expireDesktopLeaseIfNeeded(); + }, desktopLeaseMs); + unrefTimer(desktopLeaseTimer); + } + + function pendingOperations(actorId?: string): HostAccessDesktopOperation[] { + return [...operations.values()] + .filter((state) => !state.settled) + .filter((state) => !actorId || state.actorId === actorId) + .map((state) => ({ ...state.operation })); + } + + function enqueue(operation: HostAccessDesktopOperation): Promise { + const expiresAt = + operation.kind === "cancel" || operation.kind === "stop" + ? null + : now() + operationTtlMs; + const operationWithExpiry = + expiresAt === null ? operation : { ...operation, expiresAt }; + return new Promise((resolve, reject) => { + const state: OperationState = { + operation: operationWithExpiry, + actorId: operation.actorId, + botId: operation.botId, + grantId: operation.grantId, + leasedUntil: null, + expiresAt, + expiryTimer: null, + resolve: resolve as (value: unknown) => void, + reject, + settled: false, + }; + if (expiresAt !== null) { + state.expiryTimer = setTimeout(() => { + failOperation( + state, + "The native host approval expired before the operation finished.", + ); + }, operationTtlMs); + unrefTimer(state.expiryTimer); + } + operations.set(operation.operationId, state); + }); + } + + function requireGrant(input: { + grantId: string; + botId: string; + actorId: string; + }) { + const grant = grants.get(input.grantId); + if (!grant || grant.revoked) { + throw new HostAccessRefusedError( + "That folder grant is no longer available.", + ); + } + if (grant.botId !== input.botId || grant.actorId !== input.actorId) { + throw new HostAccessRefusedError( + "That folder was not granted to this Bot and person.", + ); + } + return grant; + } + + return { + requestFolderGrant(input: { + botId: string; + botName: string; + actorId: string; + writable?: boolean; + }): Promise { + expireDesktopLeaseIfNeeded(); + return enqueue({ + operationId: randomUUID(), + kind: "choose_folder", + botId: input.botId, + botName: input.botName, + actorId: input.actorId, + writable: input.writable === true, + }); + }, + + rememberGrant(grant: HostAccessGrant) { + expireDesktopLeaseIfNeeded(); + grants.set(grant.id, publicGrant(grant)); + }, + + callHost(input: { + kind: "list_files" | "read_file" | "write_file" | "run_command"; + botId: string; + actorId: string; + grantId: string; + relativePath?: string; + content?: string; + command?: string; + writable?: boolean; + }): Promise { + expireDesktopLeaseIfNeeded(); + try { + requireGrant({ + grantId: input.grantId, + botId: input.botId, + actorId: input.actorId, + }); + } catch (error) { + return Promise.reject(error); + } + return enqueue({ + operationId: randomUUID(), + kind: input.kind, + botId: input.botId, + actorId: input.actorId, + grantId: input.grantId, + ...(input.relativePath ? { relativePath: input.relativePath } : {}), + ...(input.content !== undefined ? { content: input.content } : {}), + ...(input.command ? { command: input.command } : {}), + ...(input.writable === true ? { writable: true } : {}), + }); + }, + + nextDesktopOperation(): HostAccessDesktopPollResponse | null { + expireDesktopLeaseIfNeeded(); + const current = now(); + desktopConnectedUntil = current + desktopLeaseMs; + scheduleDesktopLeaseExpiry(); + const available = [...operations.values()].find( + (state) => + !state.settled && + (state.leasedUntil === null || state.leasedUntil <= current), + ); + if (!available) return null; + available.leasedUntil = current + desktopLeaseMs; + return { + leaseMs: desktopLeaseMs, + operations: [{ ...available.operation }], + }; + }, + + resolveDesktopOperation(result: HostAccessDesktopResult) { + expireDesktopLeaseIfNeeded(); + const state = operations.get(result.operationId); + if (!state || state.settled) return; + state.settled = true; + if (state.expiryTimer) clearTimeout(state.expiryTimer); + operations.delete(result.operationId); + if (!result.ok) { + state.reject( + new HostAccessRefusedError( + result.error ?? "The native host operation failed.", + ), + ); + return; + } + if (state.operation.kind === "choose_folder") { + if (!result.grant) { + state.reject( + new HostAccessRefusedError( + "The native host did not return a folder grant.", + ), + ); + return; + } + const grant: HostAccessGrant = { + id: result.grant.grantId, + botId: state.botId, + actorId: state.actorId, + displayName: result.grant.displayName, + revoked: false, + }; + grants.set(grant.id, grant); + state.resolve(publicGrant(grant)); + return; + } + state.resolve(result.result ?? {}); + }, + + revokeGrant(grantId: string, actorId: string) { + expireDesktopLeaseIfNeeded(); + const grant = grants.get(grantId); + if (!grant || grant.actorId !== actorId) { + throw new HostAccessRefusedError( + "That folder grant is not available to revoke.", + ); + } + grant.revoked = true; + const affected = [...operations.values()].filter( + (state) => + state.grantId === grantId && state.operation.kind !== "cancel", + ); + for (const state of affected) { + failOperation( + state, + "That folder grant was revoked before the operation finished.", + ); + } + }, + + stop(actorId: string) { + expireDesktopLeaseIfNeeded(); + for (const grant of grants.values()) { + if (grant.actorId === actorId) grant.revoked = true; + } + const affected = [...operations.values()].filter( + (state) => + state.actorId === actorId && state.operation.kind !== "cancel", + ); + for (const state of affected) { + failOperation(state, "Host access was stopped."); + } + void enqueue({ + operationId: randomUUID(), + kind: "stop", + botId: "*", + actorId, + }); + }, + + statusFor(actorId: string): HostAccessStatus { + expireDesktopLeaseIfNeeded(); + return { + grants: [...grants.values()] + .filter((grant) => grant.actorId === actorId) + .map(publicGrant), + pending: pendingOperations(actorId), + connected: + desktopConnectedUntil !== null && desktopConnectedUntil > now(), + }; + }, + }; +} diff --git a/server/src/host-access/routes.ts b/server/src/host-access/routes.ts new file mode 100644 index 000000000..17ade209f --- /dev/null +++ b/server/src/host-access/routes.ts @@ -0,0 +1,165 @@ +import type { MiddlewareHandler } from "hono"; +import { Hono } from "hono"; +import { recordAuditEvent, type AuditStore } from "../audit"; +import type { AppVariables } from "../auth/guards"; +import { sameToken } from "../agents/callback-token"; +import type { BotAccessCheck } from "../agents/profile-policy"; +import { HostAccessRefusedError, type HostAccessBroker } from "./broker"; +import { + asHostAccessDesktopResult, + HOST_ACCESS_DESKTOP_LEASE_MS, +} from "./schema"; + +function bearerToken(request: Request): string | null { + const value = request.headers.get("authorization") ?? ""; + const [scheme, token] = value.split(/\s+/, 2); + return scheme?.toLowerCase() === "bearer" && token ? token : null; +} + +function desktopAuthorized(request: Request, token: string) { + const given = bearerToken(request); + return !!given && given.length === token.length && sameToken(given, token); +} + +async function audit( + auditStore: AuditStore | undefined, + input: { + actorUserId?: string; + targetId?: string; + change: string; + botId?: string; + }, +) { + if (!auditStore) return; + await recordAuditEvent(auditStore, { + eventType: "configuration.changed", + targetType: "host_access", + ...(input.targetId ? { targetId: input.targetId } : {}), + ...(input.actorUserId ? { actorUserId: input.actorUserId } : {}), + payload: input, + }); +} + +export function createHostAccessRoutes(options: { + broker: HostAccessBroker; + desktopToken?: string; + requireUser: MiddlewareHandler<{ Variables: AppVariables }>; + canUseBot: BotAccessCheck; + auditStore?: AuditStore; + botName?: ( + botId: string, + actor: AppVariables["actor"], + ) => Promise; +}) { + const routes = new Hono<{ Variables: AppVariables }>(); + const { broker, requireUser, canUseBot, auditStore } = options; + + const requireDesktop: MiddlewareHandler = async (context, next) => { + const token = options.desktopToken; + if (!token) + return context.json( + { error: "Desktop host access is not configured." }, + 503, + ); + if (!desktopAuthorized(context.req.raw, token)) { + return context.json( + { error: "Desktop host access authentication failed." }, + 401, + ); + } + await next(); + }; + + routes.get("/desktop/next", requireDesktop, (context) => { + const next = broker.nextDesktopOperation(); + return context.json( + next ?? { operations: [], leaseMs: HOST_ACCESS_DESKTOP_LEASE_MS }, + ); + }); + + routes.post("/desktop/result", requireDesktop, async (context) => { + const parsed = asHostAccessDesktopResult( + await context.req.json().catch(() => null), + ); + if (!parsed) + return context.json( + { error: "Send a valid desktop operation result." }, + 400, + ); + broker.resolveDesktopOperation(parsed); + return context.json({ ok: true }); + }); + + routes.get("/", requireUser, (context) => + context.json(broker.statusFor(context.var.actor.id)), + ); + + routes.post("/grants", requireUser, async (context) => { + const body = (await context.req.json().catch(() => null)) as { + botId?: unknown; + } | null; + if (typeof body?.botId !== "string" || !body.botId) { + return context.json({ error: "botId is required." }, 400); + } + const actor = context.var.actor; + if (!(await canUseBot(actor, body.botId))) { + return context.json({ error: "That Bot is not available to you." }, 404); + } + try { + const grant = await broker.requestFolderGrant({ + botId: body.botId, + botName: (await options.botName?.(body.botId, actor)) ?? body.botId, + actorId: actor.id, + }); + await audit(auditStore, { + actorUserId: actor.id, + targetId: grant.id, + change: "host_folder_granted", + botId: body.botId, + }); + return context.json({ grant }); + } catch (error) { + const message = + error instanceof Error ? error.message : "The folder was not granted."; + return context.json( + { error: message }, + error instanceof HostAccessRefusedError ? 409 : 500, + ); + } + }); + + routes.delete("/grants/:id", requireUser, async (context) => { + const actor = context.var.actor; + try { + broker.revokeGrant(context.req.param("id"), actor.id); + await audit(auditStore, { + actorUserId: actor.id, + targetId: context.req.param("id"), + change: "host_folder_revoked", + }); + return context.json({ ok: true }); + } catch (error) { + return context.json( + { + error: + error instanceof Error + ? error.message + : "The folder grant could not be revoked.", + }, + 404, + ); + } + }); + + routes.post("/stop", requireUser, async (context) => { + const actor = context.var.actor; + broker.stop(actor.id); + await audit(auditStore, { + actorUserId: actor.id, + change: "host_access_stopped", + }); + return context.json({ ok: true }); + }); + + return routes; +} diff --git a/server/src/host-access/schema.ts b/server/src/host-access/schema.ts new file mode 100644 index 000000000..4c92f2f1d --- /dev/null +++ b/server/src/host-access/schema.ts @@ -0,0 +1,96 @@ +export const HOST_ACCESS_DESKTOP_LEASE_MS = 15_000; + +export type HostAccessOperationKind = + | "choose_folder" + | "list_files" + | "read_file" + | "write_file" + | "run_command" + | "cancel" + | "stop"; + +export type HostAccessGrant = { + id: string; + botId: string; + actorId: string; + displayName: string; + revoked: boolean; +}; + +export type HostAccessDesktopOperation = { + operationId: string; + targetOperationId?: string; + kind: HostAccessOperationKind; + botId: string; + actorId: string; + grantId?: string; + botName?: string; + relativePath?: string; + content?: string; + command?: string; + writable?: boolean; + expiresAt?: number; +}; + +export type HostAccessDesktopResult = { + operationId: string; + ok: boolean; + result?: unknown; + error?: string; + grant?: { + grantId: string; + displayName: string; + writable?: boolean; + }; +}; + +export type HostAccessDesktopPollResponse = { + operations: HostAccessDesktopOperation[]; + leaseMs: number; +}; + +export type HostAccessStatus = { + grants: HostAccessGrant[]; + pending: HostAccessDesktopOperation[]; + connected: boolean; +}; + +export function asHostAccessDesktopResult( + value: unknown, +): HostAccessDesktopResult | null { + if (!value || typeof value !== "object") return null; + const result = value as Partial; + if (typeof result.operationId !== "string" || !result.operationId) + return null; + if (typeof result.ok !== "boolean") return null; + if (!result.ok) { + return { + operationId: result.operationId, + ok: false, + error: + typeof result.error === "string" && result.error.trim() + ? result.error + : "The native host operation failed.", + }; + } + const grant = result.grant; + return { + operationId: result.operationId, + ok: true, + ...("result" in result ? { result: result.result } : {}), + ...(grant && + typeof grant === "object" && + typeof grant.grantId === "string" && + grant.grantId && + typeof grant.displayName === "string" && + grant.displayName + ? { + grant: { + grantId: grant.grantId, + displayName: grant.displayName, + writable: grant.writable === true, + }, + } + : {}), + }; +} diff --git a/server/src/host-access/tools.ts b/server/src/host-access/tools.ts new file mode 100644 index 000000000..aa54bcde5 --- /dev/null +++ b/server/src/host-access/tools.ts @@ -0,0 +1,255 @@ +import { z } from "zod"; +import { + type AuditInitiator, + type AuditStore, + recordAuditEvent, +} from "../audit"; +import { REFUSAL_MARKER, type GrantedTool } from "../plugins/tools"; +import { HostAccessRefusedError, type HostAccessBroker } from "./broker"; + +const empty = z.object({}); + +const grantPath = z.object({ + grantId: z.string().min(1), + path: z.string().optional(), +}); + +const readFile = z.object({ + grantId: z.string().min(1), + path: z.string().min(1), +}); + +const writeFile = z.object({ + grantId: z.string().min(1), + path: z.string().min(1), + content: z.string(), +}); + +const runCommand = z.object({ + grantId: z.string().min(1), + path: z.string().optional(), + command: z.string().min(1), + writable: z.boolean().optional(), +}); + +function resultText(result: unknown): string { + if (typeof result === "string") return result; + return JSON.stringify(result ?? {}); +} + +async function auditHostAccess( + options: { + auditStore?: AuditStore; + initiator?: AuditInitiator; + actorId: string; + botId: string; + }, + input: { + operation: string; + outcome: "requested" | "succeeded" | "refused" | "failed" | "stopped"; + grantId?: string; + reason?: string; + }, +) { + if (!options.auditStore) return; + await recordAuditEvent(options.auditStore, { + eventType: "configuration.changed", + targetType: "host_access", + ...(input.grantId ? { targetId: input.grantId } : {}), + actorUserId: options.actorId, + ...(options.initiator ? { initiator: options.initiator } : {}), + payload: { + change: "host_access_tool_call", + operation: input.operation, + bot: options.botId, + actor: options.actorId, + ...(input.grantId ? { grant: input.grantId } : {}), + outcome: input.outcome, + ...(input.reason ? { reason: input.reason.slice(0, 240) } : {}), + }, + }); +} + +async function answer( + audit: { + auditStore?: AuditStore; + initiator?: AuditInitiator; + actorId: string; + botId: string; + operation: string; + grantId?: string; + }, + run: () => Promise, +): Promise { + await auditHostAccess(audit, { + operation: audit.operation, + grantId: audit.grantId, + outcome: "requested", + }); + try { + const result = await run(); + await auditHostAccess(audit, { + operation: audit.operation, + grantId: audit.grantId, + outcome: "succeeded", + }); + return resultText(result); + } catch (error) { + const reason = + error instanceof Error ? error.message : "That host operation failed."; + await auditHostAccess(audit, { + operation: audit.operation, + grantId: audit.grantId, + outcome: + error instanceof HostAccessRefusedError + ? reason.toLowerCase().includes("stop") + ? "stopped" + : "refused" + : "failed", + reason, + }); + if (error instanceof HostAccessRefusedError) { + return `${REFUSAL_MARKER} ${error.message}`; + } + return `That host operation could not be completed: ${reason}`; + } +} + +export function hostAccessTools(options: { + broker: HostAccessBroker; + botId: string; + actorId: string; + auditStore?: AuditStore; + initiator?: AuditInitiator; +}): GrantedTool[] { + const { broker, botId, actorId } = options; + const status = broker.statusFor(actorId); + const grants = status.grants.filter( + (grant) => grant.botId === botId && !grant.revoked, + ); + const audit = (operation: string, grantId?: string) => ({ + auditStore: options.auditStore, + initiator: options.initiator, + actorId, + botId, + operation, + ...(grantId ? { grantId } : {}), + }); + + const tools: GrantedTool[] = [ + { + name: "host_list_folders", + ref: "host-access/list_folders", + description: + "List the host folders this person has explicitly granted to this Bot, including the grantId to use with host file and command tools.", + parameters: empty, + execute: async () => { + if (grants.length === 0) { + await auditHostAccess(audit("list_folders"), { + operation: "list_folders", + outcome: "refused", + reason: "No host folders are granted to this Bot.", + }); + return `${REFUSAL_MARKER} No host folders are granted to this Bot.`; + } + await auditHostAccess(audit("list_folders"), { + operation: "list_folders", + outcome: "succeeded", + }); + return JSON.stringify({ + connected: status.connected, + folders: grants.map((grant) => ({ + grantId: grant.id, + displayName: grant.displayName, + })), + }); + }, + }, + ]; + + if (!status.connected || grants.length === 0) return tools; + + tools.push( + { + name: "host_list_files", + ref: "host-access/list_files", + description: + "List files in a host folder this person granted to this Bot. First call host_list_folders for grantId values.", + parameters: grantPath, + execute: async (args) => { + const parsed = grantPath.parse(args ?? {}); + return answer(audit("list_files", parsed.grantId), () => + broker.callHost({ + kind: "list_files", + botId, + actorId, + grantId: parsed.grantId, + relativePath: parsed.path ?? ".", + }), + ); + }, + }, + { + name: "host_read_file", + ref: "host-access/read_file", + description: + "Read one file from a granted host folder. Use only paths relative to that folder.", + parameters: readFile, + execute: async (args) => { + const parsed = readFile.parse(args ?? {}); + return answer(audit("read_file", parsed.grantId), () => + broker.callHost({ + kind: "read_file", + botId, + actorId, + grantId: parsed.grantId, + relativePath: parsed.path, + }), + ); + }, + }, + { + name: "host_write_file", + ref: "host-access/write_file", + description: + "Request an exact native-owner-confirmed file write in a granted host folder. The native app backs up the original and refuses unless the owner approves this operation.", + parameters: writeFile, + execute: async (args) => { + const parsed = writeFile.parse(args ?? {}); + return answer(audit("write_file", parsed.grantId), () => + broker.callHost({ + kind: "write_file", + botId, + actorId, + grantId: parsed.grantId, + relativePath: parsed.path, + content: parsed.content, + }), + ); + }, + }, + { + name: "host_run_command", + ref: "host-access/run_command", + description: + "Request an exact native-owner-confirmed shell command in the offline sandbox for a granted host folder. Set writable true only if the command needs write access; native still prompts before running.", + parameters: runCommand, + execute: async (args) => { + const parsed = runCommand.parse(args ?? {}); + return answer(audit("run_command", parsed.grantId), () => + broker.callHost({ + kind: "run_command", + botId, + actorId, + grantId: parsed.grantId, + relativePath: parsed.path, + command: parsed.command, + writable: parsed.writable === true, + }), + ); + }, + }, + ); + + return tools; +} diff --git a/server/src/index.ts b/server/src/index.ts index 707c1c29a..1c27115f1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -32,6 +32,8 @@ import { createAuth } from "./auth"; import { DEV_ACTOR, initializeDevActorUser } from "./auth/dev-actor"; import { createRoleRepository } from "./auth/guards"; import { createIdentityProviderStore } from "./auth/identity-provider-store"; +import { createHostAccessBroker } from "./host-access/broker"; +import { hostAccessTools } from "./host-access/tools"; import type { OpenBotRole } from "./auth/roles"; import { loadAttachmentForTurn, @@ -86,7 +88,7 @@ import { createPeopleStore } from "./people/store"; import { useRoutineTools } from "./plugins/builtin-routines"; import { redirectUriFor } from "./plugins/oauth"; import { createPluginStore } from "./plugins/store"; -import { grantedSkills, grantedTools } from "./plugins/tools"; +import { grantedSkills, grantedTools, REFUSAL_MARKER } from "./plugins/tools"; import { createTurnRunner } from "./routines/run-turn"; import { createRoutineRunner } from "./routines/runner"; import { createRoutineStore } from "./routines/store"; @@ -527,12 +529,24 @@ const resolveRuntimeModelApiKey = () => environment: process.env, }); -// Tools run here, not in the browser. Each one still executes through the plugin store, so the -// grant, the policy and the audit row are exactly where they were. +const hostAccessBroker = createHostAccessBroker(); + +// Tools run here, not in the browser. Each connector still executes through the plugin store, so the +// grant, the policy and the audit row are exactly where they were. Host-folder tools are also +// server-dispatched: the selected Bot and the signed-in owner are bound here, then the desktop worker +// receives only opaque grant ids and relative paths. const loadToolsForActor = (actorId: string, initiator: AuditInitiator = PERSON_INITIATOR) => - (botId: string) => - grantedTools({ store: pluginStore, botId, actorId, initiator }); + async (botId: string) => [ + ...(await grantedTools({ store: pluginStore, botId, actorId, initiator })), + ...hostAccessTools({ + broker: hostAccessBroker, + botId, + actorId, + auditStore: bootAuditStore, + initiator, + }), + ]; /** One person's standing instructions, for both the /api/settings routes and every run they start. */ const userInstructionsStore = createUserInstructionsStore(database); @@ -1222,6 +1236,28 @@ const app = createApp( // The same database every other store here is built from, so a channel's staged and sent files // live behind the same connection as the messages that reference them. database, + // Native host-folder sessions are session-only: grants disappear with this server process and the + // desktop worker must authenticate with a fresh token for this run. + hostAccessBroker, + process.env.OPENBOT_DESKTOP_HOST_TOKEN, + async ({ name, args, botId, actorId, initiator }) => { + if (!name.startsWith("host_")) return null; + const tool = hostAccessTools({ + broker: hostAccessBroker, + botId, + actorId, + auditStore: bootAuditStore, + ...(initiator ? { initiator } : {}), + }).find((candidate) => candidate.name === name); + if (!tool) { + return { + text: `${REFUSAL_MARKER} That host tool is not available for this Bot right now.`, + isError: true, + }; + } + const text = await tool.execute(args); + return { text, isError: text.startsWith(REFUSAL_MARKER) }; + }, ); /** diff --git a/server/tests/agent-callback-token.test.ts b/server/tests/agent-callback-token.test.ts index 26aa0e595..7874441e9 100644 --- a/server/tests/agent-callback-token.test.ts +++ b/server/tests/agent-callback-token.test.ts @@ -120,6 +120,7 @@ describe("who may call a tool back, and as whom", () => { ok: true, botId: AGENT_A, actorId: "visitor_9", + initiator: { kind: "person" }, }); }); @@ -172,6 +173,7 @@ describe("who may call a tool back, and as whom", () => { ok: true, botId: AGENT_A, actorId: "visitor_9", + initiator: { kind: "person" }, }); }); diff --git a/server/tests/host-access-broker.test.ts b/server/tests/host-access-broker.test.ts new file mode 100644 index 000000000..164273ec8 --- /dev/null +++ b/server/tests/host-access-broker.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, test } from "bun:test"; +import { + createHostAccessBroker, + HostAccessRefusedError, +} from "../src/host-access/broker"; +import type { HostAccessDesktopOperation } from "../src/host-access/schema"; + +describe("host access broker", () => { + test("a folder grant request queues a native picker operation and stores only the returned opaque grant", async () => { + const broker = createHostAccessBroker(); + + const pending = broker.requestFolderGrant({ + botId: "bot-a", + botName: "Research Bot", + actorId: "user-a", + }); + + const lease = broker.nextDesktopOperation(); + expect(lease?.operations[0]).toMatchObject({ + kind: "choose_folder", + botId: "bot-a", + botName: "Research Bot", + actorId: "user-a", + writable: false, + }); + + broker.resolveDesktopOperation({ + operationId: lease!.operations[0]!.operationId, + ok: true, + grant: { + grantId: "native-grant-1", + displayName: "Project", + writable: false, + }, + }); + + await expect(pending).resolves.toEqual({ + id: "native-grant-1", + botId: "bot-a", + actorId: "user-a", + displayName: "Project", + revoked: false, + }); + expect(broker.statusFor("user-a").grants).toEqual([ + { + id: "native-grant-1", + botId: "bot-a", + actorId: "user-a", + displayName: "Project", + revoked: false, + }, + ]); + }); + + test("host operations are tied to the selected Bot and actor", async () => { + const broker = createHostAccessBroker(); + broker.rememberGrant({ + id: "grant-1", + botId: "bot-a", + actorId: "user-a", + displayName: "Project", + revoked: false, + }); + + await expect( + broker.callHost({ + kind: "list_files", + botId: "bot-b", + actorId: "user-a", + grantId: "grant-1", + relativePath: ".", + }), + ).rejects.toBeInstanceOf(HostAccessRefusedError); + await expect( + broker.callHost({ + kind: "list_files", + botId: "bot-a", + actorId: "user-b", + grantId: "grant-1", + relativePath: ".", + }), + ).rejects.toBeInstanceOf(HostAccessRefusedError); + }); + + test("read-only folder grants still dispatch writes and commands for native per-operation confirmation", async () => { + const broker = createHostAccessBroker(); + broker.rememberGrant({ + id: "grant-1", + botId: "bot-a", + actorId: "user-a", + displayName: "Project", + revoked: false, + }); + + const write = broker.callHost({ + kind: "write_file", + botId: "bot-a", + actorId: "user-a", + grantId: "grant-1", + relativePath: "notes.txt", + content: "draft", + }); + const writeLease = broker.nextDesktopOperation(); + expect(writeLease?.operations[0]).toMatchObject({ + kind: "write_file", + botId: "bot-a", + actorId: "user-a", + grantId: "grant-1", + relativePath: "notes.txt", + content: "draft", + }); + broker.resolveDesktopOperation({ + operationId: writeLease!.operations[0]!.operationId, + ok: true, + result: { written: true }, + }); + await expect(write).resolves.toEqual({ written: true }); + + const command = broker.callHost({ + kind: "run_command", + botId: "bot-a", + actorId: "user-a", + grantId: "grant-1", + command: "pwd", + }); + const commandLease = broker.nextDesktopOperation(); + expect(commandLease?.operations[0]).toMatchObject({ + kind: "run_command", + command: "pwd", + }); + broker.resolveDesktopOperation({ + operationId: commandLease!.operations[0]!.operationId, + ok: true, + result: { stdout: "/workspace" }, + }); + await expect(command).resolves.toEqual({ stdout: "/workspace" }); + }); + + test("revoking a grant rejects queued and inflight calls and queues native cancellation", async () => { + const broker = createHostAccessBroker(); + broker.rememberGrant({ + id: "grant-1", + botId: "bot-a", + actorId: "user-a", + displayName: "Project", + revoked: false, + }); + + const queued = broker.callHost({ + kind: "list_files", + botId: "bot-a", + actorId: "user-a", + grantId: "grant-1", + relativePath: ".", + }); + const inflight = broker.callHost({ + kind: "read_file", + botId: "bot-a", + actorId: "user-a", + grantId: "grant-1", + relativePath: "notes.txt", + }); + const lease = broker.nextDesktopOperation(); + expect(lease?.operations[0]).toMatchObject({ + kind: "list_files", + } satisfies Partial); + + broker.revokeGrant("grant-1", "user-a"); + const rejected = await Promise.allSettled([queued, inflight]); + expect(rejected.map((entry) => entry.status)).toEqual([ + "rejected", + "rejected", + ]); + expect( + rejected.map((entry) => + entry.status === "rejected" && entry.reason instanceof Error + ? entry.reason.message + : "", + ), + ).toEqual([ + "That folder grant was revoked before the operation finished.", + "That folder grant was revoked before the operation finished.", + ]); + + const firstCancel = broker.nextDesktopOperation()?.operations[0]; + const secondCancel = broker.nextDesktopOperation()?.operations[0]; + expect([firstCancel?.kind, secondCancel?.kind]).toEqual([ + "cancel", + "cancel", + ]); + expect(broker.statusFor("user-a").grants[0]?.revoked).toBe(true); + }); + + test("stale or replayed desktop results cannot recreate grants or finish cancelled operations", async () => { + const broker = createHostAccessBroker(); + const pending = broker.requestFolderGrant({ + botId: "bot-a", + botName: "Bot A", + actorId: "user-a", + }); + const lease = broker.nextDesktopOperation(); + broker.resolveDesktopOperation({ + operationId: lease!.operations[0]!.operationId, + ok: false, + error: "cancelled", + }); + await expect(pending).rejects.toThrow("cancelled"); + + broker.resolveDesktopOperation({ + operationId: lease!.operations[0]!.operationId, + ok: true, + grant: { grantId: "grant-late", displayName: "Late" }, + }); + expect(broker.statusFor("user-a").grants).toEqual([]); + }); + + test("desktop lease expiry marks offline, revokes grants, rejects operations, and returns cancellation on reconnect", async () => { + let clock = 1_000; + const broker = createHostAccessBroker(() => clock); + broker.rememberGrant({ + id: "grant-1", + botId: "bot-a", + actorId: "user-a", + displayName: "Project", + revoked: false, + }); + const running = broker.callHost({ + kind: "read_file", + botId: "bot-a", + actorId: "user-a", + grantId: "grant-1", + relativePath: "notes.txt", + }); + expect(broker.nextDesktopOperation()?.operations[0]).toMatchObject({ + kind: "read_file", + }); + expect(broker.statusFor("user-a").connected).toBe(true); + + clock += 15_001; + expect(broker.statusFor("user-a").connected).toBe(false); + const disconnected = await Promise.allSettled([running]); + expect(disconnected[0]?.status).toBe("rejected"); + expect( + disconnected[0]?.status === "rejected" && + disconnected[0].reason instanceof Error + ? disconnected[0].reason.message + : "", + ).toContain("disconnected"); + expect(broker.statusFor("user-a").grants[0]?.revoked).toBe(true); + expect(broker.nextDesktopOperation()?.operations[0]).toMatchObject({ + kind: "cancel", + grantId: "grant-1", + }); + }); + + test("desktop lease expiry autonomously rejects inflight calls without another broker read", async () => { + const broker = createHostAccessBroker(Date.now, { + desktopLeaseMs: 10, + operationTtlMs: 1_000, + }); + broker.rememberGrant({ + id: "grant-1", + botId: "bot-a", + actorId: "user-a", + displayName: "Project", + revoked: false, + }); + const running = broker.callHost({ + kind: "read_file", + botId: "bot-a", + actorId: "user-a", + grantId: "grant-1", + relativePath: "notes.txt", + }); + expect(broker.nextDesktopOperation()?.operations[0]).toMatchObject({ + kind: "read_file", + }); + + await expect(running).rejects.toThrow("disconnected"); + expect(broker.nextDesktopOperation()?.operations[0]).toMatchObject({ + kind: "cancel", + grantId: "grant-1", + }); + }); + + test("host approval operations expire autonomously and cannot execute stale native results", async () => { + const broker = createHostAccessBroker(Date.now, { + desktopLeaseMs: 1_000, + operationTtlMs: 10, + }); + broker.rememberGrant({ + id: "grant-1", + botId: "bot-a", + actorId: "user-a", + displayName: "Project", + revoked: false, + }); + const pending = broker.callHost({ + kind: "write_file", + botId: "bot-a", + actorId: "user-a", + grantId: "grant-1", + relativePath: "notes.txt", + content: "draft", + }); + const lease = broker.nextDesktopOperation(); + const operation = lease!.operations[0]!; + expect(operation).toMatchObject({ + kind: "write_file", + grantId: "grant-1", + }); + expect(typeof operation.expiresAt).toBe("number"); + expect(operation.expiresAt! - Date.now()).toBeLessThanOrEqual(10); + + await expect(pending).rejects.toThrow("expired"); + broker.resolveDesktopOperation({ + operationId: operation.operationId, + ok: true, + result: { written: true }, + }); + expect(broker.nextDesktopOperation()?.operations[0]).toMatchObject({ + kind: "cancel", + grantId: "grant-1", + }); + }); + + test("Stop revokes all owner grants, cancels outstanding calls, and queues native stop", async () => { + const broker = createHostAccessBroker(); + broker.rememberGrant({ + id: "grant-1", + botId: "bot-a", + actorId: "user-a", + displayName: "Project", + revoked: false, + }); + const running = broker.callHost({ + kind: "read_file", + botId: "bot-a", + actorId: "user-a", + grantId: "grant-1", + relativePath: "notes.txt", + }); + + broker.stop("user-a"); + const stopped = await Promise.allSettled([running]); + expect(stopped[0]?.status).toBe("rejected"); + expect( + stopped[0]?.status === "rejected" && stopped[0].reason instanceof Error + ? stopped[0].reason.message + : "", + ).toContain("stopped"); + expect(broker.statusFor("user-a").grants[0]?.revoked).toBe(true); + expect(broker.nextDesktopOperation()?.operations[0]).toMatchObject({ + kind: "cancel", + }); + expect(broker.nextDesktopOperation()?.operations[0]).toMatchObject({ + kind: "stop", + actorId: "user-a", + }); + }); +}); diff --git a/server/tests/host-access-callback-route.test.ts b/server/tests/host-access-callback-route.test.ts new file mode 100644 index 000000000..8a82f724d --- /dev/null +++ b/server/tests/host-access-callback-route.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test"; +import { mintRunAssertion } from "../src/agents/callback-token"; +import { createApp } from "../src/app"; +import { loadConfig } from "../src/config"; +import { createHostAccessBroker } from "../src/host-access/broker"; +import { hostAccessTools } from "../src/host-access/tools"; +import { testEnvironment } from "./support/environment"; + +const KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + +function appWithHostDispatcher( + broker: ReturnType, +) { + return createApp( + loadConfig( + testEnvironment({ + AGENT_TOOL_TOKEN: "legacy-agent-tool-token", + KEY_ENCRYPTION_KEY: KEY, + }), + ), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + broker, + "desktop-token", + async ({ name, args, botId, actorId, initiator }) => { + if (!name.startsWith("host_")) return null; + const tool = hostAccessTools({ broker, botId, actorId, initiator }).find( + (candidate) => candidate.name === name, + ); + if (!tool) return { text: "not available", isError: true }; + const text = await tool.execute(args); + return { text, isError: false }; + }, + ); +} + +describe("host access tools on the signed agent callback route", () => { + test("dispatches host_read_file through the broker as the signed Bot and actor", async () => { + const broker = createHostAccessBroker(); + broker.rememberGrant({ + id: "grant-1", + botId: "bot-a", + actorId: "user-a", + displayName: "Project", + revoked: false, + }); + broker.nextDesktopOperation(); + const app = appWithHostDispatcher(broker); + + const responsePromise = app.request("/api/agent-tools/call", { + method: "POST", + headers: { + "content-type": "application/json", + "x-openbot-agent-token": "legacy-agent-tool-token", + }, + body: JSON.stringify({ + name: "host_read_file", + args: { grantId: "grant-1", path: "notes.txt" }, + // These must be ignored. The signed run below is the only identity source. + botId: "bot-b", + actorId: "user-b", + run: mintRunAssertion( + { botId: "bot-a", actorId: "user-a", runId: "run-a" }, + KEY, + ), + }), + }); + let lease = null as ReturnType; + for (let attempt = 0; attempt < 20; attempt++) { + lease = broker.nextDesktopOperation(); + if (lease?.operations[0]) break; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + expect(lease?.operations[0]).toMatchObject({ + kind: "read_file", + botId: "bot-a", + actorId: "user-a", + grantId: "grant-1", + relativePath: "notes.txt", + }); + broker.resolveDesktopOperation({ + operationId: lease!.operations[0]!.operationId, + ok: true, + result: { content: "hello" }, + }); + + const response = await responsePromise; + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + text: JSON.stringify({ content: "hello" }), + isError: false, + }); + }); +}); diff --git a/server/tests/host-access-routes.test.ts b/server/tests/host-access-routes.test.ts new file mode 100644 index 000000000..5da8177bb --- /dev/null +++ b/server/tests/host-access-routes.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test"; +import { Hono } from "hono"; +import type { AppVariables } from "../src/auth/guards"; +import { createHostAccessBroker } from "../src/host-access/broker"; +import { createHostAccessRoutes } from "../src/host-access/routes"; + +function appFor() { + const broker = createHostAccessBroker(); + const app = new Hono<{ Variables: AppVariables }>(); + app.route( + "/api/host-access", + createHostAccessRoutes({ + broker, + desktopToken: "desktop-token", + requireUser: async (context, next) => { + context.set("actor", { + id: "user-a", + email: "user@example.test", + role: "user", + }); + await next(); + }, + canUseBot: async (actor, botId) => + actor.id === "user-a" && botId === "bot-a", + botName: async () => "Readable Bot Name", + }), + ); + return { app, broker }; +} + +describe("host access routes", () => { + test("desktop polling is bearer-token authenticated and leases queued operations", async () => { + const { app, broker } = appFor(); + const grantRequest = broker.requestFolderGrant({ + botId: "bot-a", + botName: "Bot A", + actorId: "user-a", + }); + + expect((await app.request("/api/host-access/desktop/next")).status).toBe( + 401, + ); + + const response = await app.request("/api/host-access/desktop/next", { + headers: { authorization: "Bearer desktop-token" }, + }); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toMatchObject({ + operations: [ + { kind: "choose_folder", botId: "bot-a", actorId: "user-a" }, + ], + }); + broker.resolveDesktopOperation({ + operationId: body.operations[0].operationId, + ok: true, + grant: { grantId: "grant-1", displayName: "Project" }, + }); + await grantRequest; + }); + + test("status includes whether the desktop worker is connected", async () => { + const { app, broker } = appFor(); + expect(await (await app.request("/api/host-access")).json()).toMatchObject({ + connected: false, + }); + broker.nextDesktopOperation(); + expect(await (await app.request("/api/host-access")).json()).toMatchObject({ + connected: true, + }); + }); + + test("owner grant requests send the Bot's readable name to native", async () => { + const { app } = appFor(); + const request = app.request("/api/host-access/grants", { + method: "POST", + body: JSON.stringify({ botId: "bot-a" }), + headers: { "content-type": "application/json" }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const lease = await app.request("/api/host-access/desktop/next", { + headers: { authorization: "Bearer desktop-token" }, + }); + const body = await lease.json(); + expect(body.operations[0]).toMatchObject({ + kind: "choose_folder", + botName: "Readable Bot Name", + }); + await app.request("/api/host-access/desktop/result", { + method: "POST", + headers: { + authorization: "Bearer desktop-token", + "content-type": "application/json", + }, + body: JSON.stringify({ + operationId: body.operations[0].operationId, + ok: true, + grant: { grantId: "grant-1", displayName: "Project" }, + }), + }); + expect((await request).status).toBe(200); + }); + + test("owner routes do not let an admin-style caller pick for a Bot they cannot use", async () => { + const { app } = appFor(); + const denied = await app.request("/api/host-access/grants", { + method: "POST", + body: JSON.stringify({ botId: "bot-b" }), + headers: { "content-type": "application/json" }, + }); + expect(denied.status).toBe(404); + expect(await denied.json()).toEqual({ + error: "That Bot is not available to you.", + }); + }); +}); diff --git a/server/tests/host-access-tools.test.ts b/server/tests/host-access-tools.test.ts new file mode 100644 index 000000000..041ae1098 --- /dev/null +++ b/server/tests/host-access-tools.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, test } from "bun:test"; +import type { AuditEventInput } from "../src/audit"; +import { createHostAccessBroker } from "../src/host-access/broker"; +import { hostAccessTools } from "../src/host-access/tools"; + +function auditRecorder() { + const events: AuditEventInput[] = []; + return { + events, + auditStore: { + insert: async (event: AuditEventInput) => { + events.push(event); + }, + }, + }; +} + +describe("host access tools", () => { + test("offer folder discovery so the model does not need copied grant ids", async () => { + const broker = createHostAccessBroker(); + broker.rememberGrant({ + id: "grant-1", + botId: "bot-a", + actorId: "user-a", + displayName: "Project", + revoked: false, + }); + + const tools = hostAccessTools({ + broker, + botId: "bot-a", + actorId: "user-a", + }); + expect(tools.map((tool) => tool.name)).toEqual(["host_list_folders"]); + expect(await tools[0]!.execute({})).toContain("Project"); + expect(await tools[0]!.execute({})).toContain("grant-1"); + }); + + test("hides file and command operations while desktop is offline", () => { + const broker = createHostAccessBroker(); + broker.rememberGrant({ + id: "grant-1", + botId: "bot-a", + actorId: "user-a", + displayName: "Project", + revoked: false, + }); + + expect( + hostAccessTools({ broker, botId: "bot-a", actorId: "user-a" }).map( + (tool) => tool.name, + ), + ).toEqual(["host_list_folders"]); + }); + + test("server-side tools are bound to one selected Bot and actor once desktop is connected", async () => { + const broker = createHostAccessBroker(); + broker.rememberGrant({ + id: "grant-1", + botId: "bot-a", + actorId: "user-a", + displayName: "Project", + revoked: false, + }); + broker.nextDesktopOperation(); + + const tools = hostAccessTools({ + broker, + botId: "bot-a", + actorId: "user-a", + }); + expect(tools.map((tool) => tool.name)).toEqual([ + "host_list_folders", + "host_list_files", + "host_read_file", + "host_write_file", + "host_run_command", + ]); + + const answer = tools[1]!.execute({ grantId: "grant-1", path: "." }); + await Promise.resolve(); + const lease = broker.nextDesktopOperation(); + expect(lease?.operations[0]).toMatchObject({ + kind: "list_files", + botId: "bot-a", + actorId: "user-a", + grantId: "grant-1", + relativePath: ".", + }); + broker.resolveDesktopOperation({ + operationId: lease!.operations[0]!.operationId, + ok: true, + result: { entries: [{ name: "notes.txt", kind: "file" }] }, + }); + expect(await answer).toContain("notes.txt"); + + const otherBotTools = hostAccessTools({ + broker, + botId: "bot-b", + actorId: "user-a", + }); + expect(otherBotTools.map((tool) => tool.name)).toEqual([ + "host_list_folders", + ]); + expect(await otherBotTools[0]!.execute({})).not.toContain("grant-1"); + }); + + test("run commands carry working folder and writable intent to native and still require native approval", async () => { + const broker = createHostAccessBroker(); + broker.rememberGrant({ + id: "grant-1", + botId: "bot-a", + actorId: "user-a", + displayName: "Project", + revoked: false, + }); + broker.nextDesktopOperation(); + const commandTool = hostAccessTools({ + broker, + botId: "bot-a", + actorId: "user-a", + }).find((tool) => tool.name === "host_run_command")!; + + const answer = commandTool.execute({ + grantId: "grant-1", + path: "scripts", + command: "npm test", + writable: true, + }); + await Promise.resolve(); + const lease = broker.nextDesktopOperation(); + expect(lease?.operations[0]).toMatchObject({ + kind: "run_command", + relativePath: "scripts", + command: "npm test", + writable: true, + }); + broker.resolveDesktopOperation({ + operationId: lease!.operations[0]!.operationId, + ok: true, + result: { stdout: "ok" }, + }); + expect(await answer).toContain("ok"); + }); + + test("host operations audit request and outcome without content command or raw path", async () => { + const broker = createHostAccessBroker(); + broker.rememberGrant({ + id: "grant-1", + botId: "bot-a", + actorId: "user-a", + displayName: "Project", + revoked: false, + }); + broker.nextDesktopOperation(); + const { auditStore, events } = auditRecorder(); + const writeTool = hostAccessTools({ + broker, + botId: "bot-a", + actorId: "user-a", + auditStore, + initiator: { kind: "handoff", id: "run-1" }, + }).find((tool) => tool.name === "host_write_file")!; + + const answer = writeTool.execute({ + grantId: "grant-1", + path: "secret.txt", + content: "do not log", + }); + let lease = broker.nextDesktopOperation(); + for (let index = 0; index < 5 && !lease; index++) { + await Promise.resolve(); + lease = broker.nextDesktopOperation(); + } + broker.resolveDesktopOperation({ + operationId: lease!.operations[0]!.operationId, + ok: true, + result: { written: true }, + }); + await answer; + + expect(events.map((event) => event.payload.outcome)).toEqual([ + "requested", + "succeeded", + ]); + expect(JSON.stringify(events)).not.toContain("do not log"); + expect(JSON.stringify(events)).not.toContain("secret.txt"); + expect(events[0]).toMatchObject({ + eventType: "configuration.changed", + targetType: "host_access", + targetId: "grant-1", + actorUserId: "user-a", + initiator: { kind: "handoff", id: "run-1" }, + payload: { + change: "host_access_tool_call", + operation: "write_file", + bot: "bot-a", + grant: "grant-1", + outcome: "requested", + }, + }); + }); + + test("denied host operations are audited as refusals", async () => { + const broker = createHostAccessBroker(); + broker.rememberGrant({ + id: "grant-1", + botId: "bot-a", + actorId: "user-a", + displayName: "Project", + revoked: false, + }); + broker.nextDesktopOperation(); + const { auditStore, events } = auditRecorder(); + const tool = hostAccessTools({ + broker, + botId: "bot-b", + actorId: "user-a", + auditStore, + }).find((candidate) => candidate.name === "host_read_file"); + + expect(tool).toBeUndefined(); + const list = hostAccessTools({ + broker, + botId: "bot-b", + actorId: "user-a", + auditStore, + })[0]!; + const text = await list.execute({}); + expect(text).toContain("No host folders"); + expect(events.at(-1)?.payload).toMatchObject({ + operation: "list_folders", + outcome: "refused", + }); + }); +}); From df99087e2bdbe40cb26535536e41cf451e4096a3 Mon Sep 17 00:00:00 2001 From: David McKay Date: Sat, 12 Sep 2026 17:58:10 -0700 Subject: [PATCH 3/7] fix(desktop): preserve pending approvals across operation redelivery --- desktop/src-tauri/src/host_access.rs | 203 ++++++++++++++++++++++++++- 1 file changed, 200 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/host_access.rs b/desktop/src-tauri/src/host_access.rs index 40e75231c..f28907a3f 100644 --- a/desktop/src-tauri/src/host_access.rs +++ b/desktop/src-tauri/src/host_access.rs @@ -466,6 +466,12 @@ impl Inner { } let accepted = { let mut state = self.state.lock().expect("host state poisoned"); + // A native approval may outlast the server's delivery lease. The original + // worker still owns this operation and will post its result; redelivery + // must neither run it twice nor reject that original request. + if state.active_by_operation.contains(&operation.operation_id) { + return; + } if state.stopped || state.canceled_operations.contains(&operation.operation_id) { Err("Host access is stopped.".to_string()) } else if state.active_by_operation.len() >= MAX_ACTIVE_OPERATIONS { @@ -1553,6 +1559,121 @@ mod tests { use super::*; use crate::engine::{Address, Engine}; use crate::quiet::command as quiet_command; + use std::io::{BufRead, BufReader}; + use std::net::TcpListener; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::mpsc; + + struct BlockingFolderApprovalUi { + root: PathBuf, + calls: AtomicUsize, + called_tx: Mutex>, + release_rx: Mutex>, + } + + impl HostApprovalUi for BlockingFolderApprovalUi { + fn choose_folder(&self, _: &ChooseFolderPrompt) -> HostAccessResult { + self.calls.fetch_add(1, Ordering::SeqCst); + self.called_tx.lock().unwrap().send(()).unwrap(); + self.release_rx.lock().unwrap().recv().unwrap(); + Ok(ApprovedFolder { + root: self.root.clone(), + }) + } + + fn confirm_write(&self, _: &WritePrompt) -> HostAccessResult<()> { + unreachable!("choose_folder regression must not ask for write approval") + } + + fn confirm_command(&self, _: &CommandPrompt) -> HostAccessResult<()> { + unreachable!("choose_folder regression must not ask for command approval") + } + } + + struct ResultCollector { + base_url: String, + bodies: Arc>>, + thread: Option>, + } + + impl ResultCollector { + fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let bodies = Arc::new(Mutex::new(Vec::new())); + let worker_bodies = bodies.clone(); + let thread = thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(3); + loop { + match listener.accept() { + Ok((mut stream, _)) => { + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut content_length = 0_usize; + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let trimmed = line.trim_end(); + if trimmed.is_empty() { + break; + } + if let Some(value) = trimmed.strip_prefix("content-length: ") { + content_length = value.parse().unwrap(); + } else if let Some(value) = trimmed.strip_prefix("Content-Length: ") + { + content_length = value.parse().unwrap(); + } + } + let mut body = vec![0_u8; content_length]; + reader.read_exact(&mut body).unwrap(); + worker_bodies + .lock() + .unwrap() + .push(String::from_utf8(body).unwrap()); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}") + .unwrap(); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return; + } + thread::sleep(Duration::from_millis(10)); + } + Err(_) => return, + } + } + }); + Self { + base_url, + bodies, + thread: Some(thread), + } + } + + fn wait_for_posts(&self, count: usize) { + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline { + if self.bodies.lock().unwrap().len() >= count { + return; + } + thread::sleep(Duration::from_millis(10)); + } + panic!("timed out waiting for {count} host result posts"); + } + + fn bodies(&self) -> Vec { + self.bodies.lock().unwrap().clone() + } + } + + impl Drop for ResultCollector { + fn drop(&mut self) { + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } + } fn temp_root(name: &str) -> PathBuf { let path = std::env::temp_dir().join(format!( @@ -1668,6 +1789,80 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn redelivered_active_operation_does_not_reject_or_duplicate_native_approval() { + let root = temp_root("host-access-redelivery"); + let collector = ResultCollector::start(); + let (called_tx, called_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let approval = Arc::new(BlockingFolderApprovalUi { + root: root.clone(), + calls: AtomicUsize::new(0), + called_tx: Mutex::new(called_tx), + release_rx: Mutex::new(release_rx), + }); + let config = HostAccessConfig::new( + collector.base_url.clone(), + "token", + Address::new(Engine::Docker, None), + DEFAULT_IMAGE, + vec![], + ); + let inner = Arc::new(Inner { + config, + approval: approval.clone(), + instance_label: "test-instance".into(), + effect_lock: Mutex::new(()), + state: Mutex::new(State { + stopped: false, + thread: None, + grants: HashMap::new(), + running: HashMap::new(), + completed: HashSet::new(), + canceled_operations: HashSet::new(), + active_by_operation: HashSet::new(), + active_by_bot: HashSet::new(), + }), + }); + fn redelivered_operation() -> DesktopOperation { + DesktopOperation { + operation_id: "op-redelivered".into(), + kind: HostOperationKind::ChooseFolder, + bot_id: "bot-a".into(), + actor_id: "actor-a".into(), + bot_name: Some("Research Bot".into()), + grant_id: None, + target_operation_id: None, + relative_path: None, + content: None, + command: None, + writable: Some(false), + expires_at: None, + received_at_ms: now_millis(), + } + } + let client = Client::new(); + + inner.spawn_operation(client.clone(), redelivered_operation()); + called_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + inner.spawn_operation(client, redelivered_operation()); + thread::sleep(Duration::from_millis(50)); + + assert_eq!(approval.calls.load(Ordering::SeqCst), 1); + assert_eq!(collector.bodies(), Vec::::new()); + + release_tx.send(()).unwrap(); + collector.wait_for_posts(1); + thread::sleep(Duration::from_millis(50)); + let posts = collector.bodies(); + assert_eq!(posts.len(), 1); + let posted: serde_json::Value = serde_json::from_str(&posts[0]).unwrap(); + assert_eq!(posted["operationId"], "op-redelivered"); + assert_eq!(posted["ok"], true); + assert!(posted["grant"].is_object()); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn grant_binding_refuses_wrong_bot_or_actor() { let config = HostAccessConfig::new( @@ -1739,10 +1934,12 @@ mod tests { container_path(Path::new("nested/file.txt")), "/approved/nested/file.txt" ); - assert_eq!( - container_path(Path::new("nested\\file.txt")), + let expected = if cfg!(windows) { + "/approved/nested/file.txt" + } else { "/approved/nested\\file.txt" - ); + }; + assert_eq!(container_path(Path::new("nested\\file.txt")), expected); } #[test] From ec966cc225a9cb8b688e4a5cc76bb72b6a8c2097 Mon Sep 17 00:00:00 2001 From: David McKay Date: Sat, 12 Sep 2026 18:25:43 -0700 Subject: [PATCH 4/7] fix(desktop): remove telemetry notices from setup --- desktop/src/App.test.tsx | 4 +--- desktop/src/Ask.tsx | 4 +--- desktop/src/Welcome.tsx | 8 -------- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index fc1060d8f..3ca4f28b0 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -56,7 +56,7 @@ function setupEvents() { .map((call) => call.args); } -test("setup discloses default telemetry without a consent gate and deduplicates viewed steps", async () => { +test("setup records telemetry without a consent gate and deduplicates viewed steps", async () => { useRootConfigurationSetup("/tmp/private-setup-root", async () => emptyConfiguration(), ); @@ -74,8 +74,6 @@ test("setup discloses default telemetry without a consent gate and deduplicates ); }); - expect(view.getByText(/COPILOTKIT_TELEMETRY_DISABLED=1/)).toBeTruthy(); - expect(view.getByText(/DO_NOT_TRACK=1/)).toBeTruthy(); expect(view.queryByRole("checkbox")).toBeNull(); expect(view.queryByRole("switch")).toBeNull(); expect(setupEvents()).toEqual([ diff --git a/desktop/src/Ask.tsx b/desktop/src/Ask.tsx index 65a6e0eab..5f889c1f9 100644 --- a/desktop/src/Ask.tsx +++ b/desktop/src/Ask.tsx @@ -126,9 +126,7 @@ export function Ask({

- Your question goes to the AI provider you connected. Setup telemetry - records whether your Bot answered, without including your question or - its answer. + Your question goes to the AI provider you connected.

); diff --git a/desktop/src/Welcome.tsx b/desktop/src/Welcome.tsx index e0f6d8279..3037c0617 100644 --- a/desktop/src/Welcome.tsx +++ b/desktop/src/Welcome.tsx @@ -26,14 +26,6 @@ export function Welcome({ onStart }: { onStart: () => void }) { Takes a few minutes. OpenBot installs what it needs and asks you to sign in to the AI plan you already have.

-

- OpenBot sends setup and usage statistics to CopilotKit by default: a - random installation ID, setup steps, platform and engine details, - download performance, and Bot and connection choices. Telemetry does not - include prompts, files, credentials, email addresses, or server URLs. To - turn it off, launch OpenBot with COPILOTKIT_TELEMETRY_DISABLED=1 or - DO_NOT_TRACK=1. -

); } From 6e84dad185fdfeeefaabe86225f8b45f2be90134 Mon Sep 17 00:00:00 2001 From: David McKay Date: Sat, 12 Sep 2026 20:44:41 -0700 Subject: [PATCH 5/7] fix(desktop): route Windows Podman tools to the host --- desktop/src-tauri/src/acquire.rs | 440 +++++++++++++++++++++++++++++-- 1 file changed, 420 insertions(+), 20 deletions(-) diff --git a/desktop/src-tauri/src/acquire.rs b/desktop/src-tauri/src/acquire.rs index ccb03cd94..8d74bd9dd 100644 --- a/desktop/src-tauri/src/acquire.rs +++ b/desktop/src-tauri/src/acquire.rs @@ -11,9 +11,11 @@ //! `install.rs`. use crate::quiet::said as command_said; +use std::net::Ipv4Addr; use std::path::Path; use serde::{Deserialize, Serialize}; +use serde_json::Value; use crate::engine::{Address, Engine}; @@ -77,6 +79,9 @@ impl StepOutcome { /// offered. pub const MACHINE: &str = "openbot"; +const USER_MODE_NETWORKING_FLAG: &str = "--user-mode-networking=true"; +const HOST_GATEWAY_CONFIG: &str = ".config/containers/containers.conf.d/90-openbot-host.conf"; + fn podman(args: &[&str]) -> Result { podman_with(args, || { crate::engine::tool(Engine::Podman).args(args).output() @@ -150,31 +155,181 @@ fn create_machine_with( Ok(false) => {} Err(error) => return StepOutcome::stopped(Step::CreateMachine, &error), } - match run(&[ - "machine", - "init", - MACHINE, - "--cpus", - &cpus.to_string(), - "--memory", - &memory_mib.to_string(), - "--disk-size", - &disk_gib.to_string(), - ]) { + let args = create_machine_args(cpus, memory_mib, disk_gib, cfg!(target_os = "windows")); + let refs = args.iter().map(String::as_str).collect::>(); + match run(&refs) { Ok(_) => StepOutcome::went(Step::CreateMachine, format!("{MACHINE} created.")), Err(error) => StepOutcome::stopped(Step::CreateMachine, &error), } } -pub fn start_machine() -> StepOutcome { - match podman(&["machine", "start", MACHINE]) { - Ok(_) => StepOutcome::went(Step::StartMachine, format!("{MACHINE} started.")), - Err(error) if error.contains("already running") => StepOutcome::went( - Step::StartMachine, - format!("{MACHINE} was already running."), - ), - Err(error) => StepOutcome::stopped(Step::StartMachine, &error), +fn create_machine_args( + cpus: u32, + memory_mib: u32, + disk_gib: u32, + target_is_windows: bool, +) -> Vec { + let mut args = vec![ + "machine".to_string(), + "init".to_string(), + MACHINE.to_string(), + "--cpus".to_string(), + cpus.to_string(), + "--memory".to_string(), + memory_mib.to_string(), + "--disk-size".to_string(), + disk_gib.to_string(), + ]; + if target_is_windows { + args.push(USER_MODE_NETWORKING_FLAG.to_string()); + } + args +} + +#[derive(Debug, PartialEq, Eq)] +struct MachineNetworking { + state: String, + user_mode: bool, +} + +fn prepare_user_mode_networking_before_start( + run: &mut impl FnMut(&[&str]) -> Result, + target_is_windows: bool, +) -> Result<(), String> { + if !target_is_windows { + return Ok(()); + } + + let listing = run(&["machine", "inspect", MACHINE])?; + let networking = owned_machine_networking(&listing)?; + if networking.state.eq_ignore_ascii_case("running") && !networking.user_mode { + return Err(format!( + "{MACHINE} is already running without Podman user-mode networking. Stop the OpenBot engine machine and start OpenBot again so host callbacks can be configured." + )); + } + if networking.state.eq_ignore_ascii_case("stopped") && !networking.user_mode { + run(&["machine", "set", USER_MODE_NETWORKING_FLAG, MACHINE])?; + } + Ok(()) +} + +fn owned_machine_networking(listing: &str) -> Result { + let machines: Vec = serde_json::from_str(listing) + .map_err(|error| format!("could not inspect {MACHINE} networking: {error}"))?; + let machine = machines + .iter() + .find(|machine| machine.get("Name").and_then(Value::as_str) == Some(MACHINE)) + .ok_or_else(|| format!("podman machine inspect did not return {MACHINE}"))?; + let state = machine + .get("State") + .and_then(Value::as_str) + .ok_or_else(|| format!("podman machine inspect did not include {MACHINE} state"))? + .to_string(); + let user_mode = machine + .get("UserModeNetworking") + .and_then(Value::as_bool) + .ok_or_else(|| { + format!("podman machine inspect did not include {MACHINE} user-mode networking") + })?; + Ok(MachineNetworking { state, user_mode }) +} + +fn configure_host_gateway_after_start( + run: &mut impl FnMut(&[&str]) -> Result, + target_is_windows: bool, +) -> Result<(), String> { + if !target_is_windows { + return Ok(()); } + + let resolved = run(&[ + "machine", + "ssh", + MACHINE, + "getent", + "ahostsv4", + "host.containers.internal", + ])?; + let ip = first_valid_host_gateway_ip(&resolved)?; + let script = host_gateway_config_script(ip); + run(&["machine", "ssh", MACHINE, &script]).map(|_| ()) +} + +fn configure_owned_windows_podman_for_compose( + address: &Address, + mut run: impl FnMut(&[&str]) -> Result, + target_is_windows: bool, +) -> Result<(), String> { + if !owned_windows_podman_address(address, target_is_windows) { + return Ok(()); + } + let listing = run(&["machine", "inspect", MACHINE])?; + let networking = owned_machine_networking(&listing)?; + if !networking.user_mode { + return Err(format!( + "{MACHINE} is running without Podman user-mode networking. Stop the OpenBot engine machine and start OpenBot again so host callbacks can be configured." + )); + } + configure_host_gateway_after_start(&mut run, true) +} + +fn owned_windows_podman_address(address: &Address, target_is_windows: bool) -> bool { + target_is_windows + && address.engine == Engine::Podman + && address.connection.as_deref() == Some(MACHINE) +} + +fn first_valid_host_gateway_ip(raw: &str) -> Result { + for field in raw.split_whitespace() { + if let Ok(ip) = field.parse::() { + return validate_host_gateway_ip(ip); + } + } + Err(format!( + "could not resolve host.containers.internal in the {MACHINE} VM as IPv4" + )) +} + +fn validate_host_gateway_ip(ip: Ipv4Addr) -> Result { + if ip.is_unspecified() || ip.is_loopback() { + return Err(format!( + "resolved host.containers.internal in the {MACHINE} VM to unusable address {ip}" + )); + } + Ok(ip) +} + +fn host_gateway_config_script(ip: Ipv4Addr) -> String { + format!( + "mkdir -p \"$HOME/.config/containers/containers.conf.d\" && \ + tmp=\"$HOME/{HOST_GATEWAY_CONFIG}.tmp\" && \ + cat > \"$tmp\" <<'EOF'\n[containers]\nhost_containers_internal_ip=\"{ip}\"\nEOF\n\ + mv \"$tmp\" \"$HOME/{HOST_GATEWAY_CONFIG}\"" + ) +} + +fn start_machine_with( + mut run: impl FnMut(&[&str]) -> Result, + target_is_windows: bool, +) -> StepOutcome { + if let Err(error) = prepare_user_mode_networking_before_start(&mut run, target_is_windows) { + return StepOutcome::stopped(Step::StartMachine, &error); + } + let started = match run(&["machine", "start", MACHINE]) { + Ok(_) => format!("{MACHINE} started."), + Err(error) if error.contains("already running") => { + format!("{MACHINE} was already running.") + } + Err(error) => return StepOutcome::stopped(Step::StartMachine, &error), + }; + if let Err(error) = configure_host_gateway_after_start(&mut run, target_is_windows) { + return StepOutcome::stopped(Step::StartMachine, &error); + } + StepOutcome::went(Step::StartMachine, started) +} + +pub fn start_machine() -> StepOutcome { + start_machine_with(podman, cfg!(target_os = "windows")) } /// Turn Podman's own words into an instruction, where we know one. @@ -219,6 +374,13 @@ pub fn health_gate(address: &Address) -> StepOutcome { .output(); match output { Ok(out) if out.status.success() && !out.stdout.is_empty() => { + if let Err(error) = configure_owned_windows_podman_for_compose( + address, + podman, + cfg!(target_os = "windows"), + ) { + return StepOutcome::stopped(Step::HealthGate, &error); + } // An engine that answers is not an engine that can raise the stack. Asked here, where // there is a sentence to put it in, rather than left to Compose to discover. if !address.composes() { @@ -334,6 +496,17 @@ mod tests { } } + fn machine_inspect(state: &str, user_mode: bool) -> String { + serde_json::json!([ + { + "Name": "openbot", + "State": state, + "UserModeNetworking": user_mode + } + ]) + .to_string() + } + #[test] #[cfg(unix)] fn failed_podman_commands_keep_status_stdout_and_stderr() { @@ -400,6 +573,14 @@ mod tests { assert!(result.ok, "{result:?}"); assert_eq!( captured, + create_machine_args(4, 8192, 64, cfg!(target_os = "windows")) + ); + } + + #[test] + fn windows_machine_init_enables_user_mode_networking() { + assert_eq!( + create_machine_args(4, 8192, 64, true), [ "machine", "init", @@ -409,11 +590,230 @@ mod tests { "--memory", "8192", "--disk-size", - "64" + "64", + "--user-mode-networking=true" + ] + ); + } + + #[test] + fn existing_stopped_windows_machine_is_configured_before_start() { + let mut calls = Vec::>::new(); + let inspect = machine_inspect("stopped", false); + + prepare_user_mode_networking_before_start( + &mut |args: &[&str]| { + calls.push(args.iter().map(|arg| (*arg).to_string()).collect()); + if args == ["machine", "inspect", "openbot"] { + return Ok(inspect.clone()); + } + Ok(String::new()) + }, + true, + ) + .expect("stopped owned machine should be configurable"); + + assert_eq!( + calls, + [ + vec!["machine", "inspect", "openbot"], + vec!["machine", "set", "--user-mode-networking=true", "openbot"] ] ); } + #[test] + fn running_windows_machine_without_user_mode_fails_loud() { + let mut calls = Vec::>::new(); + let inspect = machine_inspect("running", false); + + let error = prepare_user_mode_networking_before_start( + &mut |args| { + calls.push(args.iter().map(|arg| (*arg).to_string()).collect()); + Ok(inspect.clone()) + }, + true, + ) + .expect_err("running machines without user-mode networking must not be treated as fixed"); + + assert_eq!(calls, [vec!["machine", "inspect", "openbot"]]); + assert!( + error.contains("without Podman user-mode networking"), + "{error}" + ); + } + + #[test] + fn malformed_machine_inspect_fails_loud() { + let error = owned_machine_networking("not json") + .expect_err("new networking inspection must not ignore malformed output"); + + assert!( + error.contains("could not inspect openbot networking"), + "{error}" + ); + } + + #[test] + fn non_windows_start_does_not_probe_podman_machine_networking() { + let mut called = false; + + prepare_user_mode_networking_before_start( + &mut |_args| { + called = true; + Ok(String::new()) + }, + false, + ) + .expect("non-Windows should not run the Windows-only networking fix"); + + assert!(!called); + } + + #[test] + fn windows_start_writes_host_gateway_config_from_vm_resolution() { + let mut calls = Vec::>::new(); + let inspect = machine_inspect("stopped", false); + + let result = start_machine_with( + |args| { + calls.push(args.iter().map(|arg| (*arg).to_string()).collect()); + match args { + ["machine", "inspect", "openbot"] => Ok(inspect.clone()), + ["machine", "set", "--user-mode-networking=true", "openbot"] => { + Ok(String::new()) + } + ["machine", "start", "openbot"] => Ok(String::new()), + ["machine", "ssh", "openbot", "getent", "ahostsv4", "host.containers.internal"] => { + Ok("192.168.127.254 STREAM host.containers.internal\n".into()) + } + ["machine", "ssh", "openbot", script] + if script.contains("host_containers_internal_ip=\"192.168.127.254\"") => + { + Ok(String::new()) + } + _ => Err(format!("unexpected args: {args:?}")), + } + }, + true, + ); + + assert!(result.ok, "{result:?}"); + assert_eq!(result.said, "openbot started."); + assert_eq!( + calls, + vec![ + vec!["machine", "inspect", "openbot"] + .into_iter() + .map(str::to_string) + .collect::>(), + vec!["machine", "set", "--user-mode-networking=true", "openbot"] + .into_iter() + .map(str::to_string) + .collect::>(), + vec!["machine", "start", "openbot"] + .into_iter() + .map(str::to_string) + .collect::>(), + vec![ + "machine", + "ssh", + "openbot", + "getent", + "ahostsv4", + "host.containers.internal", + ] + .into_iter() + .map(str::to_string) + .collect::>(), + vec![ + "machine".to_string(), + "ssh".to_string(), + "openbot".to_string(), + host_gateway_config_script("192.168.127.254".parse().unwrap()), + ], + ] + ); + } + + #[test] + fn owned_windows_podman_health_gate_writes_host_gateway_config() { + let mut calls = Vec::>::new(); + let inspect = machine_inspect("running", true); + + configure_owned_windows_podman_for_compose( + &Address::new(Engine::Podman, Some("openbot".into())), + |args| { + calls.push(args.iter().map(|arg| (*arg).to_string()).collect()); + match args { + ["machine", "inspect", "openbot"] => Ok(inspect.clone()), + ["machine", "ssh", "openbot", "getent", "ahostsv4", "host.containers.internal"] => { + Ok("192.168.127.254 STREAM host.containers.internal\n".into()) + } + ["machine", "ssh", "openbot", script] + if script.contains("host_containers_internal_ip=\"192.168.127.254\"") => + { + Ok(String::new()) + } + _ => Err(format!("unexpected args: {args:?}")), + } + }, + true, + ) + .expect("owned Windows Podman health gate should prepare host-gateway config"); + + assert_eq!(calls.len(), 3); + } + + #[test] + fn borrowed_podman_machine_health_gate_is_not_reconfigured() { + let mut called = false; + + configure_owned_windows_podman_for_compose( + &Address::new(Engine::Podman, Some("somebody-else".into())), + |_args| { + called = true; + Ok(String::new()) + }, + true, + ) + .expect("borrowed Podman machines are outside OpenBot's provisioning scope"); + + assert!(!called); + } + + #[test] + fn loopback_host_gateway_resolution_is_rejected() { + let error = validate_host_gateway_ip("127.0.0.1".parse().unwrap()) + .expect_err("loopback cannot be the container route to the Windows host"); + + assert!(error.contains("unusable address"), "{error}"); + } + + #[test] + #[cfg(unix)] + fn joined_podman_ssh_write_command_creates_expected_host_config() { + let home = temp_root("openbot-host-gateway-home"); + std::fs::create_dir_all(&home).unwrap(); + let command = host_gateway_config_script("192.168.127.254".parse().unwrap()); + let joined_remote_command = [command.as_str()].join(" "); + + let status = std::process::Command::new("sh") + .arg("-c") + .arg(&joined_remote_command) + .env("HOME", &home) + .status() + .expect("execute joined remote command under sh"); + + assert!(status.success(), "joined command failed: {status}"); + let written = std::fs::read_to_string(home.join(HOST_GATEWAY_CONFIG)).unwrap(); + assert_eq!( + written, + "[containers]\nhost_containers_internal_ip=\"192.168.127.254\"\n" + ); + let _ = std::fs::remove_dir_all(home); + } + #[test] #[cfg(unix)] fn failed_health_gate_detail_keeps_stdout_as_well_as_stderr() { From 97cc3651e0ec2eff9dee8fa6fc75ab89ee53f7c4 Mon Sep 17 00:00:00 2001 From: David McKay Date: Sat, 12 Sep 2026 21:04:52 -0700 Subject: [PATCH 6/7] fix windows podman host callback route --- desktop/src-tauri/src/acquire.rs | 64 +++++++++++++++++++++++++------- desktop/src-tauri/src/main.rs | 1 + 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/desktop/src-tauri/src/acquire.rs b/desktop/src-tauri/src/acquire.rs index 8d74bd9dd..47e2f1337 100644 --- a/desktop/src-tauri/src/acquire.rs +++ b/desktop/src-tauri/src/acquire.rs @@ -273,6 +273,11 @@ fn configure_owned_windows_podman_for_compose( configure_host_gateway_after_start(&mut run, true) } +/// Run before Compose even when an existing engine skipped the install/start steps. +pub fn prepare_for_compose(address: &Address) -> Result<(), String> { + configure_owned_windows_podman_for_compose(address, podman, cfg!(target_os = "windows")) +} + fn owned_windows_podman_address(address: &Address, target_is_windows: bool) -> bool { target_is_windows && address.engine == Engine::Podman @@ -301,10 +306,17 @@ fn validate_host_gateway_ip(ip: Ipv4Addr) -> Result { fn host_gateway_config_script(ip: Ipv4Addr) -> String { format!( - "mkdir -p \"$HOME/.config/containers/containers.conf.d\" && \ - tmp=\"$HOME/{HOST_GATEWAY_CONFIG}.tmp\" && \ + "set -e; \ + mkdir -p \"$HOME/.config/containers/containers.conf.d\"; \ + target=\"$HOME/{HOST_GATEWAY_CONFIG}\"; \ + tmp=\"$target.tmp\"; \ cat > \"$tmp\" <<'EOF'\n[containers]\nhost_containers_internal_ip=\"{ip}\"\nEOF\n\ - mv \"$tmp\" \"$HOME/{HOST_GATEWAY_CONFIG}\"" + if [ -f \"$target\" ] && cmp -s \"$tmp\" \"$target\"; then \ + rm \"$tmp\"; \ + else \ + mv \"$tmp\" \"$target\"; \ + systemctl --user try-restart podman.service; \ + fi" ) } @@ -792,26 +804,52 @@ mod tests { #[test] #[cfg(unix)] - fn joined_podman_ssh_write_command_creates_expected_host_config() { - let home = temp_root("openbot-host-gateway-home"); + fn joined_podman_ssh_write_command_restarts_api_only_when_config_changes() { + let root = temp_root("openbot-host-gateway-home"); + let home = root.join("home"); + let bin = root.join("bin"); std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&bin).unwrap(); + let calls = root.join("systemctl-calls"); + let fake_systemctl = bin.join("systemctl"); + std::fs::write( + &fake_systemctl, + format!("#!/bin/sh\nprintf '%s\n' \"$*\" >> '{}'\n", calls.display()), + ) + .unwrap(); + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&fake_systemctl, std::fs::Permissions::from_mode(0o755)) + .unwrap(); + } let command = host_gateway_config_script("192.168.127.254".parse().unwrap()); let joined_remote_command = [command.as_str()].join(" "); + let path = format!( + "{}:{}", + bin.display(), + std::env::var("PATH").unwrap_or_default() + ); - let status = std::process::Command::new("sh") - .arg("-c") - .arg(&joined_remote_command) - .env("HOME", &home) - .status() - .expect("execute joined remote command under sh"); + for _ in 0..2 { + let status = std::process::Command::new("sh") + .arg("-c") + .arg(&joined_remote_command) + .env("HOME", &home) + .env("PATH", &path) + .status() + .expect("execute joined remote command under sh"); + + assert!(status.success(), "joined command failed: {status}"); + } - assert!(status.success(), "joined command failed: {status}"); let written = std::fs::read_to_string(home.join(HOST_GATEWAY_CONFIG)).unwrap(); assert_eq!( written, "[containers]\nhost_containers_internal_ip=\"192.168.127.254\"\n" ); - let _ = std::fs::remove_dir_all(home); + let systemctl_calls = std::fs::read_to_string(&calls).unwrap(); + assert_eq!(systemctl_calls, "--user try-restart podman.service\n"); + let _ = std::fs::remove_dir_all(root); } #[test] diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 4aa03c4a2..e6a2bdddb 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -906,6 +906,7 @@ async fn start_stack_inner( return Err(status.detail.into()); }; let found = found.pin()?; + acquire::prepare_for_compose(&found)?; // Checked here as well as in the health gate, because the gate only runs when an engine had to // be installed. A machine that already had Podman skips all of that and arrives at Compose, From 107a0667da09e55aa07ee51e1d7bf95f2da4c78e Mon Sep 17 00:00:00 2001 From: David McKay Date: Sat, 12 Sep 2026 22:35:10 -0700 Subject: [PATCH 7/7] fix: protect host credentials and pin Bun image bytes --- Dockerfile | 11 +- agent-computer/Dockerfile | 6 +- desktop/src-tauri/src/host_access.rs | 229 ++++++++++++++++++++++++++- tests/compose.test.ts | 25 +++ 4 files changed, 257 insertions(+), 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index 863c736af..614d0df0f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,23 +18,22 @@ # image. Keep this version matched to `agent-computer/package.json`: bump both or neither. FROM node:24.18.1-bookworm-slim AS node-toolchain +FROM oven/bun:1.3.14@sha256:e10577f0db68676a7024391c6e5cb4b879ebd17188ab750cf10024a6d700e5c4 AS bun-toolchain FROM ubuntu:24.04 AS base -# Bun is pinned. The installer takes whatever is newest otherwise, so the runtime drifts from the -# one the lockfile was resolved against and an image built next month is not the image built today. -ARG BUN_VERSION=1.3.14 +# The Bun image digest pins the amd64/arm64 release bytes, including if its tag changes. ARG PLAYWRIGHT_VERSION=1.62.1 -# Into /usr/local rather than /root/.bun, because the runtime stage runs as `pwuser` and cannot read -# root's home. Set before the install, or the installer has already chosen the wrong directory. +# Keep Bun and global installs readable by the runtime's unprivileged user. ENV BUN_INSTALL=/usr/local ENV PATH="/usr/local/bin:${PATH}" ENV DEBIAN_FRONTEND=noninteractive ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright COPY --from=node-toolchain /usr/local /usr/local +COPY --from=bun-toolchain /usr/local/bin/bun /usr/local/bin/bun RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl unzip xz-utils \ - && curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" \ + && ln -s bun /usr/local/bin/bunx \ && bunx --bun "playwright@${PLAYWRIGHT_VERSION}" install --with-deps chromium \ && rm -rf /root/.cache /tmp/* /var/lib/apt/lists/* \ && useradd --create-home --shell /bin/bash pwuser diff --git a/agent-computer/Dockerfile b/agent-computer/Dockerfile index 405b0ed89..d630b39f0 100644 --- a/agent-computer/Dockerfile +++ b/agent-computer/Dockerfile @@ -3,19 +3,21 @@ # The image tag and Playwright dependency must be pinned to the same exact version. Bump both or # neither. FROM node:24.18.1-bookworm-slim AS node-toolchain +FROM oven/bun:1.3.14@sha256:e10577f0db68676a7024391c6e5cb4b879ebd17188ab750cf10024a6d700e5c4 AS bun-toolchain FROM ubuntu:24.04 -ARG BUN_VERSION=1.3.14 +# The Bun image digest pins the amd64/arm64 release bytes, including if its tag changes. ARG PLAYWRIGHT_VERSION=1.62.1 ENV BUN_INSTALL=/usr/local ENV PATH="/usr/local/bin:${PATH}" ENV DEBIAN_FRONTEND=noninteractive ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright COPY --from=node-toolchain /usr/local /usr/local +COPY --from=bun-toolchain /usr/local/bin/bun /usr/local/bin/bun RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl unzip xz-utils \ - && curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" \ + && ln -s bun /usr/local/bin/bunx \ && bunx --bun "playwright@${PLAYWRIGHT_VERSION}" install --with-deps chromium \ && rm -rf /root/.cache /tmp/* /var/lib/apt/lists/* diff --git a/desktop/src-tauri/src/host_access.rs b/desktop/src-tauri/src/host_access.rs index f28907a3f..c74c43fb3 100644 --- a/desktop/src-tauri/src/host_access.rs +++ b/desktop/src-tauri/src/host_access.rs @@ -1273,13 +1273,19 @@ struct ForbiddenPaths { } fn validate_grant_root(path: &Path, configured_forbidden: &[PathBuf]) -> HostAccessResult { + validate_grant_root_with_forbidden(path, &forbidden_paths(configured_forbidden)) +} + +fn validate_grant_root_with_forbidden( + path: &Path, + forbidden: &ForbiddenPaths, +) -> HostAccessResult { let root = canonical(path)?; if root.parent().is_none() { return Err(HostAccessError::Denied( "The filesystem root cannot be granted.".into(), )); } - let forbidden = forbidden_paths(configured_forbidden); for denied in &forbidden.exact_or_ancestor_only { if &root == denied || path_contains(&root, denied) { return Err(HostAccessError::Denied( @@ -1298,16 +1304,29 @@ fn validate_grant_root(path: &Path, configured_forbidden: &[PathBuf]) -> HostAcc } fn forbidden_paths(configured: &[PathBuf]) -> ForbiddenPaths { + forbidden_paths_with_home(configured, home_dir().as_deref()) +} + +fn forbidden_paths_with_home(configured: &[PathBuf], home: Option<&Path>) -> ForbiddenPaths { let mut exact_or_ancestor_only = Vec::new(); let mut protected_subtrees = Vec::new(); protected_subtrees.extend(configured.iter().filter_map(|path| canonical(path).ok())); - if let Some(home) = home_dir().and_then(|path| canonical(&path).ok()) { + if let Some(home) = home.and_then(|path| canonical(path).ok()) { exact_or_ancestor_only.push(home.clone()); for relative in [ ".ssh", ".gnupg", ".aws", ".config/gcloud", + ".config/gh", + ".local/share/keyrings", + ".claude", + ".codex", + ".cargo/credentials", + ".cargo/credentials.toml", + ".netrc", + ".git-credentials", + ".npmrc", ".docker", ".kube", "Library/Application Support/OpenBot", @@ -1316,6 +1335,7 @@ fn forbidden_paths(configured: &[PathBuf]) -> ForbiddenPaths { "Library/Application Support/Firefox", "Library/Keychains", "AppData/Roaming/OpenBot", + "AppData/Roaming/GitHub CLI", "AppData/Local/Google/Chrome", "AppData/Local/BraveSoftware", "AppData/Roaming/Mozilla/Firefox", @@ -1331,21 +1351,46 @@ fn forbidden_paths(configured: &[PathBuf]) -> ForbiddenPaths { "/private/etc", "/etc", "/var/run", - "C:\\Windows", - "C:\\Program Files", - "C:\\Program Files (x86)", - "C:\\ProgramData", ] { if let Ok(path) = canonical(Path::new(path)) { protected_subtrees.push(path); } } + #[cfg(windows)] + protected_subtrees.extend( + windows_environment_paths(|name| std::env::var_os(name)) + .iter() + .filter_map(|path| canonical(path).ok()), + ); ForbiddenPaths { exact_or_ancestor_only, protected_subtrees, } } +#[cfg(any(windows, test))] +fn windows_environment_paths( + mut get_env: impl FnMut(&str) -> Option, +) -> Vec { + // Installations may relocate these folders; ProgramW6432 also covers the native + // Program Files directory when this process runs under WOW64. + let mut paths: Vec<_> = [ + "SystemRoot", + "ProgramFiles", + "ProgramFiles(x86)", + "ProgramW6432", + "ProgramData", + ] + .into_iter() + .filter_map(&mut get_env) + .map(PathBuf::from) + .collect(); + if let Some(app_data) = get_env("AppData") { + paths.push(PathBuf::from(app_data).join("GitHub CLI")); + } + paths +} + fn home_dir() -> Option { std::env::var_os("HOME").map(PathBuf::from).or({ #[cfg(windows)] @@ -1698,6 +1743,178 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn grant_root_rejects_default_credential_directories_and_overlapping_folders() { + let home = temp_root("host-access-credential-home"); + let forbidden = forbidden_paths_with_home(&[], Some(&home)); + let mut allowed_secrets = Vec::new(); + for relative in [ + ".config/gh", + ".local/share/keyrings", + ".claude", + ".codex", + "AppData/Roaming/GitHub CLI", + ] { + let directory = home.join(relative); + let child = directory.join("nested"); + fs::create_dir_all(&child).unwrap(); + for candidate in [directory.as_path(), &child, directory.parent().unwrap()] { + if validate_grant_root_with_forbidden(candidate, &forbidden).is_ok() { + allowed_secrets.push(candidate.to_path_buf()); + } + } + let sibling = home.join(format!("{relative}-project")); + fs::create_dir_all(&sibling).unwrap(); + assert!(validate_grant_root_with_forbidden(&sibling, &forbidden).is_ok()); + } + fs::remove_dir_all(home).unwrap(); + assert!( + allowed_secrets.is_empty(), + "credential folders allowed: {allowed_secrets:?}" + ); + } + + #[test] + fn grant_root_rejects_home_credential_files_but_allows_project_configuration() { + let home = temp_root("host-access-credential-files"); + let forbidden = forbidden_paths_with_home(&[], Some(&home)); + let mut allowed_secrets = Vec::new(); + for relative in [ + ".cargo/credentials", + ".cargo/credentials.toml", + ".netrc", + ".git-credentials", + ".npmrc", + ] { + let file = home.join(relative); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write(&file, "test credential").unwrap(); + for candidate in [file.as_path(), file.parent().unwrap()] { + if validate_grant_root_with_forbidden(candidate, &forbidden).is_ok() { + allowed_secrets.push(candidate.to_path_buf()); + } + } + } + for relative in ["projects/app", ".cargo/registry"] { + let project = home.join(relative); + fs::create_dir_all(&project).unwrap(); + fs::write( + project.join(".npmrc"), + "registry=https://registry.npmjs.org", + ) + .unwrap(); + assert!(validate_grant_root_with_forbidden(&project, &forbidden).is_ok()); + assert!(resolve_relative(&project, ".npmrc").is_ok()); + } + fs::remove_dir_all(home).unwrap(); + assert!( + allowed_secrets.is_empty(), + "credential files allowed: {allowed_secrets:?}" + ); + } + + #[test] + fn grant_root_rejects_relocated_windows_system_directories() { + let root = temp_root("host-access-windows-locations"); + let locations = [ + ("SystemRoot", root.join("relocated/Windows")), + ("ProgramFiles", root.join("relocated/Applications")), + ("ProgramFiles(x86)", root.join("relocated/Applications x86")), + ("ProgramW6432", root.join("relocated/Applications x64")), + ("ProgramData", root.join("relocated/Shared data")), + ]; + for (_, directory) in &locations { + fs::create_dir_all(directory.join("nested")).unwrap(); + } + let paths = windows_environment_paths(|name| { + locations + .iter() + .find(|(key, _)| *key == name) + .map(|(_, path)| path.join(".").into_os_string()) + }); + let forbidden = forbidden_paths_with_home(&paths, None); + let mut allowed_system_paths = Vec::new(); + for (_, directory) in &locations { + for candidate in [ + directory.clone(), + directory.join("nested"), + directory.parent().unwrap().to_path_buf(), + ] { + if validate_grant_root_with_forbidden(&candidate, &forbidden).is_ok() { + allowed_system_paths.push(candidate); + } + } + let sibling = directory.with_file_name(format!( + "{}-project", + directory.file_name().unwrap().to_string_lossy() + )); + fs::create_dir_all(&sibling).unwrap(); + assert!(validate_grant_root_with_forbidden(&sibling, &forbidden).is_ok()); + } + fs::remove_dir_all(root).unwrap(); + assert!( + allowed_system_paths.is_empty(), + "system folders allowed: {allowed_system_paths:?}" + ); + } + + #[test] + fn grant_root_rejects_github_cli_credentials_under_relocated_appdata() { + let root = temp_root("host-access-github-cli-appdata"); + let app_data = root.join("relocated/Roaming"); + let credentials = app_data.join("GitHub CLI"); + let child = credentials.join("nested"); + let sibling = app_data.join("GitHub CLI-project"); + fs::create_dir_all(&child).unwrap(); + fs::create_dir_all(&sibling).unwrap(); + let paths = windows_environment_paths(|name| { + (name == "AppData").then(|| app_data.clone().into_os_string()) + }); + let forbidden = forbidden_paths_with_home(&paths, None); + let allowed_secrets: Vec<_> = [&credentials, &child, &app_data] + .into_iter() + .filter(|path| validate_grant_root_with_forbidden(path, &forbidden).is_ok()) + .collect(); + assert!(validate_grant_root_with_forbidden(&sibling, &forbidden).is_ok()); + fs::remove_dir_all(root).unwrap(); + assert!( + allowed_secrets.is_empty(), + "GitHub CLI folders allowed: {allowed_secrets:?}" + ); + } + + #[cfg(windows)] + #[test] + fn grant_root_rejects_windows_system_directories_from_process_environment() { + for name in [ + "SystemRoot", + "ProgramFiles", + "ProgramFiles(x86)", + "ProgramW6432", + "ProgramData", + ] { + let Some(value) = std::env::var_os(name) else { + assert!( + matches!(name, "ProgramFiles(x86)" | "ProgramW6432"), + "missing {name}" + ); + continue; + }; + let path = PathBuf::from(value); + let canonical_path = canonical(&path).unwrap(); + assert!( + forbidden_paths(&[]) + .protected_subtrees + .contains(&canonical_path), + "system location missing: {name}" + ); + assert!( + validate_grant_root(&path, &[]).is_err(), + "system location allowed: {name}" + ); + } + } + #[test] fn relative_paths_cannot_escape_through_dotdot_or_symlink() { let root = temp_root("host-access-relative"); diff --git a/tests/compose.test.ts b/tests/compose.test.ts index 270f0475c..92d595629 100644 --- a/tests/compose.test.ts +++ b/tests/compose.test.ts @@ -464,6 +464,31 @@ test("builds the deployment image with Playwright's Chromium payload only", () = ); }); +test("takes Bun from the same immutable release in both computer images", () => { + const { packageManager } = JSON.parse( + readFileSync(join(import.meta.dir, "..", "package.json"), "utf8"), + ); + const bunVersion = packageManager.replace("bun@", ""); + const sources = []; + for (const dockerfile of [rootDockerfile(), agentComputerDockerfile()]) { + const source = dockerfile.match( + /^FROM (oven\/bun:\S+) AS bun-toolchain$/m, + )?.[1]; + expect(source).toMatch( + new RegExp( + `^oven/bun:${bunVersion.replaceAll(".", "\\.")}@sha256:[a-f0-9]{64}$`, + ), + ); + expect(dockerfile).toContain( + "COPY --from=bun-toolchain /usr/local/bin/bun /usr/local/bin/bun", + ); + expect(dockerfile).not.toContain("bun.sh/install"); + expect(dockerfile).not.toMatch(/\b(?:curl|wget)\b[^\n]*\|\s*(?:bash|sh)\b/); + sources.push(source); + } + expect(sources[0]).toBe(sources[1]); +}); + /** * Per-Bot egress reaches the processes that read it. *