Desktop Agent Tools
Status: Implemented (Stage 1+2, 2026-10-09).
client_file_*andclient_exec_*in the Facelift desktop app (Electron, “Vancetope”) — the Agent operates on the user’s machine like the Foot-CLI, with a Permission-Gate. Planning history and discarded alternatives:planning/desktop-agent-tools.md. Feature description (files, tests, operation):readme/desktop-agent-tools.md.
1. Purpose and Scope
The desktop app loads the same deployed web UI as the browser, but as a native program, it has access to the machine. The web UI detects the Electron bridge, registers the file/exec families under its Session — execution, including the Permission-Gate, runs in the Electron Main Process.
- No OS security boundary — the same boundary as foot-sandbox §1: The Gate intercepts mistakes and enforces user-in-the-loop confirmation; it is not a seatbelt.
- No Mobile feature — iPad/iPhone/Android do not get the bridge;
window.vanceDesktopToolsdoes not exist there. - No second sandbox model — behavior and policy schema are 1:1 foot-sandbox; new are location (Electron Main), storage (per Account, §5), and transport (Bridge/IPC).
2. Architecture
vance-face (deployed Web-UI, per Account-WebView)
DesktopAgentToolService
• registers client_file_* / client_exec_* Specs only if
'vanceDesktopTools' in window (Capability-Gate)
AND locally enabled (Enablement-Gate, §6)
• Handler = thin forwarder to the Bridge
│ contextBridge (preload-tools.ts, per Account-WebView, sandboxed)
▼ window.vanceDesktopTools.invoke(op, params)
┌────────────────────────────────────────────────────────────┐
│ Electron Main Process — trusted boundary │
│ • DesktopToolsService: Ops (fs/child_process), Job model │
│ • PermissionPolicy + PermissionGate: ALLOW/DENY/ASK, │
│ per-Account permissions.yaml │
│ • ASK → native dialog (25 s Timeout → DENY) │
│ • Workdir per Account (CWD for relative paths) │
└────────────────────────────────────────────────────────────┘
| Layer | Location | Responsibility |
|---|---|---|
| Tool Specification + LLM Visibility | vance-face (platform/desktopAgentTools.ts) |
ToolSpecs with parity to foot; registration via existing client-tool-register |
| Bridge | facelift-desktop preload-tools.ts |
Thin, immutable contract: invoke(op, params) → IPC. No policy logic in the Renderer |
| Execution | facelift-desktop src/tools/ |
Op implementation, Exec Job model |
| Gate | facelift-desktop src/tools/ |
Verdict + Ask dialog + Policy persistence — exclusively here |
The Renderer never decides. The Account-WebView loads remote content;
everything running there is considered potentially compromisable. The Renderer can
only request (invoke), the Main makes the verdict. The Ask dialog is a
native dialog.showMessageBox — not an HTML overlay. The Policy is not even
readable in the Renderer.
3. Connection Profile desktop
The desktop app is a third client type: Web-UI interface, local
capabilities. Canonical constant Profiles.DESKTOP = "desktop" in
vance-api (de.mhus.vance.api.ws.Profiles). Wire default remains web.
- The Web-UI sets
?profile=desktoponly if the Bridge exists — a browser tab remainsweb, even with a faked UA (isFaceliftDesktop()is UI cosmetic, not a capability; vance-facelift §5). - Session binding remains unchanged (client-protokoll-erweiterbarkeit
§2.1a): Desktop Sessions carry
profile=desktopfixed; cross-profile resume →409 Profile Mismatch. Intended — Recipes resolve Tool sets and Prompt appendices per Profile. - Recipes that want to arm local Tools carry a
profiles.desktopblock (recipes §6a);profiles.defaultstill applies as a catch-all.
4. Bridge Contract
The Preload (facelift-desktop/src/preload-tools.ts) is injected per
Account-WebView and exposed via contextBridge:
interface VanceDesktopTools {
version: string;
getContext(): { os: string; arch: string; shell: string;
cwd: string; sandboxEnabled: boolean; timezone: string };
invoke(op: string, params: Record<string, unknown>): Promise<
| { ok: true; result: Record<string, unknown> }
| { ok: false; error: string; denyReason?: string }>;
/** Read-only view of per-Account enablement (§6) — read at bind. */
toolsEnabled: { get(): Promise<boolean> };
}
- Registration rides on
client-tool-registerwithsource: "desktop"— the Account-WebView registers its Agent Tools and the Web-UI its UI-State Tools (source: "chat") over the same socket; server-side, a registration only replaces the declaredsourcegroups (websocket-protokoll §Client-Tool-Register). - Tool Packs: the Bridge extends the namespace with
pack_invokewith{pack, tool, args}(closed op grammar — Pack names never become part of the op syntax) andpacks.list()→{tools, errors}— the materialized Pack Tools plus the failed Packs with error (a failed server must be visible, not silently absent; §12). - IPC
desktop-tools:invoke(channeldesktop-tools:tools-enabled:getfor enablement); handlers in Main resolve the Account-WebView via itswebContents.id— the WebView instance is the isolation boundary (§8). opis a closed namespace; unknown Ops → error, no fallthrough. The contract is thin: new Tool parameters flow asparamswithout changing the Bridge code.- Sandboxed Preloads are self-contained: they may only
electronrequire; IPC channel literals are inline in the Preload, not in a shared module (risk of crash, see readme).
4.1 Ops and Foot Parity
| Op | Tool Name (Wire) |
|---|---|
file.read / file.write / file.edit / file.list / file.grep / file.find / file.count / file.head_tail |
client_file_* |
file.delete |
client_file_delete (separate Gate domain, foot-sandbox §3.1) |
exec.run / exec.status / exec.tail / exec.kill / exec.stat |
client_exec_* (Job model) |
Parity is the contract: Name, paramsSchema, response format, and
error image (incl. denyReason) correspond to the Foot-CLI; deviations are
a Spec bug. ToolSpec.allowedProfiles is ["desktop"]. The Brain
dispatches via the generic file_*/exec_* wrappers
(work-target) to the client_* names — whether a
Foot or the Desktop app is behind it is irrelevant to the dispatcher.
4.2 Exec Job Model
Brain-side, client-tool-invoke blocks for a maximum of 30 s. Therefore, the
same form as in the Foot applies: exec.run starts the process and returns
immediately with a jobId; status/tail/kill/stat address the Job. Jobs
live per Account in the Main Process; app termination kills running Jobs
(will-quit → killAll — no shell orphans).
5. Sandbox & Permissions
Behavioral Spec: foot-sandbox, complete. Matching
(Glob for paths, Regex for commands), path canonization with symlink and
.. resolution, default-deny floor (~/.ssh|aws|gnupg|vancetope), delete
as a separate domain, exact “always” rules — identical. Deltas:
| Aspect | Foot-CLI | Desktop App |
|---|---|---|
| Policy file | ~/.vancetope/permissions.yaml (one per machine) |
per Account: <userData>/tools/<accountId>/permissions.yaml — schema identical, storage not shared |
| CWD for relative paths/globs | --workdir |
Account’s Workdir (§9) |
ASK interface |
REPL menu, 25 s | native Electron dialog, 25 s, Timeout → DENY; not displayable → immediate DENY; one prompt at a time (Mutex) |
| “always” | writes rule + Reload | identical — into the asking Account’s per-Account file |
| Rule management | File by hand | in Account management (Shell): rule list with revocation per rule, add rule (Glob/Regex), reset to empty policy (§5.1) |
| Disable | --no-sandbox |
UI switch in Account management (§5.1), two-click confirmation + warning banner; sandbox: false still possible directly in config file |
5.1 Rule Management in the Shell (implemented)
The Account management (Edit Account view, Desktop-gated) shows the Account’s
Policy structured (paths/commands/delete × allow/deny), allows revoking
individual rules (incl. “Allow always”) via desktop-shell:policy:*
and resetting to the empty Policy (everything asks again). The
default-deny floor is only visibly readable, not removable. Channels are
Shell-only — the Account-WebView never reaches Policy management.
Master switch: “Disable sandbox” per Account in the same section —
first click arms (auto-disarm after 5 s), second confirms; off =
the Gate allows everything without prompting, including the Deny-Floor
(foot-sandbox §2). A red warning banner remains visible while disabled;
the Client-Context reports sandboxEnabled: false so the Agent’s
environment block remains honest.
Confinement (confineToWorkdir, default off): Paths outside the
Account Workdir are set to DENY instead of ASK — the Workdir becomes the
visible boundary of the Agent. Explicit rules still win outside
(deny remains deny, an explicit Allow applies), commands are not
confinement-capable (their rules are separate anyway), the delete domain
follows confinement. The check is a canonical prefix comparison
(realpath form of the Workdir) in Policy evaluation; a switch in
Account management (desktop-shell:confine:*).
Why per-Account instead of the central Foot file: A Facelift Account
can have a different trust level than the next (external Tenant vs.
own), and relative Globs (./**) resolve against the per-Account Workdir
— in a shared file, the same rules would mean something different per Account.
The Foot file remains untouched.
The Ask dialog names the Tool, subject (path/command), and Account — the user sees whose Session is asking (v1 shows the Account ID as a label).
6. Enablement + Shell Button
The function is off by default (fail-safe), enabled per Account. Two Gates, both must be green, otherwise the families are not registered:
- Capability-Gate —
'vanceDesktopTools' in window. - Enablement-Gate — local in the Desktop app, per Account.
Storage: Client, not Brain setting. The capability exists
exclusively on this machine — the server could not enforce a setting,
it would only see what the client registers. Enablement is
the same category as Sandbox Policy and Workdir: a property of the
Client per Account (a Facelift Account = a Brain login, so the same
granularity as a per-user setting, only locally decided and locally
enforceable; it does not travel to other machines). Storage:
<userData>/tools/tools-config.json, keyed by accountId, fields
toolsEnabled (default false) and workdir.
Enablement is read at Session-Bind/WS-Open (the moment
client-tool-register occurs) — a toggle takes effect from the next binding.
Enablement only unlocks registration; what the Agent is allowed to do in
individual cases is still decided solely by the Gate (§5).
6.1 Button in the Shell Renderer (dual-function)
The switch is in the Shell-Topbar (facelift-bridge, next to Home),
not in the hosted Web-UI. Reason: Ownership follows Enforcement
— enablement switches a Main-Process capability, its switch belongs in
the Client-UI; constant visibility on every route; Mobile never renders it
(isDesktop()-Gated).
- Background color = state: blue = on, gray = off. Click toggles
enablement of the active Account — write path only via the
Shell-Preload-API (
faceliftDesktop.toolsEnabledGet/Set({accountId})on thedesktop-shell:*channels). The Account-WebView Bridge is read-only (toolsEnabled.get()): Remote content can honor enablement, never change it. - Icon color = activity: The Main counts in-flight invokes per Account
and pushes 0↔n transitions as a
desktop-shell:activityevent ({accountId, active}) to the Shell. Idle: white (on) or gray (off); active: red + pulsating, tooltip “Agent is working on your machine…”. - No Kill-Switch in v1 — a running Turn ends normally; stopping via the existing Stop/Force-Stop surface (session-lifecycle).
Web-UI consequence: The Web-UI (vance-face) has no switch and
no activity indicator. The DesktopAgentToolService remains — it queries
enablement via the Bridge and decides registration at bind.
7. Client-Context via Query Parameter
Browser WS cannot use custom headers; the Desktop app provides the
ClientContext (os/arch/shell/cwd/sandboxEnabled/timezone, values from
getContext()) as a query parameter clientContext in the WS URL:
?profile=desktop&clientContext=<urlencoded JSON>. The server parses it
generically like the Foot header (handshake-final; missing/corrupt → ignored,
never breaks). Spoofable and harmless: The Context carries no authority —
whoever fakes windows only sabotages themselves. The header remains in parallel
(foot changes nothing); details websocket-protokoll §Handshake.
8. Isolation (Multi-Account)
- Bridge instance per Account-WebView — the Main knows the origin
of each
invoke; Account A can never trigger on behalf of B. - Client Config per Account — Policy including
sandboxflag and enablement per Account; one Account can disable the Tools without affecting others. - Workdir per Account as CWD — relative paths/globs of one Account never resolve in another’s tree.
- The Ask dialog names the Account — enablement belongs to the asking Account, not “the app”.
No process-level isolation in v1: All Ops run in the Main Process under the user identity, identical to the Foot. The boundary is the Policy.
Storage semantics of the Face in the app: The Account-WebView is the
“tab” — it dies with the app, the persistent partition (localStorage)
survives. What is in sessionStorage in the browser (per tab) is lost
with every app restart, while localStorage lives on — the
inverse asymmetry to the browser tab, which lives for days or is restored
with sessionStorage. The Face therefore chooses the carrier
based on Bridge presence (getDesktopTools()): the
Project memory (lastProject) is in the app’s localStorage
(one WebView per Account = exactly one “tab”; restart loss would be the
only thing the app forgets). Deliberately NOT moved: age keys
(ageKeyStore — die with the WebView, that is the
security design) and purely cosmetic states (panel tab,
View/Edit reset on restart is safe-by-default).
9. Workdir
One Workdir per Account, persisted in the Desktop Config, default ~
(deliberately broad — the Gate intercepts). Relative path parameters and relative
Policy Globs resolve against it. No per-Project Workdir in v1 — one
Account, one Workdir, like foot one JVM, one Workdir.
Configuration in Account management (Shell, Edit Account view):
Display of current Workdir + native directory dialog (“Choose…”,
workdirPick/workdirSet via the Shell-Preload-Bridge on
desktop-shell:workdir:*) + Confinement switch (§5.1). The Main validates (non-empty absolute path);
changes take effect with the next Invoke (the Config is read per
operation), clientContext.cwd with the next Bind. The
Account-WebView never reaches the configuration — same trust boundary as
for enablement (§6).
10. Threat Model (Summary)
| Risk | Countermeasure |
|---|---|
| Compromised Face deployment (main threat) | Default-off enablement, Capability-Gate, Main-Gate, Default-Deny-Floor, ASK with Account context |
| XSS in Renderer | Renderer only forwards, never decides (§2); dialog native |
Clipboard reading by remote content (clipboard-read is granted to WebView without prompt — SheetView paste needs it; Copy buttons need clipboard-sanitized-write) |
conscious tradeoff: a compromised Face deployment could read the clipboard; an OS prompt does not exist in Electron for this. Boundary: the grant applies exactly to the two clipboard permissions, everything else remains deny-by-default |
| UA-/Profile-/Context-Spoofing | No Tool without Bridge, no Exec without Gate; wrong Context sabotages the spoofer |
Overly broad Policy (allow: ["~/**"]) |
User decision as with foot; Default-Deny-Floor |
| Version skew Face ↔ App | Contract = Op names + Params; parity obligation (§4.1) |
| App termination during exec job | will-quit kills Jobs (§4.2) |
11. Deliberate v1 Gaps
- No
EXEC_FINISHED-Push — there is no Client→Brain push channel for Job terminations; the Agent pollsexec.status(termination of a running Turn is thus possible, just not event-driven). - Ask dialog shows Account ID as label, not display name.
12. Tool Packs (MCP Passthrough)
Implemented: Stage 1 (stdio), 2026-10-09. Planning history:
planning/desktop-tool-packs.md— also covers expansion stages (HTTP transport, REST Packs, management UI, Foot migration to common file).
The Desktop app is a second Pack host alongside the Foot: It starts
MCP servers locally and passes their Tools through as Client Tools — the same
machine role as the Foot (foot-tool-packs.md), the same
user contracts, different format (an mcp.json instead of individual Pack files)
and different runtime (TypeScript client @modelcontextprotocol/sdk).
Configuration — one file, all Clients (staged):
| Level | File | Trust |
|---|---|---|
| global | ~/.vancetope/mcp.json |
User’s own file — never asks |
| project | <workdir>/.vancetope/mcp.json |
comes with the Workdir (Repo!) — Trust dialog: once / always for this workdir / no; 25 s Timeout = rejection; “always” matches Name+Command and lands in <userData>/tools/<accountId>/trusted-packs.yaml (never in Workdir) |
Format: mcpServers map; Key = Pack name; fields command (stdio),
labels, enabled, disabledSubTools. Merge: Union by name,
Project wins. Selection (which Packs are active) separately in
<workdir>/.vancetope/config.yaml (toolPacks: block, like foot-tool-packs
§3: enabled/packs/disabledPacks, exact names, each field optional).
Tool interface: <pack>__<subtool> (Budget family = Pack name),
Labels = Pack labels + automatically mcp, mcp:<pack>, side-effect —
existing @label Recipes apply unchanged. Registration with the
Agent Tools in a client-tool-register (source: "desktop",
allowedProfiles: ["desktop"], deferred: true).
Invocation: pack_invoke {pack, tool, args} — v1 ungated (foot-
parity: loading the Pack is consent; the Trust-Gate covers materialization).
The Brain invocation timeout (30 s) applies unchanged.
Lifecycle: Materialization per Account (server start, initialize +
tools/list) lazy on first access; “Reload tool packs” in Account
management discards the cache and reconnects (mcp.json edits
without app restart). App termination kills server processes (killAll, like
Exec Jobs).
Failures are visible: packs.list() returns {tools, errors};
the Tool inventory (insights) and reload status show the cause of error
per Pack (e.g., spawn npx ENOENT).
Pack Home per Project (2026-10-12): Pack processes run with a
HOME that matches the currently selected Project in the Face — the
Face reports tenant + project with every effective selection change
(rememberProject hook, Bridge member packs.setProject, optional for
old builds); the Main resolves:
packHomeByProject["tenant/project"] ?? packHome ?? actual Home and
respawns servers only on actual Home change (Project change without
its own entry is free). Spawn: HOME + cwd to the Project Home,
but npm_config_cache/XDG_CACHE_HOME pinned to the actual user cache directories
— otherwise npx redownloads per Project. Config client-owned
in tools-config.json (packHome, packHomeByProject), UI in
EditAccount; Toggle-Tooltip in Shell shows Project + effective Home.
An orphaned/defective Home directory is recursively created before spawning
(deleted directories would otherwise cause a misleading
spawn <cmd> ENOENT). The Home is client-private, not secret: the
Agent sees it via client_file_list results anyway; it is
not actively hidden and not explicitly reported in clientContext.
Pack Credentials per Account (2026-10-12): Packs authenticate
via Env variables; mcp.json declares only references
("env": {"JIRA_TOKEN": "${secret:envtest.token}"} — literals for
harmless config allowed, plaintext tokens never: the file can be shared
or in the repo). The values are managed by the end-user with a click in
the app (EditAccount → “Pack credentials”: one line per reference with
origin Pack and set/missing chip, input in Shell modal — the
value only goes Renderer→Main, never comes back). Storage per Account in
<userData>/tools/<accountId>/*.pack-secrets.yaml, values
safeStorage-encrypted (macOS Keychain; unreadable without Keychain);
only the Main Process reads. Resolution at spawn time; a
missing Secret is a visible Pack error with name (“waiting
for credential ‘x’ — set it in the app”), not a silent 401 on call.
Setting/deleting triggers Respawn (like Pack Home). Syntax family deliberately
like the Foot (${env:NAME}) — ${secret:…} = Client-Account-Store;
{{secret:…}} (Brain syntax) remains taboo: PASSWORD settings never go
to Clients (settings-system.md).
PATH contract (macOS): An app launched from Finder inherits a
minimal PATH — the Main retrieves the login shell PATH (zsh -l,
mind quotings: the outer /bin/sh expands $PATH unquoted
itself) at startup, with fallback to common dev paths. Otherwise, npx/Homebrew
binaries for MCP spawns and exec_run would be invisible.