Skip to content

Commit 0f2f239

Browse files
committed
feat: add Tailscale bootstrap helpers for remote TCP setup
1 parent 584d35f commit 0f2f239

20 files changed

Lines changed: 936 additions & 52 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ CodexMonitor is a Tauri app for orchestrating multiple Codex agents across local
1313
- Worktree and clone agents for isolated work; worktrees live under the app data directory (legacy `.codex-worktrees` supported).
1414
- Thread management: pin/rename/archive/copy, per-thread drafts, and stop/interrupt in-flight turns.
1515
- Optional remote backend (daemon) mode for running Codex on another machine.
16+
- Remote setup helpers for self-hosted connectivity (Orbit actions + Tailscale detection/host bootstrap for TCP mode).
1617

1718
### Composer & Agent Controls
1819

docs/mobile-ios-cloudflare-blueprint.md

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
# CodexMonitor iOS + Orbit Cloudflare Blueprint
1+
# CodexMonitor iOS Remote Blueprint (Orbit + Tailscale Bootstrap)
22

3-
This document is the canonical implementation plan for shipping CodexMonitor on iOS using Orbit on Cloudflare as the relay/control plane to a macOS runner.
3+
This document is the canonical implementation plan for shipping CodexMonitor on iOS with a Tailscale-first bootstrap path and Orbit on Cloudflare as the production relay/control plane to a macOS runner.
44

55
## Scope
66

77
- Build and ship a real iOS app (Tauri mobile target).
88
- Keep macOS as the execution host (Codex binary, repos, git, terminals, files).
9+
- Provide a low-friction self-host bootstrap path via Tailscale + TCP daemon for early end-to-end mobile testing.
910
- Use Orbit on Cloudflare as secure relay/realtime bridge between iOS and macOS.
1011
- Make macOS setup manageable from CodexMonitor Settings: authenticate, pair device, launch/stop runner, inspect status/logs.
1112
- Keep one backend logic path (shared core + daemon). Do not duplicate backend behavior in iOS UI.
@@ -53,6 +54,10 @@ This document is the canonical implementation plan for shipping CodexMonitor on
5354
- `terminal-exit`
5455
- Mobile UI scope is the existing app layout in mobile form-factor (no separate mobile-only feature surface).
5556
- Shared-core parity refactor is in place for prompts, local usage, codex utility helpers, git/github UI helpers, and workspace actions.
57+
- Tailscale setup helper is implemented for TCP remote mode:
58+
- Desktop command: `tailscale_status`
59+
- Desktop command: `tailscale_daemon_command_preview`
60+
- Settings UI helpers: detect Tailscale, use suggested tailnet host, show daemon launch command template
5661
- Daemon RPC parity for the current mobile scope is complete.
5762
- `terminal_*` and `dictation_*` command parity are intentionally out of scope for this mobile phase.
5863

@@ -70,14 +75,20 @@ This document is the canonical implementation plan for shipping CodexMonitor on
7075
- Maintains outbound connection to Orbit.
7176
- Receives JSON-RPC from Orbit and returns results/events.
7277

73-
3. Orbit Cloud Services
78+
3. Tailscale Tailnet (Bootstrap Path)
79+
- iOS and macOS join the same user-managed tailnet.
80+
- iOS connects directly to daemon TCP endpoint over tailnet (`remoteBackendProvider=tcp`).
81+
- No hosted CodexMonitor service required.
82+
83+
4. Orbit Cloud Services
7484
- Auth service (passkey + JWT/session).
7585
- Orbit relay (Worker + Durable Object routing + event persistence endpoint).
7686
- User-owned self-host deployment path only.
7787

7888
## Canonical Protocol Choice
7989

8090
- Use Orbit JSON-RPC relay model plus Orbit control messages (`orbit.subscribe`, `orbit.unsubscribe`, `orbit.list-anchors`, keepalive ping/pong).
91+
- For Tailscale bootstrap mode, continue using existing TCP JSON-RPC over tailnet (`remoteBackendProvider=tcp`) with token auth.
8192
- Do not introduce a second custom transport protocol for this phase.
8293
- Reconnection/resync should use Orbit thread event history endpoint and thread resume flows.
8394

