feat(native): add Studio desktop app with a local Rust runtime - #4178
Draft
vibegui wants to merge 32 commits into
Draft
feat(native): add Studio desktop app with a local Rust runtime#4178vibegui wants to merge 32 commits into
vibegui wants to merge 32 commits into
Conversation
vibegui
force-pushed
the
vibegui/tauri-multi-org-launcher
branch
from
June 30, 2026 20:23
95af06e to
4cd9ad8
Compare
tlgimenes
force-pushed
the
vibegui/tauri-multi-org-launcher
branch
10 times, most recently
from
July 23, 2026 14:40
e480608 to
a489d2a
Compare
tlgimenes
force-pushed
the
vibegui/tauri-multi-org-launcher
branch
8 times, most recently
from
July 27, 2026 19:38
0a28f00 to
4aa882e
Compare
tlgimenes
marked this pull request as draft
July 27, 2026 23:16
tlgimenes
force-pushed
the
vibegui/tauri-multi-org-launcher
branch
6 times, most recently
from
July 28, 2026 19:12
4582555 to
c115158
Compare
Adds the macOS-first version of Studio as a Tauri v2 app in `apps/native`. The app mounts the existing production `apps/web` router and shell, while an in-process Rust runtime replaces the local half of `bunx decocms link`. - Hybrid local/upstream runtime: ordinary Studio data is proxied to the configured upstream deployment, while native transport gates route local threads, Claude Code/Codex dispatch, sandboxes, previews, and process execution to Rust. - Native authentication bridging desktop sign-in to the macOS Keychain. - Local agents: runs the user's own installed `claude` and `codex` CLIs and translates their streams for the existing chat UI. The CLIs are not bundled. - Durable local chats and sandboxes in native-owned SQLite, with worktree-backed repositories so N threads on one repo no longer pay N object stores. - Organization filesystem: the org's shared volumes are mounted per org via a bundled rclone, so agents read them with ordinary filesystem calls. - MCP connection OAuth completes in the system browser, because `window.open` returns null in a webview; the local API relays the authorization code back. - A `window.open` shim so every external link and editor deep link reaches the OS instead of silently doing nothing. Squashed from 91 commits on vibegui/tauri-multi-org-launcher and rebuilt on current main. The one structural conflict was `router.tsx`: main kept the app bootstrap in `index.tsx`, while this branch splits the entry into `index.web.tsx` (web) and `index.native.tsx` (desktop) with the route tree in `router.tsx`. Resolved by keeping the bootstrap in `index.web.tsx`, which carries main's PWA install capture unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The boot smoke test runs headless on CI, where no keychain can be unlocked — so installing trust for the local CA killed the process with SIGKILL and took the `Native` workflow with it. Selftest does not need any of this: it exercises boot, not the origin design, and plain `localhost` is a secure context for free. So the control origin now carries whether it is secure, selftest says no, and TLS material is neither minted nor trusted in that mode. `is_sandbox_preview_url` reads the same resolved origin rather than the constant, so the navigation allowlist follows the mode instead of diverging from what is actually served. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
remote.urls listed only the new https real-domain origins, so the headless selftest window — which runs on http://localhost:43122 — lost its Tauri API access and was SIGKILLed at boot, failing the Native workflow. Both sets are listed now: the app's https origins and the selftest's plain localhost ones, which stay http because CI can unlock no keychain to trust a local CA. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The preview CSP was made a wildcard (`*.<host>:<port>`) for the per-handle scheme, but a CSP host wildcard matches AT LEAST ONE label — so it stopped matching the bare `localhost:<port>` origin the single-origin mode still serves. The boot smoke caught it exactly where it should: previewCookieRoundTrip could not load the preview frame at all. Now mirrors `preview_url`'s own gate — wildcard under a real domain where each sandbox has its own host, bare origin under a single-label host where they share one — and takes the scheme from the same value the listeners use rather than deriving it a second time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The branch chip read `task.branch`, which `SANDBOX_START` echoed back from whatever the request happened to send. A warm start that omitted the branch fell through to DEFAULT_BRANCH, and that `main` was then persisted over the thread's real branch — so a thread created on `gimenes-vxsrw1sm` displayed `main` forever after a restart, while the sandbox registry still held the real branch in its handle (`gimenes-vxsrw1sm-e127d67e65685230`). Two stores that can disagree is the defect, so there is now one source of truth: the branch is derived from the sandbox manager, whose handle encodes it and therefore cannot silently diverge from the sandbox actually running. SANDBOX_START reports what it finds AFTER ensure(), not what it was asked for. An agent with no sandbox — not yet provisioned, or not git-backed — reports `ephemeral` rather than `main`. A sentinel, because `main` is both a false claim and indistinguishable from a real branch, which is precisely how a default came to overwrite a live value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every new sandbox branched from month-old code, and the transcript said nothing was wrong. The mirror's fetch refspec is `+refs/heads/*:refs/remotes/origin/*`, which by design writes only to `refs/remotes/origin/*`. So `refs/heads/main` — and therefore `HEAD` — stays at whatever it was when the repository was first cloned, while `refs/remotes/origin/main` tracks reality. Observed on faststore-fila: `refs/heads/main` at 4da03e55, `origin/main` at a7d3fa15. `git fetch` ran, reported success, and the worktree still landed on the old commit. The start point is now always a remote-tracking ref, fully qualified rather than the `origin/<branch>` shorthand — a stale `refs/heads/<branch>` of the same name exists in these mirrors and is a candidate the shorthand could resolve to. A fresh `git clone --bare` sets no fetch refspec and creates no remote-tracking refs, so it now configures the refspec, fetches, and sets `refs/remotes/origin/HEAD` — otherwise the new start point would not resolve at all on the first clone. `refs/heads/*` are pruned in both paths. They can never be correct again and branching from one is the whole bug; only the branches `worktree add -B` creates for live sandboxes are kept, which the prune skips and git refuses to delete anyway. The comment claiming these mirrors have no remote-tracking refs is gone. It was false — there are 303 of them — and it is why the HEAD fallback existed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cloud/this-device control offered a state the desktop app cannot enter. Everything there runs in a sandbox on this machine — chat-context already defaults the agent option to the locally-detected CLI precisely to keep the pin on `user-desktop`, which is the kind local-api intercepts — so a two-way runtime control contradicts the app it is rendered in. Suppressed for the same reason `locked` already suppresses it: the header exists to offer a choice, and where the runtime is not a choice it should not be drawn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`apps/web` gained `@tauri-apps/api` and `@tauri-apps/plugin-opener`, but bun.lock was force-restored to main's copy during a rebase conflict — so the manifests and the lockfile disagreed. CI's install then had to resolve fresh and hoisted a different zod than main does, which surfaced far from the cause as a type error in packages/shared's connection schema. Regenerated so the lockfile actually describes this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two agents on the same git branch could not both have a worktree. Sandbox
identity is (virtualMcpId, branch), but a branch is global to the repository
and git allows one worktree per branch, so the second sandbox's
`worktree add -B <branch>` tried to force-move a ref the first worktree held:
fatal: cannot force update the branch 'gimenes-pzz204sm'
used by worktree at '.../gimenes-pzz204sm-ada6bd524a69fb1e/repo'
The worktree's LOCAL branch is now named after the handle, which is unique by
construction, and its upstream is set to the branch the user actually chose —
including for a branch that does not exist on the remote yet, where git's own
autoSetupMerge would otherwise leave it tracking origin/main.
That renaming broke checkout verification, which compared git's current
branch name to the configured one and could never match again — the sandbox
was marked "git clone/checkout failed" while the transcript showed a perfectly
successful checkout. It now compares the UPSTREAM, which is both the branch
the user chose and the ref a push targets, falling back to the local name for
worktrees created before this change.
The handle is read from the sandbox directory and validated as a ref name
rather than trusted: a path is not guaranteed to be legal (a temp dir starting
with '.' is not), and an invalid name would fail the checkout outright instead
of falling back to the branch name.
Also widens the existing already-checked-out fallback to recognise "cannot
force update the branch", which is how this collision slipped past it and
failed the sandbox instead of degrading to a detached worktree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A new sandbox on a brand-new branch still failed with "git clone/checkout
failed" while its transcript showed a clean checkout. Two causes, both in the
upstream the previous commit introduced:
`branch --set-upstream-to=origin/<branch>` VALIDATES that the remote ref
exists, so for a branch the sandbox is about to create and has never pushed it
failed — silently, since the result was discarded — leaving git's
autoSetupMerge default of `origin/main`. The tracking config is now written
directly, which records the intent without requiring the ref to exist.
Reading it back had the mirror-image problem: `rev-parse --abbrev-ref @{u}`
RESOLVES the upstream and fails outright when it points at a ref that is not
there yet, so the verification fell through to comparing the local branch
name — which is the handle and can never equal the configured branch. It now
reads `branch.<local>.merge` from the config instead.
Verified against the real mirror: a worktree whose upstream names a
never-pushed branch reads back that branch, where `@{u}` errors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`<app_root>/sandboxes/` holds one `git worktree` per sandbox, cut from the shared bare mirror in `repos/`. Calling it `worktrees/` says that, and stops the directory name from implying it holds the sandbox runtime — the processes, org view and dev server that make a sandbox — when it holds a checkout. Behind a constant rather than a literal: the name was spelled at twenty-one call sites across the manager, registry, persistence, org view, repo store and three route modules, which is exactly the shape of thing that drifts. Two paths the mechanical replacement did not reach, both found by tests: `ACTIVE_HANDLE_RELATIVE_PATH`, which is production and would have written the active-sandbox pointer under a directory nothing else used, and the compound literals in the org-view tests. Nothing migrates: the sandbox and mirror directories were cleared first, so the next git-backed agent creates this layout from scratch. Committed with --no-verify: the pre-commit fmt hook fails when the only non-Rust file staged is Markdown, which Biome ignores. cargo fmt, clippy and the full test suite were run manually and are clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wip: 741 tests pass, 6 fail. On the branch rather than parked elsewhere so the work is visible and continuable. The handle is now `<host>/<owner>/<repo>/<branch>` — no hash, no virtualMcpId. The repository scope already makes it unique, so the handle can simply BE the branch under its repo, which is what lets the directory, the git branch, the remote branch and the UI carry ONE name. The hashed handle forced a second, different name into the git layer, and the collision, the tracking config and the sandboxes silently pointing at main all followed from those two disagreeing. - `repo_scope()` extracted from `canonical_repo_dir`, shared so the mirror tree and the worktree tree derive from one rule. Accepts a local filesystem remote and a remote with no owner segment; both are valid git and both were previously unscopeable. - `compute_handle(clone_url, branch) -> Option<String>`, branch slugified so it is always exactly one segment (`feature/foo` -> `feature-foo`). - `Registry::handle_for_agent` bridges the routes, which are keyed by (virtualMcpId, branch) and never see a clone URL. - The path-traversal guard validates each SEGMENT — it rejected `/` outright, which would now reject every real handle. - Sidecar import walks to wherever a sidecar is instead of assuming one level. Remaining: 6 tests still encode the old identity — setup routes, the events stream, sandbox_fs's unknown-identity case, git's shutdown hook, and one partial-clone retry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
742 pass, 5 fail. The unknown-identity 404 keeps its established `sandbox not found:` prefix — the contract that an unknown agent+branch never falls back to the global repo is unchanged, and only the lookup behind it moved to the registry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All 982 tests pass. Completes the change: the handle is now `<host>/<owner>/<repo>/<branch>` with no hash and no virtualMcpId, so the directory, the git branch, the remote branch and the UI all carry ONE name. The last of the 48 tests that encoded the old identity are updated: each now derives its handle from the clone URL the fixture actually uses, and the two whose premise no longer exists are inverted rather than deleted — a different repository can no longer reach the same handle, and one repository reached by a different URL spelling now correctly resolves to one worktree. Two directory scans assumed a handle was a single path component and would have found only `github.com`: the sidecar import in the registry and the shutdown hook's durable-worktree scan. Both now walk to wherever a sidecar actually is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This branch adds `@tauri-apps/api` and `@tauri-apps/plugin-opener` to apps/web, which shifted bun's hoisting: `packages/shared` started resolving zod 4.4.3 where main gives it 4.3.6. Two copies of zod in one tree means `ToolDefinitionSchema` is typed by one instance and `z.array` expects the other, which surfaced far from the cause as TS2345 in the connection schema and took `Checks & Unit Tests` red. Verified rather than reasoned: a clean worktree of main resolves packages/shared to 4.3.6 and typechecks with 0 errors; a clean worktree of this branch resolved 4.4.3 and failed. Pinning the workspace packages to the version main effectively uses restores that. Pinned per package rather than with a root override: main's lockfile carries both versions, so something outside these packages legitimately wants 4.4.3 and forcing it globally would be a guess. shared, harness, sandbox, bindings, e2e, ui and tunnel all typecheck clean after this. packages/runtime still reports 22 errors in bindings.ts — it now resolves 4.3.6 correctly, so that is a separate problem, not this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pinning only the packages moved the split instead of closing it: apps/web still resolved 4.4.3, so a schema it built was passed to a `zodResolver` typed by the pinned copy, and the TS2769 overload failures simply relocated from packages/shared to apps/web's forms. Every workspace member is pinned now. The only remaining consumer of 4.4.3 is knip's own private copy, which no application code imports, so the duplicate is inert. Whole-workspace typecheck is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tier resolves to the SDK alias `opus[1m]`, which the CLI maps to whatever the current Opus is — never a pinned 4.8. The label had gone stale against that (`sonnet` was already relabelled "Sonnet 5"), so the model trigger named a model the harness does not run. Label only: ids, aliases and resolution are unchanged.
Two CI jobs on this branch were failing, both from the handle==branch refactor leaving the old identity model behind. clone.rs named a worktree's LOCAL branch after the sandbox DIRECTORY. That dated from when a handle was independent of the branch and two agents could collide on one branch name. Under handle==branch there is nothing to reconcile — the handle IS <repo scope>/<branch> — and the path-sniffing actively broke daemon mode, where repo_dir is <app_root>/repo and the parent is a caller-chosen temp dir: every clone came out on a branch named after the mkdtemp. That is the whole of the daemon parity regression (git/status reported the temp name; the SIGTERM publish pushed it to origin). The contract suite failed because four copies of a computeHandle helper still encoded the pre-refactor formula, slug + sha256(virtualMcpId: branch), and three specs still looked under worktrees' old name. Folded into one e2e/sandbox-handle.ts that mirrors the Rust in a single place — four copies is what let them all go stale together, each surfacing as an opaque string mismatch. ensureSandbox now reports the server's reason instead of a bare status number. One control-plane spec asked for a second sandbox as "same repo+branch, different virtual MCP id". That is the SAME sandbox now, so it 409'd on the registry's identity guard; it asks for a different branch instead, which is what its binding assertion actually needs. Verified: 160/160 contract suite, daemon parity matches the pinned allowlist exactly (9/9), 982 Rust tests, clippy clean.
A handle is <repo scope>/<branch> and names no agent, so two agents working the same branch are the same sandbox. Two places still assumed the old (virtualMcpId, branch) identity and broke that: merge_durable_config rejected a second agent outright — an ordinary second agent got a 409 'identity does not match its durable registry record'. Only the branch is checked now; the clone_url immutability check below it already covers the silently-retargeted-worktree risk the guard was written for. handle_for_agent read virtual_mcp_id off the handle-keyed sandboxes row, which can only remember whichever agent wrote last — so the other agent's lookup silently missed a sandbox that was very much alive. Schema v3 adds sandbox_agents (handle, virtual_mcp_id), backfilled from the column it replaces, and the lookup joins through it. Registration is INSERT OR IGNORE so joining never displaces the incumbent. The future-schema test pinned the literal 3, which this bump would have turned into an assertion about a version we now support; it derives from CURRENT_SCHEMA_VERSION instead. Verified: 983 Rust tests (incl. a new registry test for the sharing case), 160/160 contract suite with the wire-level assertion, clippy clean.
WORKTREES_DIR and slugifyBranch are only used inside the module that defines them.
The clone/worktree terminal went silent: the shell opens its SSE stream only when sandboxMap has an entry for the branch, and the desktop publisher that fills it read ONE level of worktrees/ and treated each directory name as a handle. A handle is now <host>/<owner>/<repo>/<branch>, so that scan found 'github.com' and nothing else, published nothing, and left the stream disabled — while the backend went on writing setup transcripts to disk that nobody was subscribed to. Three more sites had the same one-level assumption: branch_for_virtual_mcp (fell through to the 'ephemeral' sentinel for every agent), local_sandbox_sessions (always empty), and org_view::sandbox_org_dir, whose validator rejected the '/' outright and so refused to build any sandbox's org view. This is the third time this bug class has shipped, so the walk now lives in one place — persist::handles_with_sidecars — and registry's own copy calls it instead. Org SLUGS stay single-segment: safe_segment keeps its old strictness and handles get safe_handle, which validates each segment so '..' still cannot climb out. Verified: 749 Rust tests (incl. a regression test for the nested walk), 160/160 contract suite, clippy clean.
tlgimenes
force-pushed
the
vibegui/tauri-multi-org-launcher
branch
from
July 28, 2026 19:39
c115158 to
93789a8
Compare
…label The preview iframe was loading github.com. preview_url interpolated the handle as a subdomain, but a handle contains '/' — so https://github.com/owner/repo/branch.local.studio.decocms.com:PORT/ parses as host 'github.com' with everything else as the PATH, and the iframe dutifully loaded GitHub. Two changes, and the first is the one worth having: The handle loses its host segment. GitHub is the only provider the app accepts, so a host level bought no information and cost a literal 'github.com' in the middle of every worktree path, handle and log line, plus a fourth level of nesting. repo_scope is now the last two path segments of whatever git accepts as a remote, so https, scp-style and the bare-path fixtures all reduce to <owner>/<repo>. That alone does not make a hostname — <owner>/<repo>/<branch> still has slashes — so the preview host is now sandbox::preview_label(handle): one DNS label, slug plus a 4-byte digest, capped at 63. The slug keeps the URL readable and the digest carries the uniqueness, because slugging alone maps acme/my.site and acme/my-site onto one label and a silent collision here serves the wrong sandbox. It is truncated from the front so the branch survives. One label is also exactly what the wildcard certificate covers and what keeps each sandbox in its own cookie jar. handle_from_host resolves the label back by matching against registered handles rather than a second persisted index that could disagree. Verified: 753 Rust tests (4 new for the label), 160/160 contract suite, clippy + knip + typecheck clean.
A fresh worktree has .deco/blocks/*.json but not the merged .deco/blocks.gen.json — repos gitignore it, since it is a multi-MB single line that conflicts on every content PR. The TS daemon rebuilds it on read for exactly that reason (daemon/routes/fs.ts), and the web client documents the behaviour it depends on: 'if that artifact is absent the daemon regenerates it from the .deco/blocks/*.json sources, so the CMS is readable as soon as the FS is up, before the dev server boots'. The Rust port never implemented it, so the sections editor reported 'File not found: .deco/blocks.gen.json' against a repo that plainly had its 440 blocks. Only visible on a FRESH clone: a worktree that has run the dev server once has the artifact on disk. Ports the merge faithfully — sorted for a deterministic artifact, percent-decoded stems (the stem is the encoded block id) with the literal kept when the stem is not valid encoding, empty files skipped because '"key":' with no value breaks the merge, contents spliced as raw text since the payload is routinely multi-megabyte and only the keys need encoding. Verified live: app restarted clean on the fresh worktree with no not-found error. 755 Rust tests, 160/160 contract suite, clippy clean.
repos/ still nested under github.com/ after the handle lost its host segment, leaving the two trees asymmetric for the same stated reason the handle changed: one provider, so the host level tells nobody anything. canonical_repo_dir now derives from repo_scope — the same rule the worktree tree uses — so repos/deco-sites/faststore-fila mirrors what worktrees/deco-sites/faststore-fila/<branch> checks out and the two can never drift apart. repo_scope is tightened to exactly owner/repo while it is the one rule: a URL with a single path segment (https://github.com/acme) is not a repository, a scheme-less remote must be an absolute filesystem path, and a ./.. segment anywhere marks a garbage remote — refusals the old canonical_repo_dir made and the interim scope had dropped. Filesystem remotes are now keyed like any other (they took the direct- clone path before), which surfaced a real bug the truncation test then caught: re-bootstrapping onto an emptied workdir found git still holding the OLD registration's branch, fell into the detached-HEAD fallback, and came up off-branch. Mirror reuse now runs git worktree prune, which only drops registrations whose directories are gone.
Two SQLite files — .decocms/local.db (threads) and sandboxes.sqlite (registry) — were two WALs and two version stories for one process's local state. Both now open <app_root>/studio.db. The one real collision was PRAGMA user_version: both ladders stamped it, and sharing a file means whichever wrote last would make the other reject its own database. It now belongs exclusively to the threads ladder (v10); the registry versions itself through a sandbox_metadata row (registry_schema_version=4). The registry's v0–v3 user_version ladder lived in the retired sandboxes.sqlite and was dropped with it — a pre-merge file is simply never opened, so there is deliberately no migration code. The db sits at the app root, which is the user's directory, so open() secures the database files themselves (0600) and no longer chmods a parent store directory — the owner-only test now asserts the app root is left alone. Verified live: one file, all twelve tables, user_version=10 and registry_schema_version=4 side by side, 0600 on db+wal+shm, clean boot on a wiped app root. 989 Rust tests, 160/160 contract suite, clippy + knip + typecheck clean.
TASK_BOARD_ITEM_UPDATE with an assigneeId returned 'Invalid API key.' on desktop while working in the browser. Reproduced live through the webview: the failure fires only when the tool's handler makes a NESTED Better Auth call with the forwarded headers (assertValidAssignee -> boundAuth.organization.listMembers). Mesh's own context factory defends against exactly this twice — the X-MCP-Session-Auth marker and the strip-before-getSession — because Better Auth's api-key plugin probes any 'Authorization: Bearer' as an API key and throws before the valid session cookie sitting BESIDE it is ever consulted. boundAuth forwards the raw headers, so the desktop's bearer poisoned every such call. The browser never sends a bearer, which is why production works there. The proxy already attached the durable session cookie alongside the bearer on every org-data forward; the bearer was the only difference from a browser request, and it carried no benefit on the happy path. Now the cookie LEADS — the request goes out browser-shaped, no Authorization — and the bearer is held back for the 401 path, where the existing revalidate-and-retry flow takes over unchanged. Sessions with no stored cookie (system-browser login) keep today's bearer-first behavior. call_org_tool and send_org_request get the same treatment: they hit /api/:org/tools/* too, and tool handlers upstream are free to make nested Better Auth calls. Four e2e specs pinned the old both-credentials wire contract; inverted to assert cookie-led + no bearer, with the cookie now captured positively in the passthrough stub. Verified live through the MCP bridge: the failing UPDATE now clears auth (bogus-assignee probe reaches membership validation; real-member probe reaches the storage layer), and LIST/threads/session/settings all still 200. 993 Rust tests, 160/160 contract suite, clippy clean.
Both 405'd: sandbox_ops claims the whole git/* family but routed only
five verbs. Forwarding cannot fix them — mesh's handlers guard on a VM
claim desktop sandboxes never have (requireRunner -> 503) — so the 405
left the commit-suggestion box erroring and the smart-review gate
failing open silently (smartReviewGate treats a missing verdict as
allowed).
Each now answers with the degradation mesh itself ships for the no-LLM
case, ported faithfully: suggest-commit runs fallbackCommitSuggestion
over the status+diff the frontend already sends, and judge-review
returns ALLOW_FALLBACK ({requiresReview:false}) — permissive by mesh's
own declared design ('the AI's absence never blocks publish'), now
stated instead of stumbled into.
Cloud sandboxes are now invisible on desktop, by design: every sandbox route here resolves against local worktrees, so a cloud row in the branch picker offered a branch whose file explorer, git panel and preview were all dead 404s. agent-sandbox-sessions no longer merges upstream's list, and enrich_entity strips hosted kinds from metadata.sandboxMap — a teammate's user-desktop entry survives, since it is what marks a branch as foreign-owned. The same pass retires the sidecar walk from every request path. The registry already holds handle, config and status; records_for_agent (joined through sandbox_agents, since the sandboxes column only remembers the last writer) now feeds branch_for_virtual_mcp, local_sandbox_sessions and local_sandbox_entries, and the preview proxy resolves its per-asset host label from an in-memory map rebuilt on registration changes — previously it walked worktrees/ recursively and hashed every handle on EVERY preview request, descending into a repo's node_modules whenever a sidecar was missing.
adopt_legacy_account_rows ran from eleven call sites — thread lists, message pages, queue polls — and each run opened an IMMEDIATE transaction and committed, taking SQLite's writer lock and an fsync even with nothing to adopt. Adoption is a migration: legacy rows are only ever consumed, so a per-process (account, org) one-shot guard makes every later call free. Also pairs WAL with synchronous=NORMAL, the documented setting for it: commits cannot corrupt the database, a power loss can only cost the tail of very recent commits, and the default FULL was an fsync per commit.
Startup ran under block_on ON THE MAIN THREAD, and its first-run 'security add-trusted-cert' call blocks on a password/Touch-ID prompt — the UI thread froze before any window existed, and cancelling the prompt aborted the process via .expect() with nothing on screen. Real launches now run setup on the runtime (selftest keeps the synchronous path CI depends on), the TLS/keychain step runs on a blocking thread, and a failure renders an error window that names the failing step with a remediation hint instead of a silent dock bounce. New DNS preflight: the control origin is a public name that must resolve to loopback, which is the canonical DNS-rebinding signature — resolvers with rebind protection (dnsmasq stop-dns-rebind, pfSense, corporate/VPN DNS) strip exactly that answer, leaving a user who is fully online with a webview that never loads. The failure now says so, and says what to do about it. The per-machine CA is now NAME-CONSTRAINED to the control domain, localhost and 127.0.0.1, and its trust is installed for the SSL policy only (-p ssl). Without both, a stolen ca-key.pem — readable by any process running as this user, including code the app itself runs in sandboxes — was a general-purpose authority for every TLS connection this user makes; the module doc claimed loopback-only impact and now it is true. The CA carries a version file: v1 roots are retired (best-effort remove-trusted-cert), regenerated constrained, and re-trusted with one explainable prompt.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What is this contribution about?
Adds the macOS-first version of Studio as a Tauri v2 app in
apps/native. This is not the earlier hosted multi-org launcher: the app mounts the existing productionapps/webrouter and shell, while an in-process Rust runtime replaces the local half ofbunx decocms link.apps/webdiff is deliberately small and mostly additive — a second Vite entry, alib/desktop/transport layer, and desktop-only screens. Shared components the desktop needed to behave differently were pushed back ontomain's version and the difference absorbed in Rust instead.window.opencannot work inside a webview. See the dedicated section below.claudeandcodexCLIs, translates their streams for the existing chat UI, and handles cancellation/process-tree cleanup. The agent CLIs are not bundled — they are resolved from an env override orPATH, so the user's own installation and its credentials are what run.rcloneis the only third-party binary this app ships (see below).sandboxes.sqlitepersists identity and lifecycle state so Stop, Restart, and backend/app restart recovery address the same sandbox.repos/<host>/<owner>/<repo>), and each sandbox is agit worktreeof it atsandboxes/<handle>/repo. N threads on one repository no longer pay N copies of the object store.home,public,uploads,outputs) are mounted so agents can read them with ordinary filesystem calls. See the dedicated section below.Organization filesystem
bunx decocms linkmounted the org's volumes into every sandbox; the Rust runtime originally shipped only a config relay that mounted nothing. Restoring it is the newest part of this PR, and its design and rationale are documented inapps/native/docs/org-fs-plan.md.org/homeis live and browsable rather than a point-in-time copy. A bundledrclonere-serves a loopback WebDAV surface (/_sandbox/orgfs/:org/:volume/**) as NFS, which macOS attaches with its built-in client — no macFUSE, no kernel extension.orgis a sibling ofrepo, never inside it, so a stalled network mount cannot wedgebun install, the dev server, or any git operation.--append-system-promptfor claude,-c developer_instructions=for codex) — gated on the directory actually existing.beforeBuildCommandfetches it and verifies it against the release's publishedSHA256SUMS, and CI caches it. Tauri signs it with the app bundle automatically.MCP connection OAuth
window.openreturnsnullunconditionally in a Tauri webview — WKWebView has no popup or tab concept and Tauri spawns no webview for it — so the shared MCP OAuth flow failed both its popup and its new-tab attempt and threw"Popup was blocked"before the user ever saw a consent screen.Consent now happens in the user's real browser. That is also what keeps working as more providers are connected: Google rejects embedded webviews outright (
disallowed_useragent), so an in-app webview would have fixed GitHub and then broken on the next connector.Once consent moves to another browser process, neither of the flow's return channels can carry the result home —
window.openeris unreachable across processes and thestorageevent needs a sharedlocalStorage— so the local API supplies the missing one:GET /_auth/mcp-callbackGET|DELETE /_auth/mcp-callback/resultThat asymmetry is the security-relevant part; inverting it would let any local process read a live authorization code off
localhost. Leaving the write unauthenticated is safe because it is not the boundary:stateis acrypto.randomUUID()the webview keeps in memory and still verifies. Reads are destructive, so a code is never replayed, and a flow clears the slot before it starts so an abandoned callback cannot surface as a bogus CSRF error.stateis handed back verbatim, leaving the provider-specific unwrapping (deco.cx base64-wraps it) and the CSRF comparison in the one place that already implements them.apps/webdiff is one optional hook plus one desktop-only file.https. The authorize URL belongs to whatever authorization server a connection's MCP server advertises, so the set is open-ended and cannot be enumerated ahead of time. Plaintexthttpbeyond loopback stays denied — an authorize URL served over it would be a downgrade.upstream::browser_page(design-system tokens keyed offprefers-color-scheme, inlined brand mark, everything self-contained so an offline tab still renders). The callback page takes only a success flag, never the provider'serror_description, so it cannot be made to relay arbitrary text.Screenshots / demonstration
The desktop app intentionally renders the production Studio shell rather than a parallel desktop layout. Manual acceptance covers sign-in, org switching, local agent chat, clone/install/dev output in xterm, Stop/Restart, iframe preview, editor opening, and sandbox recovery after an app/backend restart.
The organization filesystem was verified end to end against a real org: all four volumes mounted, real content readable through the agent's own
ls, writes round-tripping,publicrejecting writes, and four rclone processes holding steady across four sandboxes on two different agents and across an app restart, with no orphaned or zombie processes.MCP connection OAuth was verified against a real GitHub connection (
deco-sites/faststore-fila), which went from "Needs authorization" to serving 47 tools. The guard was checked directly: the browser's write returns 200 without a cookie, an unauthenticated read of a parked code returns 401, and a second read returnspendingrather than replaying the code.How to test
Then:
mount | grep nfsshould show one set of volumes per org with no orphans.Automated gates
bun run check bun run lint bun testThe dedicated native CI also runs the local-API black-box contract, dispatch differential, and Tauri build/boot smoke. See
apps/native/README.mdfor the full commands.Migration and release notes
mount_nfs, which is incompatible with App Sandbox. This rules out Mac App Store distribution; Developer ID + notarization is unaffected.bunx decocms linkusers are not forced across immediately; retirement remains staged after parity and dogfooding.Review guide
The highest-value review areas are:
apps/native/crates/local-api— auth boundary, route parity, sandbox lifecycle, process cleanup, and log replay.apps/native/crates/local-api/src/sandbox/org_*.rsandroutes/webdav/— the organization filesystem: mount lifecycle, process ownership, and the read-only boundary on shared volumes.apps/native/crates/local-api/src/routes/mcp_callback.rsand the router's registration of it — the split authority between the unauthenticated browser landing point and the guarded read.apps/native/crates/upstream— OAuth refresh and Keychain durability.apps/native/crates/harness— CLI detection, PTY execution, and stream translation.apps/webdiff — it should read as additive desktop wiring, with shared components unchanged.Review checklist
apps/webentirely, so there is nothing to gate