Repository navigation
Offload native macOS builds and host app windows on another Mac #2403
Description
Problem
The maintainer wants to offload both native macOS builds and the launched app to another Mac, then view and control it from Stim Desktop or the phone app. Local builds and app execution currently consume resources on the main development Mac.
Evidence
User request on October 4, 2026: build and run macOS apps on the other machine so the main machine stays free. Native macOS build/launch support landed in #2397; phone window viewing in #2398 and Control in #2401. #1802 covers build offloading for iOS/Android, and #2266 covers hosted simulators. Open-issue search found no dedicated native macOS remote build-and-run task.
Cause
Native macOS development currently owns a local workspace app process/window. Existing offload and hosted-session contracts do not establish a remote native macOS app lifecycle and its owned window.
Fix idea
Extend the existing approved build-machine and hosting-machine flows to native macOS, with a product decision for selecting placement. Sync exact worktree inputs, build and launch in an isolated worker area, and expose remote status, build/app logs, window streaming and exclusive Control through the existing Desktop and phone viewers. The worker needs explicit Screen Recording/Accessibility onboarding; retain pinned node identity and separate build, hosting and viewer permissions.
Keep the main Mac free of the hosted app process and native compiler workload. Stop, cancellation, disconnect and ownership recovery must affect only the exact remotely owned app and its worker area. Never capture the worker desktop or unrelated windows, and never silently widen approval. Define how SwiftPM/Xcode native targets, signing, cache reuse, toolchain matching and local fallback behave before adding command selectors.
Acceptance: a real two-Mac worktree edit/build/launch/view/control/stop cycle from Desktop and phone, with readable failure/cancellation paths, exact app ownership evidence, and measured resources remaining on the main Mac. Track this after the local native macOS viewer and its permission onboarding; this issue does not claim current remote support.
Activity
janicduplessis commentedon Oct 5, 2026
Claimed: feat/2403-hosted-macos-host
janicduplessis commentedon Oct 5, 2026
Design: hosted macOS apps on an approved Mac
Goal: a coding agent or a person on this Mac builds a native macOS app, runs it on another Mac (the mini), sees and drives it, and stops it, without SSH. The end state replaces
mini-desktop.sh, which Stim Desktop test copies use today.Transport and approval: tailnet only, no new approval
offload/tailnet.tsfindPeer/endpoint), connects overwssto that node's address, and sends its token only to that node. No SSH, scp, rsync, Funnel or public tunnel at any step, including tests and acceptance.platform: 'macos') under the existingdevice-hostcapability, sohosting.machines,stim doctor --fix(which asks the host) andstim-server devices grant <id> --device-host(run by a person on the host) already cover it. Revocation stops hosted macOS sessions through the existingDeviceHost.revokepath.hello {request, deviceName}request fromdoctor --fix, and the samedevices grant.device-host/machines.tsis close to a copy ofoffload/build-machines.ts. What is still duplicated is two settings (offload.machinesandhosting.machines), two client credential files, two requests and two grants. Proposal, needs a product decision: one machine list, with onedoctor --fixrequest that asks for bothbuildanddevice-host, and one grant command that approves both (devices grant <id> --build --device-host). Each capability would stay separately grantable and revocable on the host. This conflicts with the October 5 note on Run owned simulators and emulators on paired Macs with load-aware placement #2266 ("build approval and device-host approval remain separate") and with the separatebuild-clients.json, so I will open it as its own issue and not change it in this stack.Protocol
These are the existing
device-host.*methods withplatform: 'macos':offer/reserve: the host checks that it runs macOS and reports its CPU architecture and macOS version.reservejournals a session inserver/device-host-sessionsunder an exclusive ownership claim, as iOS and Android sessions do. A macOS session counts towardconcurrency.maxDevices. When it is ready, its "device" is the host identity (architecture, OS version) plus the slot-assigned bundle id. No simulator is created andcreated-devices.jsonstays empty.app.offer/app.chunk/app.launch/app.attach: the existing chunked, digest-verified delivery. The manifest accepts a macOS bundle (Contents/Info.plist,Contents/MacOS/<exec>). Install materializes the bundle with the existing escape, link and alias checks, then refuses an app whose executable has no slice for the host architecture, whoseLSMinimumSystemVersionorvtoolminosis newer than the host, or whose platform is notMACOS. No Metro.stopand revocation: identity-verified SIGTERM, then SIGKILL, of the recorded supervisor and app (themacos/stop.tspath;devices/teardown.tsstays for simulators and emulators). Then the session's app data is removed (see Isolation). Teardown that cannot be verified leaves the sessionunknown, with its claim and records kept.frames.subscribe/control.begin/input.*: reuseMacosSource, keyed by the hosted app's recorded identity (pid, start time, executable, bundle id). It checks that identity on every frame and captures one window of that exact process only, never the desktop. macOS input also needsdevice-host.input.scrollanddevice-host.input.key, whichcontrol.tsalready requires for local macOS.Isolation on the host
device-host/sessions/<id>/, with its ownSTIM_HOME,HOMEandCFFIXED_USER_HOMEand its ownTMPDIR.<Info.plist CFBundleIdentifier>.hosted<N>. The bundle is re-stamped and re-signed on the host. A fixed pool, like the three test-copy slots inmini-desktop.sh, lets a person's Screen Recording and Accessibility approvals for the app under test survive rebuilds. An optional host-only machine setting names a stable keychain signing identity; without it the bundle is signed ad hoc.HOMEandCFFIXED_USER_HOMEmoveNSHomeDirectory()and Application Support, but notUserDefaults, because cfprefsd still writes~/Library/Preferences/<bundle id>.plist. Preference isolation therefore comes from the bundle id. Stop deletes that bundle id's defaults domain and its per-bundle files, and nothing else. Keychain, app groups and TCC are per user and shared. This is the same limit Bring native macOS development to React Native workflow parity #2408 accepts for localstim macos, and it is documented.Permissions (TCC)
A person grants Screen Recording and Accessibility once on the host, to the process that runs
stim-frames. That is the existing signed capture host, with the existing onboarding text. Nothing is scripted or reset. If access is missing, the client gets the same refusal message that localstim macosviewing already shows.Client surface (proposed; flag added only in the client PR)
stim macos --host <machine>: builds the Debug product locally (or remotely, see the plan), delivers it, launches it, and records the placement in workspace state. A named host is strict: on refusal or when the host is unreachable, the command fails and never falls back to a local launch.--jsonkeeps its single payload and addshost,sessionandagent.stim statusshows the host and session state.stim stop/worktree removestop the hosted session.stim logsstreams the hosted app's stdout and stderr.frames.subscribe/control.*for a workspace placed on a host to that host over the pinned connection, using the client's device-host credential. Desktop still talks only to its loopback stim-server. The relay is shared with hosted iOS (Run owned simulators and emulators on paired Macs with load-aware placement #2266).Agent control with agent-device
Findings (local only, isolated state directories, my own fixture app):
agent-device open <bundle> --platform macos,snapshot -i,clickandscreenshotwork against astim macosfixture. The screenshot is the app window only.agent-device proxyplusconnect proxy,devices --platform macoslists the host, butopenrefuses: remote leases exist only forios-simulator,ios-instance,android-instanceandharmonyos-instance. This holds in 0.21.12 and 0.21.20, and on upstream main, whereLEASE_BACKEND_BY_PLATFORMmaps the macOS desktop host to no backend on purpose. fix: infer API run results from the requested platform #2989 (the apple/ios platform-axis fix, in 0.21.16) does not change this.desktop/frontmost-app/menubarsurfaces, install and launch of other apps, and anything other than window-scoped screenshots. A host administrator would allocate it through the existing loopback admin path, the wayretainOnCloseleases are kept today. That is the smallest change; I will propose it upstream with this design.Plan within the tailnet-only rule, as the maintainer decided:
/device-host/agent/<session>/*on its existing tailnet serve route. Requests are accepted only from the approved client's tailnet node, only with that session's token, and only for that session's lease. They are forwarded to the loopback daemon with the daemon's own token, which never leaves the host.--remote-configfile (daemonBaseUrl,daemonAuthToken,leaseId,platform: macos) with mode 0600 in its workspace directory.stim status --jsonandstim macos --jsongive only its path. The agent runsagent-device open <bundle> --remote-config <path>. The token is never printed.Parity with
mini-desktop.shmini-desktop.shbuild(rsync +bundle.shover SSH, claim one of 3 slots, stable signing,defaults write)stim macos --host mini: remote build through offload (or local build + delivery), host slot pool for bundle ids, optional host signing identity, defaults passed asmacos.arguments(-key valueargument domain) instead ofdefaults writelaunch(open -n --env HOME=...)shot <out.png>agent-device screenshot --remote-config <path>, or Desktop/phone viewerclick/rclick/key/type(cliclick)agent-device click/fill/type/press --remote-config <path>quit(frees slot)stim stop(verified stop, slot freed, defaults domain removed)cleanstim worktree remove(session area removed on the host)concurrency.maxDeviceson the host, one bundle-id slot per live sessionOut of scope here
Placement policy (#2266), Xcode-project macOS targets,
swift teston the host, Metro for macOS, store or distribution signing.Plan (stacked PRs, each reviewed and merged on its own)
MacosSource, plusdevice-host.input.scrollanddevice-host.input.key.stim macos --host <machine>(local build + delivery), status, logs and stop.stim macos --hostand stop usingmini-desktop.sh.janicduplessis commentedon Oct 5, 2026
Addendum: agent control is automatic, behind a driver adapter
When an app is launched on another Mac, agent control is set up with it, with no manual step. The driving tool can be replaced: agent-device is the first and default implementation, and another tool (for example argent) is added later as one more adapter, without protocol changes.
hosting.agentDriver, a machine-layer setting on the host, defined in the settings registry. Choices areagent-device(default) andnone. A choice is added only when its adapter exists.start()/stop(): the driver's host daemon. stim-server reference-counts it on live hosted apps and holds it under an ownership claim whose child is the daemon. It stops when the last app stops, on revocation and on server shutdown, and restarts if it crashes while apps are running.issue(session)/revoke(session): a credential scoped to one client and one hosted app, such as an agent-device lease allocated through its loopback admin path plus a per-session token.forward(session, request): the HTTP forward behind/device-host/agent/<session>/*on stim-server's tailnet route. It checks the session credential and the client node, then forwards with the daemon's own token, which never leaves the host.describe(session): returns{ driver, env, files, command }for the client. For agent-device that is a 0600--remote-configfile holdingdaemonBaseUrl,daemonAuthToken,leaseIdandplatform, plus the command shapeagent-device <cmd> --remote-config <path>.device-host.app.launch/app.attachresults carry an opaqueagentobject built fromdescribe. Only the client writes its files, and the client passes it throughstim macos --jsonandstim status --jsonwith secrets as file paths, never inline. Nothing else in the protocol names a driver.statusreports. Changing drivers does not change the guide's workflow.This becomes plan step 6 ("host driver adapter + agent-device adapter"). It is still blocked only on the upstream macOS app lease.
janicduplessis commentedon Oct 5, 2026
Correction to the driver addendum:
hosting.agentDriverdefaults tonone. A driver daemon starts only after the host's owner sets the setting, for examplestim settings set hosting.agentDriver agent-device. Once it is set, the lifecycle above applies automatically: the daemon starts with the first hosted app and stops after the last one. When a hosted macOS app is running with no driver,stim status --jsonreportsagent: { driver: 'none' }, and bothstim doctorandstim guidename the setting, so an agent knows how to turn agent control on.wip: host hosted macOS app sessions (unreviewed starting point for #2403
janicduplessis commentedon Oct 5, 2026
Interface contracts for parallel tracks
These contracts let the #2403 tracks proceed in parallel. The shared types land in #2472 (
packages/core/state/hosted-macos.ts,HostedAppDelivery.agent, settinghosting.agentDriver). Everything below is binding for the tracks; a change goes through a comment here first.1. Protocol (host stim-server,
device-hostcapability, approved tailnet client only)Existing methods, now with
platform: 'macos'. Track A widensHostedDevicePlatform, the parsers and the JSON Schema enums together with the worker that handles them:Agent HTTP route (track E), on the host's existing tailnet serve route:
ANY /device-host/agent/<session>/<driver path>. It is accepted only with that session's grant token, only from the owning client's tailnet node, and only while the session's app is running. It is forwarded to the host's loopback driver daemon.2. Host session record and worker home
The journal is
server/device-host-sessions/sessions.json(version 1). It holds aHostedDeviceSessionwithplatform: 'macos', a requiredappSlot(1-64, the lowest free slot among sessions that are not stopped, reserved in the journal transaction like Android'sconsolePort), anddevice: HostedMacosDevice | nullwhoseappSlotmatches. A macOS session counts towardconcurrency.maxDevices.Worker area:
device-host/sessions/<id>/:home/is the worker'sSTIM_HOME. It holdshosted-device.json(theHostedMacosDevice), nocreated-devices.json, andhosted-macos-app.json({ bundleId }, the host-assigned id, used for cleanup at stop).home/macos-app/is a pseudo workspace root. ItsMacosAppRecordlives athome/workspaces/<workspaceName(root)>/state.jsonunder the keymacos, written by the existingmacos-runsupervisor under its runtime claim.home/app-home/is the app'sHOMEandCFFIXED_USER_HOME, withtmp/as itsTMPDIR.apps/<attempt>/holds the verified blobs and the materializedApp.app.Stop and revocation stop the supervisor and the app with identity-checked signals (
stopMacosAppHeld). They then rundefaults delete <hosted bundle id>and remove its plist. No other domain is touched.3. stim-frames helper for a hosted session
This is unchanged from local macOS:
stim-frames macos '<MacosAppState JSON>'withSTIM_HOMEset to the worker home. The fields it uses arebundle,bundleId(the hosted id),executableandapp.{pid,startedAtMicros}. Track B addsreadHostedMacosApp(home): MacosAppRecord | nullto core/state.DeviceHost.viewTargetreturns{ platform: 'macos', app: MacosAppState }only whilemacosAppState(...).state === 'running'. A relaunch closes the session's view, as iOS install does today.4. Client CLI and JSON (track C)
stim macos --host <machine> [--json]:<machine>must be ahosting.machinesentry with an approved credential. The Debug product is built locally (track D adds the remote build). The bundle is staged with its baseCFBundleIdentifier, without the worktree hash, so the host's slot id stays stable across worktrees and the TCC approvals for the app under test persist. The command delivers and launches the app. A named host is strict: it never falls back to a local launch.--jsonprints a single payload:{ platform: 'macos', product, launchId, build, host: HostedMacosPlacement }. There is no localapporsupervisorprocess.host.agentis aHostedAgentAccess:{ driver: 'none', setting: 'hosting.agentDriver' }, or{ driver: 'agent-device', remoteConfig: '<0600 file in the workspace dir>', command: 'agent-device <command> --remote-config <path>' }. A token is never printed.macos.host: HostedMacosPlacement.stim status --jsonincludes it under the environment'smacos.stim stopandworktree removesenddevice-host.stopand delete the agent files.frames.subscribe,control.*andinput.*for{ workspace, platform: 'macos' }to the host when the workspace state hasmacos.host, using the client's device-host credential over the pinned connection. Desktop and the phone talk only to their local server. This relay is the same one hosted iOS needs (Run owned simulators and emulators on paired Macs with load-aware placement #2266).5. Settings
hosting.agentDriver(machine,none|agent-device, defaultnone): landed in feat: add hosted macOS app contracts #2472. Track E makesagent-devicetake effect, and makesdoctor/guidename the setting when a hosted macOS app runs with no driver.mini-desktop.shprovides, it proposeshosting.macosSigningIdentity(machine-only, sensitive: false) here before adding it.6. Agent driver adapter (track E; host side, server package)
The client-side adapter turns a
HostedAgentGrantinto aHostedAgentAccess(it writes files and builds the command). Everything specific to agent-device stays inside its two adapters: the binary path (resolved explicitly,~/.local/bin/agent-devicefirst, because the host's LaunchAgentPATHis only system directories), daemon flags, lease backend and remote-config fields. Agent control waits on an upstream macOS app lease in agent-device (see above). Until that exists,issuerefuses, and the delivery carries{ driver: 'none' }together with a notice.janicduplessis commentedon Oct 5, 2026
Tracks, filed as sub-issues. Contracts: the comment above, plus #2472 (shared types and
hosting.agentDriver, in review).feat/2403-hosted-macos-host, unreviewed)stim macos --host, status/stop, viewer relayThe branch
feat/2403-hosted-macos-hostnow belongs to #2473. This issue stays open for the integration work and the two-Mac acceptance.janicduplessis commentedon Oct 5, 2026
Track A (#2480) amends one number in the contracts: the hosted bundle id limit is 249 characters, not 255, because the host deletes
~/Library/Preferences/<id>.plistat stop and that file name must fit in 255 bytes. Also:agent: { driver: 'none' }is explicit onapp.launch/app.attachresults for a macOS app that isinstalledwhile its session isready; absent otherwise (not installed, stopped, iOS/Android). Never journaled.janicduplessis commentedon Oct 5, 2026
Track B (#2474) merged as #2485 (3db30ee). Host side:
device-host.frames.subscribe,control.beginandinput.*(including newdevice-host.input.scroll|key) accept a hosted macOS session while its app runs. Capture and control are keyed by the session's recorded app identity and refuse anything outside that session. TCC: Screen Recording and Accessibility go to the process that spawnsstim-frames. On the mini the LaunchAgentdev.stim.serverruns/opt/homebrew/bin/node, so that is the process to grant once (not scripted; the mini's actual grants were not checked). On the dev MacBook a real capture of the hosted fixture worked; control was refused at the helper's window-focus step (AXRaise returns -25205 while another app is frontmost, also for a plain non-hosted launch), so a click reaching the fixture is still unverified.janicduplessis commentedon Oct 5, 2026
Two-Mac acceptance (2026-10-05)
Setup: the MacBook was the client, with global
stimat 3db30ee (Stim build digestfa234f983f733f37). The mini was the host and build machine. All product traffic went over the tailnet through the pinned node, with no SSH. SSH was used only to redeploy the mini's service and for read-only checks on it.Mini redeploy
The mini's
dev.stim.servernow runs 3db30ee from a newversions/<sha>worktree. The steps werepnpm install --frozen-lockfile(8 s) andpnpm run build, thenservice installwith the same port,--envand--path-prependflags (no--serve).65a89e99683a0cb2tofa234f983f733f37, which matches the client, so offloaded builds are accepted.https://…:7443answers 426 from the MacBook. The serve config is byte-identical to the previous deploy's.last seen.deployment-evidence/<sha>/summary.txton the mini.The maintainer then granted Screen Recording and Device Control and Data Access to
/opt/homebrew/bin/nodeand restarted the service twice. Each restart stopped every hosted session; see below.Results
stim macos --host janics-mac-mini --jsonwithSTIM_OFFLOAD_MODE=off, SwiftUI fixture (3 files)appSlot1,agent: {driver: none}stim status --jsonmacos.hostwith the session, slot and hosted id;state: runningdevice-host.frames.subscribe(probe)input.textplusinput.key backspacetyped "hello min";input.scrollaccepted. This worked with another app frontmost, and the fixture came to the frontmacos-hostedadvertised; relayed frames (first in 741 ms); relayed controlh1clicked and typed in the AppKit fixture; the Desktop macOS card showed the hosted window live (light and dark below). #2483 is merged as 90fdda5.stim stop --jsonmacos: {status: "stopped"}(notnullhere); the placement is cleared locallystopped, no app process,defaults read <id>.hostedNreports the domain not found, no plist,app-home,App.appandblobsremoved;receipt.jsonandlogs/macos.ndjsonkept by designSTIM_OFFLOAD_MODE=force stim macos(offloaded build, local run), fixtureoffloadedTo: janics-mac-miniswift buildon the mini)STIM_OFFLOAD_MODE=force stim macos --host janics-mac-mini, fixtureorigin/mainworktree (7a41544)dev.stim.desktop.development.hostedN; the window showed the onboarding of its isolated homeswift buildplus about 43 s of cold package downloads on the mini), then about 68 s to deliver 200 files / 94 MB → #2497stim logsmacos.ndjsonstays on the host → #2489The first offloaded Desktop build fell back to a local build (256 s) because the mini's service restarted mid-build (
1006, "No such build job"). It is not a track D defect.Desktop hosted card through the relay, light and dark:
Direct host control of the AppKit fixture (click and typing):
Track B focus refusal
It reproduces on the mini, so it is not caused by the MacBook's frontmost app. It is specific to SwiftUI windows. With identical grants and conditions, AppKit control works, and SwiftUI fails even when the SwiftUI app is frontmost. Stim Desktop is SwiftUI, so this blocks the main use. Filed as #2496 with the evidence.
Parity with
mini-desktop.shmini-desktop.shbuild(rsync,bundle.sh, stable signing,defaults write)stim macos --host <mac>; addSTIM_OFFLOAD_MODE=forceto build on the minibundle.shresources are missing (#2487);macos.argumentsare not passed, so a hosted Desktop runs with default autopilot and server-port preferences instead of the test copy'sdefaults write(#2492); ad hoc signing (#2493 covers host TCC ownership)launchHOMEandSTIM_HOME, slot bundle idshot <out.png>click,key,typedevice-host.input.touch,input.key,input.text, through the relay from Desktop or phonerclickinput.touchhas no secondary buttonquitstim stopcleanstim worktree removeOther gaps filed: #2498 (a host service restart stops every hosted session while the client's
stim statusstill saysrunning;stim macos --hostrecovers in a new slot) and #2499 (a missingmacos.productis reported as an unexpected error).Cleanup: all placements are stopped and verified on the mini. Both worktrees and the fixture workspaces were removed through
stim worktree remove. The scratch relay server is stopped and its scratch Stim home deleted. The mini's three olderversions/directories are kept.janicduplessis commentedon Oct 5, 2026
Update after #2503 (9c73648), checked on the mini with a hosted SwiftUI fixture and hosted Stim Desktop (details in #2496):
mini-desktop.shclick,key,typedevice-host.input.touch,input.key,input.text,input.scrollthrough the relay or a direct client-setupGuide.completed YEStomacos.argumentsas a workaround. Still no agent CLI (#2477).macos.argumentsare passed (autopilot and notifications off) and applied in the hosted Desktop. Therclickrow is unchanged: no secondary button.janicduplessis commentedon Oct 5, 2026
Hosted-app agent driving is verified end to end on the real tailnet path: a fixture app and a fresh worktree's
apps/desktopwere each built on the MacBook, hosted on the Mac mini under the signed Stim Host, and driven with agent-device (open,snapshot,click,fill,screenshot,close) through themacos-applease. Refusals for another app, the desktop surface and full-screen capture held. Details and observations are in #2477.janicduplessis commentedon Oct 10, 2026
Done: #2482 (4c6542d) offloads macOS SwiftPM Debug builds, #2485 (3db30ee) streams and controls hosted macOS apps, and #2503 fixes SwiftUI window control. The two-Mac acceptance and end-to-end agent driving through the
macos-applease are recorded above. Viewer sizing for hosted macOS windows is tracked separately in #3070 and #2945. Closing in a triage pass.