@@ -249,6 +260,10 @@ Update `src/features/settings/components/SettingsView.tsx` to add an Orbit secti
249260
Required controls:
250261

251262
- Provider selector (`TCP daemon` / `Orbit`)
263+
- TCP + Tailscale helpers:
264+
- `Detect Tailscale`
265+
- `Use suggested host`
266+
- daemon launch command template
252267
- Orbit WS URL input
253268
- Orbit Auth URL input
254269
- Runner name input
@@ -265,6 +280,7 @@ Current implementation status:
265280

266281
- Implemented now:
267282
- Provider selector
283+
- TCP Tailscale helper controls (`Detect Tailscale`, suggested host, daemon command template)
268284
- Orbit WS/Auth URL inputs
269285
- Runner name input
270286
- Access toggle + client id/secret ref fields
@@ -299,6 +315,24 @@ UX behavior:
299315

300316
## User Setup Flows
301317

318+
## Tailscale Bootstrap (Implemented)
319+
320+
Desktop setup:
321+
322+
1. Install Tailscale and sign into the same tailnet on desktop and iPhone.
323+
2. In CodexMonitor Settings, set `Backend Mode = Remote`, `Provider = TCP`.
324+
3. Click `Detect Tailscale` and then `Use suggested host`.
325+
4. Set a `Remote backend token`.
326+
5. Copy the generated daemon command template and run it on desktop.
327+
6. Use the same host/token in mobile app remote settings.
328+
329+
Mobile setup:
330+
331+
1. Install and sign into Tailscale on iOS.
332+
2. Open CodexMonitor iOS app.
333+
3. Set remote provider to TCP and enter tailnet host + token from desktop setup.
334+
4. Connect and validate thread list + messaging.
335+
302336
## Self-Hosted Orbit
303337

304338
Desktop setup:

memory/decisions.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,3 +267,10 @@ Type: decision
267267
Event: Product direction is now self-hosted Orbit only; app should not present hosted Orbit mode or imply CodexMonitor-hosted relay/auth services.
268268
Action: Removed `orbitDeploymentMode` from frontend/backend settings models and defaults, removed deployment mode selector from Settings UI, and updated mobile Orbit blueprint/setup language to self-host-only.
269269
Rule: Keep Orbit setup/docs/UI self-host-only unless user explicitly requests reintroducing hosted mode.
270+
271+
## 2026-02-07 20:48
272+
Context: Tailscale bootstrap path for mobile remote onboarding
273+
Type: decision
274+
Event: Added end-to-end Tailscale setup support so users can self-host remote TCP access before iOS Orbit UX is finalized.
275+
Action: Implemented desktop `tailscale_status` and `tailscale_daemon_command_preview` commands, wired Settings helpers (detect, suggested host, daemon command), and updated blueprint docs to include the Tailscale bootstrap flow.
276+
Rule: Keep Tailscale as the first-run self-host bootstrap path for TCP remote setup while Orbit remains the production relay path.

