Skip to content

feat(native): add Studio desktop app with a local Rust runtime - #4178

Draft
vibegui wants to merge 32 commits into
mainfrom
vibegui/tauri-multi-org-launcher
Draft

feat(native): add Studio desktop app with a local Rust runtime#4178
vibegui wants to merge 32 commits into
mainfrom
vibegui/tauri-multi-org-launcher

Conversation

@vibegui

@vibegui vibegui commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

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 production apps/web router and shell, while an in-process Rust runtime replaces the local half of bunx decocms link.

  • Same Studio UI, minimal shared-code churn: org switching, settings, connections, chat, and project views remain shared with the web app. The apps/web diff is deliberately small and mostly additive — a second Vite entry, a lib/desktop/ transport layer, and desktop-only screens. Shared components the desktop needed to behave differently were pushed back onto main's version and the difference absorbed in Rust instead.
  • 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: OAuth 2.1/PKCE and Better Auth bridge desktop sign-in to macOS Keychain. Credential auth uses the shared login UI; social/SSO providers complete in the system browser.
  • MCP connection OAuth: connecting an MCP server (GitHub, Google, …) also completes in the system browser, because window.open cannot work inside a webview. See the dedicated section below.
  • Local agents: detects and runs the user's installed, authenticated claude and codex CLIs, 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 or PATH, so the user's own installation and its credentials are what run. rclone is the only third-party binary this app ships (see below).
  • Durable local chats: native-owned SQLite stores local threads, ordered messages, turn queues, cancellation state, and crash recovery. These chats intentionally remain device-local in v1.
  • Durable sandboxes: Rust implements the filesystem, bash, Git, tasks, setup, SSE, HTTP/WebSocket preview, and editor-integration surfaces previously provided by the link daemon. sandboxes.sqlite persists identity and lifecycle state so Stop, Restart, and backend/app restart recovery address the same sandbox.
  • Worktree-backed repositories: one bare clone per upstream repository lives in a shared store (repos/<host>/<owner>/<repo>), and each sandbox is a git worktree of it at sandboxes/<handle>/repo. N threads on one repository no longer pay N copies of the object store.
  • Organization filesystem: the org's shared volumes (home, public, uploads, outputs) are mounted so agents can read them with ordinary filesystem calls. See the dedicated section below.
  • RAM-bounded terminal replay: clone, install, and dev-server output is written to capped files and tail-replayed into xterm, so late mounts and restarts recover output without permanent per-task RAM buffers.

Organization filesystem

bunx decocms link mounted 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 in apps/native/docs/org-fs-plan.md.