memory/mistakes.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,33 @@ Rule: Any settings update that changes remote transport config must invalidate t
8181
Root cause: Remote client cache lifecycle was only tied to disconnect/errors, not to transport settings mutations.
8282
Fix applied: Updated `src-tauri/src/settings/mod.rs` to compare previous vs updated transport settings and reset cached remote backend when they differ; added predicate unit tests.
8383
Prevention rule: Treat transport-config settings as cache keys and invalidate on change at the backend boundary, not only from UI handlers.
84+
85+
## 2026-02-07 20:36
86+
Context: Orbit token sync persistence retry behavior in Settings
87+
Type: mistake
88+
Event: `syncRemoteBackendToken` updated `latestSettingsRef` before settings persistence succeeded, so a failed save could make later retries no-op.
89+
Action: Moved `latestSettingsRef` mutation to after successful `onUpdateAppSettings` completion and added a regression test for retry-after-failure.
90+
Rule: In async settings flows, only advance local "latest" refs after persistence succeeds.
91+
Root cause: Optimistically mutating in-memory settings state before awaiting durable save.
92+
Fix applied: Updated `src/features/settings/components/SettingsView.tsx` token sync ordering and added `retries Orbit token persistence after a failed save` in `src/features/settings/components/SettingsView.test.tsx`.
93+
Prevention rule: Keep optimistic UI drafts separate from persisted-settings refs and add explicit retry-path tests for async save failures.
94+
95+
## 2026-02-07 21:01
96+
Context: Tailscale settings helper token preview freshness
97+
Type: mistake
98+
Event: Tailscale daemon command preview did not auto-refresh after remote token changes, leaving `tokenConfigured` warning state stale until manual refresh.
99+
Action: Added `appSettings.remoteBackendToken` as a dependency of the auto-refresh effect in `SettingsView` so preview data is recomputed after token edits.
100+
Rule: Any derived helper output that depends on settings values must include those values in effect dependencies (or equivalent invalidation paths).
101+
Root cause: The effect dependency list tracked provider/mode only and omitted token changes used by preview generation.
102+
Fix applied: Updated `src/features/settings/components/SettingsView.tsx` effect dependencies and revalidated `SettingsView` tests.
103+
Prevention rule: When adding helper panels, explicitly audit dependency arrays against all backend inputs shown in that panel (especially token/auth state).
104+
105+
## 2026-02-07 21:03
106+
Context: Tailscale helper auto-preview on mobile
107+
Type: mistake
108+
Event: Settings auto-fetched desktop-only Tailscale daemon command preview on mobile, creating immediate unsupported-error noise.
109+
Action: Added `isMobilePlatform` helper and gated auto preview fetch in `SettingsView` to desktop platforms only.
110+
Rule: Do not auto-run desktop-only diagnostics on mobile surfaces; gate by platform first.
111+
Root cause: Auto-refresh effect was scoped by provider/mode only and assumed desktop capabilities.
112+
Fix applied: Updated `src/features/settings/components/SettingsView.tsx` effect logic and added `src/utils/platformPaths.test.ts` coverage for mobile detection.
113+
Prevention rule: For any auto-run settings helper, explicitly classify desktop-only vs cross-platform behavior before wiring useEffect refreshes.

memory/todo.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
- [ ] 2026-02-07: Implement Orbit-only mobile remote foundation: Orbit transport in `remote_backend`, runner Orbit mode, self-host settings + pairing UX, and iOS keychain-backed auth storage.
55
- [ ] 2026-02-07: Harden Orbit WS transport with explicit reconnect/backoff + replay/resync handling for mobile reconnect flows.
66
- [ ] 2026-02-07: Complete Orbit production readiness gaps after baseline wiring: secure secret storage (Keychain/keyring), LaunchAgent/background runner management, pairing QR/code UX, and app-side Orbit reconnect/resync handling.
7+
- [ ] 2026-02-07: Build iOS-side remote onboarding UI that can consume the new Tailscale bootstrap outputs (status hints + daemon command + host/token handoff).
78

89
## Done
910
- [x] 2026-02-07: Restored Sentry frontend reporting removed in `83a37da` (`@sentry/react`, `Sentry.init`, captureException callsites, and metrics instrumentation).
@@ -18,3 +19,4 @@
1819
- [x] 2026-02-07: Implement Cloudflare WebSocket transport internals behind `remoteBackendProvider=cloudflare` (connect/read/write/pending-response flow) while preserving TCP behavior and callsites.
1920
- [x] 2026-02-07: Migrate remote provider naming/settings from Cloudflare-specific keys to Orbit (`orbit` provider + `orbit*` settings).
2021
- [x] 2026-02-07: Remove unreleased Cloudflare backward-compat aliases and backport tests to keep Orbit implementation canonical-only.
22+
- [x] 2026-02-07: Add Tailscale bootstrap support for remote TCP setup (desktop status detection + suggested host + daemon command preview + settings/docs wiring).

src-tauri/src/daemon_binary.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
use std::path::PathBuf;
2+
3+
pub(crate) fn daemon_binary_candidates() -> &'static [&'static str] {
4+
if cfg!(windows) {
5+
&["codex_monitor_daemon.exe", "codex-monitor-daemon.exe"]
6+
} else {
7+
&["codex_monitor_daemon", "codex-monitor-daemon"]
8+
}
9+
}
10+
11+
pub(crate) fn resolve_daemon_binary_path() -> Result<PathBuf, String> {
12+
let current_exe = std::env::current_exe().map_err(|err| err.to_string())?;
13+
let parent = current_exe
14+
.parent()
15+
.ok_or_else(|| "Unable to resolve executable directory".to_string())?;
16+
let candidate_names = daemon_binary_candidates();
17+
18+
for name in candidate_names {
19+
let candidate = parent.join(name);
20+
if candidate.is_file() {
21+
return Ok(candidate);
22+
}
23+
}
24+
25+
Err(format!(
26+
"Unable to locate daemon binary in {} (tried: {})",
27+
parent.display(),
28+
candidate_names.join(", ")
29+
))
30+
}
31+
32+
#[cfg(test)]
33+
mod tests {
34+
use super::daemon_binary_candidates;
35+
36+
#[test]
37+
fn daemon_binary_candidates_prioritize_underscored_name() {
38+
assert!(daemon_binary_candidates()[0].starts_with("codex_monitor_daemon"));
39+
}
40+
}

src-tauri/src/lib.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use tauri::{RunEvent, WindowEvent};
44

55
mod backend;
66
mod codex;
7+
mod daemon_binary;
78
mod dictation;
89
mod event_sink;
910
mod files;
@@ -20,6 +21,7 @@ mod settings;
2021
mod shared;
2122
mod state;
2223
mod storage;
24+
mod tailscale;
2325
mod terminal;
2426
mod types;
2527
mod utils;
@@ -174,7 +176,9 @@ pub fn run() {
174176
orbit::orbit_sign_out,
175177
orbit::orbit_runner_start,
176178
orbit::orbit_runner_stop,
177-
orbit::orbit_runner_status
179+
orbit::orbit_runner_status,
180+
tailscale::tailscale_status,
181+
tailscale::tailscale_daemon_command_preview
178182
])
179183
.build(tauri::generate_context!())
180184
.expect("error while running tauri application");

src-tauri/src/orbit/mod.rs

Lines changed: 1 addition & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
use std::path::PathBuf;
21
use std::process::Stdio;
32
use std::time::{SystemTime, UNIX_EPOCH};
43

54
use tauri::State;
65

6+
use crate::daemon_binary::resolve_daemon_binary_path;
77
use crate::shared::orbit_core;
88
use crate::shared::process_core::{kill_child_process_tree, tokio_command};
99
use crate::shared::settings_core;
@@ -20,45 +20,6 @@ fn now_unix_ms() -> i64 {
2020
.unwrap_or(0)
2121
}
2222

23-
fn daemon_binary_candidates() -> &'static [&'static str] {
24-
if cfg!(windows) {
25-
&["codex_monitor_daemon.exe", "codex-monitor-daemon.exe"]
26-
} else {
27-
&["codex_monitor_daemon", "codex-monitor-daemon"]
28-
}
29-
}
30-
31-
fn resolve_daemon_binary_path() -> Result<PathBuf, String> {
32-
let current_exe = std::env::current_exe().map_err(|err| err.to_string())?;
33-
let parent = current_exe
34-
.parent()
35-
.ok_or_else(|| "Unable to resolve executable directory".to_string())?;
36-
let candidate_names = daemon_binary_candidates();
37-
38-
for name in candidate_names {
39-
let candidate = parent.join(name);
40-
if candidate.is_file() {
41-
return Ok(candidate);
42-
}
43-
}
44-
45-
Err(format!(
46-
"Unable to locate daemon binary in {} (tried: {})",
47-
parent.display(),
48-
candidate_names.join(", ")
49-
))
50-
}
51-
52-
#[cfg(test)]
53-
mod tests {
54-
use super::daemon_binary_candidates;
55-
56-
#[test]
57-
fn daemon_binary_candidates_prioritize_underscored_name() {
58-
assert!(daemon_binary_candidates()[0].starts_with("codex_monitor_daemon"));
59-
}
60-
}
61-
6223
async fn refresh_runner_runtime(runtime: &mut OrbitRunnerRuntime) {
6324
let Some(child) = runtime.child.as_mut() else {
6425
runtime.status.state = OrbitRunnerState::Stopped;

0 commit comments

Comments
 (0)