<appRoot>/orgs/<slug>/{home,public,uploads,outputs}   ← the only real mounts, one set per ORG
<appRoot>/sandboxes/<handle>/
├── repo/    ← git worktree (the agent's cwd)
└── org/     ← per-sandbox symlinks into the org mounts
  • Mounted, not synced, so org/home is live and browsable rather than a point-in-time copy. A bundled rclone re-serves a loopback WebDAV surface (/_sandbox/orgfs/:org/:volume/**) as NFS, which macOS attaches with its built-in client — no macFUSE, no kernel extension.
  • Shared per organization, not per sandbox. Every sandbox links into one set of mounts, so the process count is a function of orgs visited, not of agents or threads.
  • Warmed on org switch, not on sandbox start, so the mounts are up before an agent needs them; sandbox provisioning then waits at most 2s and reports the result in the setup transcript.
  • org is a sibling of repo, never inside it, so a stalled network mount cannot wedge bun install, the dev server, or any git operation.
  • Agents are told where it is. The desktop CLIs receive no system prompt of their own, so an organization-filesystem block is appended (--append-system-prompt for claude, -c developer_instructions= for codex) — gated on the directory actually existing.
  • rclone is bundled (78 MB per architecture) but not committed: beforeBuildCommand fetches it and verifies it against the release's published SHA256SUMS, and CI caches it. Tauri signs it with the app bundle automatically.

MCP connection OAuth

window.open returns null unconditionally 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.opener is unreachable across processes and the storage event needs a shared localStorage — so the local API supplies the missing one:

Route Called by Guard
GET /_auth/mcp-callback the system browser outside — it has no per-launch session cookie and cannot be given one
GET|DELETE /_auth/mcp-callback/result the webview inside — a parked authorization code is only readable by a caller that proved it is this app

That 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: state is a crypto.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.

  • Rust stays a dumb relay. state is 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.
  • The SDK takes an injected adapter rather than importing the desktop bridge, so no Tauri binding reaches the plain web build and the browser flow is unchanged. The apps/web diff is one optional hook plus one desktop-only file.
  • The opener capability now allows 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. Plaintext http beyond loopback stays denied — an authorize URL served over it would be a downgrade.
  • Browser-facing pages share Studio's chrome. Sign-in and this callback both serve dead-end pages into a browser that is not the app; they now share upstream::browser_page (design-system tokens keyed off prefers-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's error_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, public rejecting 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 returns pending rather than replaying the code.

How to test

# One-time setup per macOS development machine
bun run --cwd=apps/native dev:signing:setup

# Start Vite HMR + the Tauri app
bun run --cwd=apps/native dev

Then:

  1. Sign in and switch organizations to confirm the shared production shell is active.
  2. Open a git-backed chat, select a detected Claude Code or Codex tier, and send a message.
  3. Open the sandbox drawer and verify setup xterm replay, live dev output, iframe preview, and Open in VSCode/Cursor.
  4. Use Stop and Restart and confirm the Rust lifecycle and terminal state update.
  5. Ask the agent what it can see in the organization home folder; it should read real org content by absolute path.
  6. Quit and relaunch; authentication, local chats, and sandbox metadata should recover, an intact sandbox should restart without recloning, and mount | grep nfs should show one set of volumes per org with no orphans.

Automated gates

bun run check
bun run lint
bun test

The dedicated native CI also runs the local-API black-box contract, dispatch differential, and Tauri build/boot smoke. See apps/native/README.md for the full commands.

Migration and release notes

  • v1 targets macOS. Sandboxes are bare child processes running as the current OS user, not containers or VMs.
  • Local chats do not synchronize with the hosted thread store. Desktop v1 supports the user's Claude Code/Codex CLIs; it does not add local Decopilot inference.
  • Native SQLite schemas are app-owned and migrate independently from Studio's Postgres schema.
  • The organization filesystem depends on mount_nfs, which is incompatible with App Sandbox. This rules out Mac App Store distribution; Developer ID + notarization is unaffected.
  • Existing bunx decocms link users are not forced across immediately; retirement remains staged after parity and dogfooding.
  • Production distribution still needs Apple Developer ID/notarization credentials, updater keys and artifact hosting. The self-signed development identity is only for stable local Keychain access during rebuilds.

Review guide

The highest-value review areas are:

  1. apps/native/crates/local-api — auth boundary, route parity, sandbox lifecycle, process cleanup, and log replay.
  2. apps/native/crates/local-api/src/sandbox/org_*.rs and routes/webdav/ — the organization filesystem: mount lifecycle, process ownership, and the read-only boundary on shared volumes.
  3. apps/native/crates/local-api/src/routes/mcp_callback.rs and the router's registration of it — the split authority between the unauthenticated browser landing point and the guarded read.
  4. apps/native/crates/upstream — OAuth refresh and Keychain durability.
  5. apps/native/crates/harness — CLI detection, PTY execution, and stream translation.
  6. The apps/web diff — it should read as additive desktop wiring, with shared components unchanged.

Review checklist

  • Title and description match the implemented native architecture
  • Existing production Studio UI is reused, with a deliberately small shared-code diff
  • No web-facing rollout surface: the desktop download CTA was removed from apps/web entirely, so there is nothing to gate
  • Documentation and release runbooks are included
  • Automated and manual native acceptance paths are documented
  • Organization filesystem verified end to end against a real organization

@vibegui
vibegui force-pushed the vibegui/tauri-multi-org-launcher branch from 95af06e to 4cd9ad8 Compare June 30, 2026 20:23
@tlgimenes
tlgimenes force-pushed the vibegui/tauri-multi-org-launcher branch 10 times, most recently from e480608 to a489d2a Compare July 23, 2026 14:40
@tlgimenes tlgimenes changed the title feat(launcher): Tauri desktop launcher for multi-org Studio feat(native): add Studio desktop app with a local Rust runtime Jul 23, 2026
@tlgimenes
tlgimenes force-pushed the vibegui/tauri-multi-org-launcher branch 8 times, most recently from 0a28f00 to 4aa882e Compare July 27, 2026 19:38
@tlgimenes
tlgimenes marked this pull request as draft July 27, 2026 23:16
@tlgimenes
tlgimenes force-pushed the vibegui/tauri-multi-org-launcher branch 6 times, most recently from 4582555 to c115158 Compare July 28, 2026 19:12
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>
tlgimenes and others added 20 commits July 28, 2026 16:35
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
tlgimenes force-pushed the vibegui/tauri-multi-org-launcher branch from c115158 to 93789a8 Compare July 28, 2026 19:39
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants