From 82c2483b443740a1e0becee26136594b55b54c91 Mon Sep 17 00:00:00 2001 From: Alex Weinstein Date: Sat, 25 Jul 2026 19:54:45 -0700 Subject: [PATCH 1/4] feat: add secure Conductor Pocket iPhone relay --- .gitignore | 1 + docs/conductor-pocket-ui-spec.md | 1047 ++++++++ tools/conductor-pocket/README.md | 113 + tools/conductor-pocket/SECURITY.md | 119 + tools/conductor-pocket/package-lock.json | 279 +++ tools/conductor-pocket/package.json | 22 + tools/conductor-pocket/public/app.css | 1444 ++++++++++++ tools/conductor-pocket/public/app.js | 2098 +++++++++++++++++ tools/conductor-pocket/public/icon.svg | 4 + tools/conductor-pocket/public/index.html | 48 + .../public/manifest.webmanifest | 18 + .../conductor-pocket/public/service-worker.js | 39 + .../scripts/install-relay.mjs | 298 +++ tools/conductor-pocket/scripts/ui-fixture.mjs | 496 ++++ tools/conductor-pocket/src/accessibility.mjs | 125 + tools/conductor-pocket/src/cli.mjs | 289 +++ tools/conductor-pocket/src/conductor-db.mjs | 440 ++++ .../src/conductor-send.applescript | 288 +++ tools/conductor-pocket/src/config.mjs | 168 ++ tools/conductor-pocket/src/constants.mjs | 28 + tools/conductor-pocket/src/encoding.mjs | 28 + tools/conductor-pocket/src/errors.mjs | 12 + tools/conductor-pocket/src/security.mjs | 476 ++++ tools/conductor-pocket/src/server.mjs | 548 +++++ .../conductor-pocket/src/tailscale-config.mjs | 52 + .../test/accessibility.test.mjs | 46 + .../test/conductor-db.test.mjs | 157 ++ tools/conductor-pocket/test/config.test.mjs | 56 + tools/conductor-pocket/test/security.test.mjs | 137 ++ tools/conductor-pocket/test/server.test.mjs | 161 ++ .../test/tailscale-config.test.mjs | 57 + 31 files changed, 9094 insertions(+) create mode 100644 docs/conductor-pocket-ui-spec.md create mode 100644 tools/conductor-pocket/README.md create mode 100644 tools/conductor-pocket/SECURITY.md create mode 100644 tools/conductor-pocket/package-lock.json create mode 100644 tools/conductor-pocket/package.json create mode 100644 tools/conductor-pocket/public/app.css create mode 100644 tools/conductor-pocket/public/app.js create mode 100644 tools/conductor-pocket/public/icon.svg create mode 100644 tools/conductor-pocket/public/index.html create mode 100644 tools/conductor-pocket/public/manifest.webmanifest create mode 100644 tools/conductor-pocket/public/service-worker.js create mode 100644 tools/conductor-pocket/scripts/install-relay.mjs create mode 100644 tools/conductor-pocket/scripts/ui-fixture.mjs create mode 100644 tools/conductor-pocket/src/accessibility.mjs create mode 100644 tools/conductor-pocket/src/cli.mjs create mode 100644 tools/conductor-pocket/src/conductor-db.mjs create mode 100644 tools/conductor-pocket/src/conductor-send.applescript create mode 100644 tools/conductor-pocket/src/config.mjs create mode 100644 tools/conductor-pocket/src/constants.mjs create mode 100644 tools/conductor-pocket/src/encoding.mjs create mode 100644 tools/conductor-pocket/src/errors.mjs create mode 100644 tools/conductor-pocket/src/security.mjs create mode 100644 tools/conductor-pocket/src/server.mjs create mode 100644 tools/conductor-pocket/src/tailscale-config.mjs create mode 100644 tools/conductor-pocket/test/accessibility.test.mjs create mode 100644 tools/conductor-pocket/test/conductor-db.test.mjs create mode 100644 tools/conductor-pocket/test/config.test.mjs create mode 100644 tools/conductor-pocket/test/security.test.mjs create mode 100644 tools/conductor-pocket/test/server.test.mjs create mode 100644 tools/conductor-pocket/test/tailscale-config.test.mjs diff --git a/.gitignore b/.gitignore index ce144083..fb0ea876 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ dist-ssr *.njsproj *.sln *.sw? +.gstack/ diff --git a/docs/conductor-pocket-ui-spec.md b/docs/conductor-pocket-ui-spec.md new file mode 100644 index 00000000..b43326f3 --- /dev/null +++ b/docs/conductor-pocket-ui-spec.md @@ -0,0 +1,1047 @@ +# Conductor Pocket - UI Specification v1.0 + +Author: Fable (design authority). Status: ready for implementation. +Scope: visual and interaction spec only. No application code. Backend contract +assumptions are listed in §12 and must be confirmed against the relay's real +capabilities before any control that depends on them is shown. + +--- + +## 1. Product frame + +Conductor Pocket is a periscope, not a dashboard. The Mac is doing the work; +the phone watches, steers, and speaks into it. Every design decision follows +from three facts: + +1. **The connection is the product.** If the SSE stream is down, nothing else + is true. Connection state is therefore ambient, always visible, and never + optimistic. +2. **Alex is a power user with dozens of workspaces.** Recency and search beat + taxonomy. The fastest path back to the last chat wins. +3. **Sends are physical.** A send is an Accessibility injection into a real + composer on a real Mac. The UI never claims "sent" until the relay confirms + the text landed and was submitted. No control appears unless its capability + is verified. + +### Design language: "Sideband" + +Named for the radio term: a secondary channel carrying the primary signal. +Warm graphite surfaces, a single copper accent, monospace for machine truth, +SF Pro for human speech. Dark-first (this is a night-and-couch tool on OLED), +with a full warm-paper light theme. Calm and dense, never decorative. The one +memorable thing: the interface never lies, and it looks like it knows that - +machine facts (paths, durations, host names, fingerprints) are always set in +mono; human conversation is always set in text sans. The eye learns the split +in a minute and trusts it forever. + +Explicitly rejected: glassmorphism, neon/cyberpunk, purple SaaS gradients, +floating tab bars, decorative blur, skeuomorphic terminal cosplay. + +--- + +## 2. Foundations + +### 2.1 Typography + +System stacks only. No downloaded fonts, no CDN. + +``` +--font-text: -apple-system, BlinkMacSystemFont, system-ui, "Helvetica Neue", sans-serif; +--font-mono: ui-monospace, "SF Mono", Menlo, monospace; +--font-round: ui-rounded, -apple-system, system-ui, sans-serif; /* numerals, badges, timers */ +``` + +Type ramp (CSS px at default root; implement in rem, root 16px, so iOS text +scaling flows through - see §8.3): + +| Token | Size/Line | Weight | Tracking | Family | Usage | +|---------------|-----------|--------|----------|--------|-------| +| display | 26/32 | 700 | -0.4px | text | Root list large title, pairing headline | +| title | 20/25 | 600 | -0.3px | text | Sheet titles, error screens | +| headline | 17/22 | 600 | -0.4px | text | Nav titles, row titles | +| body | 16/24 | 400 | 0 | text | Transcript text, form text | +| body-em | 16/24 | 600 | 0 | text | Inline emphasis, button labels | +| callout | 15/20 | 400 | 0 | text | List previews, secondary copy | +| footnote | 13/18 | 400 | 0 | text | Timestamps, meta lines, banners | +| caption | 12/16 | 500 | +0.1px | text | Chips, badges labels | +| micro-caps | 11/14 | 600 | +0.6px, uppercase | text | Section headers ("ACTIVE", "RECENT") | +| mono-body | 13/19 | 400 | 0 | mono | Tool detail, code blocks, drafts | +| mono-micro | 12/16 | 500 | 0 | mono | Paths, durations, fingerprints, tailnet names | +| round-badge | 12/16 | 600 | 0 | round | Unread counts, queue positions, elapsed timers | + +Rules: +- Transcript assistant text is `body`. Never smaller. +- All inputs are ≥16px font-size (prevents iOS focus zoom). +- Tabular numerals (`font-variant-numeric: tabular-nums`) on all timers, + counts, and latency readouts so they don't jitter while updating. +- Line length in transcript: max 68ch on wide screens (see §7). + +### 2.2 Color tokens + +Warm neutrals (slightly toward brown, never blue-gray) + copper accent + +three status hues. Status is never conveyed by color alone; every status has +a glyph and/or label (§8). + +#### Dark theme (default) + +| Token | Value | Usage | +|---|---|---| +| `bg/canvas` | `#141210` | App background, transcript | +| `bg/raised` | `#1C1A17` | Rows, cards, composer field, tool cards | +| `bg/elevated` | `#242019` | Sheets, popovers, switcher | +| `bg/inset` | `#0E0D0B` | Code blocks, mono detail wells | +| `hairline` | `#2E2A25` | Default borders, separators | +| `hairline/strong` | `#3D372F` | Focused card borders, input borders | +| `text/primary` | `#F4F1EC` | Primary text | +| `text/secondary` | `#A9A199` | Meta, timestamps, previews | +| `text/tertiary` | `#746D64` | Disabled, placeholder ONLY (fails 4.5:1 - never for information) | +| `accent/copper` | `#D08A54` | Interactive tint, links, focus ring, working pulse | +| `accent/copper-press` | `#B9754C` | Pressed tint | +| `fill/copper` | `#C87E4C` | Filled primary buttons, unread badges | +| `on-copper` | `#211208` | Text/glyphs on `fill/copper` | +| `bubble/user` | `#7A4526` | User message bubble fill | +| `on-bubble` | `#F6EDE4` | User bubble text | +| `status/live` | `#4CC38A` | Connected, delivered, success | +| `status/wait` | `#E3B341` | Reconnecting, queued, needs-input, degraded | +| `status/down` | `#E5684F` | Offline, failed, revoked, destructive | +| `tint/live` | `#4CC38A` @ 12% | Success banner bg | +| `tint/wait` | `#E3B341` @ 12% | Warning banner bg | +| `tint/down` | `#E5684F` @ 12% | Error banner bg | +| `scrim` | `#000000` @ 44% | Behind sheets | + +#### Light theme + +| Token | Value | Usage | +|---|---|---| +| `bg/canvas` | `#F7F5F1` | Warm paper background | +| `bg/raised` | `#FFFFFF` | Rows, cards, composer field | +| `bg/elevated` | `#FFFFFF` | Sheets (with shadow) | +| `bg/inset` | `#EFEBE4` | Code blocks, mono wells | +| `hairline` | `#E5E0D8` | Borders, separators | +| `hairline/strong` | `#CFC7BA` | Input borders | +| `text/primary` | `#211D19` | Primary text | +| `text/secondary` | `#6B645A` | Meta, timestamps | +| `text/tertiary` | `#9A9284` | Disabled, placeholder ONLY | +| `accent/copper` | `#9C5730` | Interactive tint, links, focus | +| `accent/copper-press` | `#7F4626` | Pressed | +| `fill/copper` | `#9C5730` | Filled primary buttons, badges | +| `on-copper` | `#FFF7F0` | Text on copper fill | +| `bubble/user` | `#9C5730` | User bubble fill | +| `on-bubble` | `#FFF7F0` | User bubble text | +| `status/live` | `#1D7A50` | Connected, success | +| `status/wait` | `#8A6116` | Reconnecting, queued | +| `status/down` | `#B3341F` | Offline, failed | +| `tint/live` etc. | same hues @ 10% on white | Banners | +| `scrim` | `#211D19` @ 32% | Behind sheets | + +Theme switching: follows system (`prefers-color-scheme`). `theme-color` meta +provided for both schemes (`#141210` / `#F7F5F1`). No in-app theme toggle in +v1. + +### 2.3 Spacing, radii, elevation + +4px grid: `2, 4, 8, 12, 16, 20, 24, 32, 40, 56`. + +- Screen gutter: 16px (≤599px width), 20px (600-1023), 24px (≥1024). +- List row min-height: 56px (workspace), 64px (chat, two-line). All rows are + full-bleed tap targets with 16px horizontal padding. + +Radii: + +| Token | Value | Usage | +|---|---|---| +| `r/chip` | 6px | Status chips, capability chips | +| `r/control`| 10px | Buttons, tool activity rows, inputs | +| `r/card` | 14px | Cards, banners, conflict sheet cards | +| `r/bubble` | 18px | User bubbles, composer field | +| `r/sheet` | 16px | Sheet top corners | +| `r/full` | 999px| Pills, badges, "Latest" jump pill | + +Elevation: +- Dark theme: no drop shadows on rows/cards - separation via `bg/raised` + + `hairline`. Sheets only: `0 -12px 48px rgba(0,0,0,0.5)`. +- Light theme: `shadow/1: 0 1px 2px rgba(33,29,25,0.06), 0 4px 16px rgba(33,29,25,0.05)` + on cards; `shadow/sheet: 0 -12px 48px rgba(33,29,25,0.18)`. + +### 2.4 Iconography + +No external assets. A single inline SVG sprite, 20×20 viewBox, 1.8px stroke, +round caps, drawn to match SF Symbols "medium" weight optically. Required +glyphs (names are implementation IDs): + +`chevron-left, chevron-right, chevron-down, arrow-up (send), stop (square in +circle), mic-note (info only), gear, lock, faceid (abstract: rounded square + +two dots + smile arc), key, wifi-slash, bolt (activity), clock (queued), +hourglass (waiting), check, check-double (delivered), x, warn (triangle), +search, plus, copy, expand, terminal (tool), file (tool), globe (tool), +git-branch, phone, laptop, eye-off, refresh, share-ios (rounded square with +up arrow, for install guidance), squares (switcher)`. + +Glyph color follows text color of its context. Status glyphs use status hues. + +### 2.5 Motion + +| Token | Value | Usage | +|---|---|---| +| `dur/fast` | 120ms | Chip changes, chunk fade-in, pressed states | +| `dur/base` | 200ms | Row expand/collapse, banner in/out | +| `dur/slow` | 320ms | Screen push/pop crossfade-slide | +| `dur/sheet` | 380ms | Sheet presentation | +| `ease/standard` | cubic-bezier(0.2, 0, 0, 1) | Most transitions | +| `ease/exit` | cubic-bezier(0.3, 0, 1, 1) | Dismissals | +| `ease/sheet`| cubic-bezier(0.32, 0.72, 0, 1) | Sheets (Apple's curve) | + +Signature motions (the complete set - nothing else animates): +1. **Working pulse**: 6px copper dot, opacity 0.45→1.0→0.45, 1.8s + ease-in-out infinite. Used in status rows and workspace/chat activity. +2. **Streaming ingress**: each appended text chunk fades in at `dur/fast`, + opacity only. No translation (protects scroll anchoring). No + per-character typewriter effects. +3. **Screen push**: incoming slides 24px from right + fade, `dur/slow`. + Pop reverses. Sheets rise with `ease/sheet`. +4. **Banner**: slides down from under nav bar, `dur/base`. +5. **Pressed**: rows and buttons darken to pressed tokens within 80ms of + touchstart (no waiting for click). +6. **Reconnect resolve**: banner crossfades amber→green "Live", holds 1.2s, + slides away. + +`prefers-reduced-motion`: 1 becomes a static dot at 100% opacity with the +label carrying state; 2-6 become plain crossfades ≤120ms. + +Haptics: not reliably available in iOS web. Do not simulate or fake them. + +--- + +## 3. Structure and navigation + +**No tab bar.** A single navigation stack plus one overlay: + +``` +[Lock gate] → Workspaces (root) → Chats (workspace) → Transcript (chat) + ⌄ gear ⌄ title tap + Security panel Switcher sheet +``` + +- **Launch = last chat.** The app persists route + scroll position and cold + starts directly into the last open transcript, painting from the local + snapshot (§6.5) before the stream connects. Workspaces list is *up* the + stack, not the home screen. +- **Back**: nav-bar chevron (44×44) + iOS edge-swipe. Both always work. +- **Switcher sheet** (the power move): tapping the transcript title, or the + `squares` glyph on any nav bar, opens a bottom sheet at 65% height: + search field on top, then "Recent" chats across ALL workspaces sorted by + last activity, each row showing workspace name in `mono-micro` + status + glyph + unread badge. One tap jumps anywhere. On desktop this is ⌘K. +- **Nav bar** (all screens): height 52px + safe-area top. Leading: back + chevron. Center: `headline` title + optional `footnote` subtitle. Trailing: + contextual glyph (gear on root, squares elsewhere). Background `bg/canvas` + at 92% opacity with `backdrop-filter: saturate(1.2)` only if cheap; + otherwise solid. Bottom hairline appears only after content scrolls 1px. + +**Connection status placement** (ambient, global): the nav-bar subtitle is +the connection voice. When live it shows context (e.g. workspace name or +"3 chats"); when not live, it is replaced by the state in the state's color: +`Connecting…` / `Reconnecting…` (`status/wait`) / `Mac unreachable` +(`status/down`). Hard failures additionally get a banner (§5.7). There is no +separate "status bar widget" - the subtitle IS the truth. + +--- + +## 4. Component library (shared anatomy) + +### 4.1 Status taxonomy (used everywhere) + +| State | Glyph | Color | Meaning | +|---|---|---|---| +| `working` | pulse dot | copper | Agent actively generating/tooling | +| `waiting` | hourglass | `status/wait` | Agent blocked on user input/permission | +| `queued` | clock | `status/wait` | Message accepted, not yet running | +| `idle` | none | - | Nothing happening | +| `live` | dot (static) | `status/live` | Stream connected | +| `offline` | wifi-slash | `status/down` | Relay unreachable | +| `error` | warn | `status/down` | Failure needing action | + +### 4.2 Row (list item) + +56-64px min-height, `bg/canvas` (workspace list) or full-bleed on canvas with +hairline separators inset 16px from leading edge. Pressed: `bg/raised`. +Anatomy: [leading glyph/monogram 36×36] [12px] [title `headline` + +subtitle `callout` secondary, each 1-2 lines, ellipsize] [8px] [trailing +meta stack: time `footnote` secondary over status glyph/badge]. + +Monograms: 36×36, `r/control`, `bg/raised` fill, 1px hairline, one or two +initial letters in `headline` `text/secondary`. No per-workspace colors - +the calm comes from restraint; status carries the color. + +### 4.3 Badge + +Unread: `fill/copper` pill, `round-badge` type in `on-copper`, min 20×20, +padding 0 6px, count caps at "99+". Waiting-badge: same geometry in +`status/wait` fill with `bg/canvas`-contrast text, labeled glyph (hourglass) +when width allows. + +### 4.4 Banner + +Full-width under nav bar, min-height 44, `r/card` inset 8px each side (so it +reads as an object, not chrome), tint background + status-color glyph + +`footnote` text `text/primary` + optional trailing action in `body-em` of the +status color. Max one banner at a time; priority: auth > offline > degraded +> reconnecting > success. + +### 4.5 Buttons + +- **Primary**: `fill/copper`, `on-copper` `body-em`, height 50, `r/control`, + full-width in flows. Pressed: `accent/copper-press`. Disabled: `bg/raised` + fill + `text/tertiary` label (and a reason nearby - never a bare disabled + button, §5.6). +- **Secondary**: transparent, 1px `hairline/strong` border, `text/primary`. +- **Destructive**: as secondary but `status/down` text/border; confirmation + always required (sheet, never browser confirm()). +- **Text button**: `accent/copper` `body-em`, 44px min target. + +### 4.6 Sheet + +`bg/elevated`, `r/sheet` top corners, grabber (36×4, `hairline/strong`, +centered, 8px from top), title row optional, content, safe-area bottom +padding. Dismiss: swipe down, scrim tap, or X. Heights: 65% (switcher), +auto-content (conflict, confirmations), full (security panel on ≤599px). + +### 4.7 Tool activity card (transcript) + +The signature component. Collapsed by default, always. + +Collapsed row: height 40, `bg/raised`, `r/control`, 1px hairline, inset +12px padding. Anatomy: [tool glyph 16, `text/secondary`] [8px] [summary: +verb + object, `footnote` `text/primary`, object in `mono-micro` - e.g. +`Edited` `composer/index.ts`] [flex] [duration `mono-micro` secondary, e.g. +`8.2s`] [chevron-down 14]. + +Running variant: glyph replaced by working pulse; duration becomes live +elapsed (tabular); left border 2px `accent/copper`. + +Grouped runs: consecutive tool calls collapse into a single stack card: +summary `Ran 6 actions` + total duration. Expanding (tap, `dur/base`, +chevron rotates) reveals the individual rows indented 12px, each further +expandable to a detail well: `bg/inset`, `mono-body`, max-height 240px with +internal scroll, horizontal scroll for long lines (no wrap), top-right +`copy` glyph button (44×44 target). Failed calls: warn glyph + `status/down` +summary text; card border-left 2px `status/down`. + +Expansion state is per-message and not persisted; reopening a chat shows all +collapsed. + +--- + +## 5. Surfaces + +### 5.1 First-run pairing (from one-time link) + +Route: opened in Safari from a one-time URL generated on the Mac. + +Layout (430px, centered column, gutter 24): +- Top: 96px spacer (breathing room; this is the only "marketing" moment). +- App mark: 56×56 rounded square, `bg/raised` + hairline, containing a 24px + copper `bolt` glyph. No wordmark art; below it, `display` "Conductor + Pocket" and `callout` secondary "Remote for your Mac's Conductor". +- Pairing card (`r/card`, `bg/raised`, padding 20): + - `micro-caps` secondary: "PAIRING WITH" + - `title`: Mac name from relay, e.g. "Alex's MacBook Pro" + - `mono-micro` secondary: tailnet hostname, e.g. `alexs-mbp.tailnet-xyz.ts.net` + - Divider hairline, 16px vertical margin. + - `micro-caps` secondary: "VERIFICATION WORDS" + - Four words in `mono-body` `text/primary`, one row, separated by + middle-dot: `ember · canyon · relay · fern` + - `footnote` secondary: "Conductor on your Mac is showing the same four + words. If they don't match, don't pair." +- Primary button: "The words match - Pair" +- Text button: "Cancel" + +States: +- **Verifying** (token check in flight): card shows skeleton lines (§5.11), + button disabled with spinner replacing label. +- **Link expired/used**: replace card with error card (warn glyph, + `title` "This link has expired", `callout` secondary "Pairing links work + once. Generate a new one on your Mac: Conductor → Settings → Pocket."). + No retry button (nothing to retry). +- **Success** → advances to 5.2. + +### 5.2 Passkey enrollment and lock + +**Enrollment screen** (immediately after pairing): +- Centered: faceid glyph 44px copper, `title` "Lock this remote", + `callout` secondary, max 34ch: "Anyone with this phone could drive your + Mac. Face ID keeps it to you." +- Primary: "Enable Face ID" (triggers WebAuthn platform passkey creation). +- If Mac-side policy marks lock optional: text button "Not now". If policy + requires it: no skip control at all (do not show a disabled skip). +- Success: green check crossfade, auto-advance after 800ms to 5.9 (install + guidance) if running in Safari, else to workspace list. +- Failure/cancel of WebAuthn: inline `footnote` in `status/down` under + button: "Face ID wasn't set up. Try again." Button remains. + +**Lock gate** (every launch/foreground past auto-lock timeout): +- Solid `bg/canvas` full screen (privacy: no content behind, no blur + reliance). Centered: app mark 44px, `headline` "Locked", primary button + "Unlock with Face ID". Auto-triggers WebAuthn on appear; the button is the + retry. +- After 3 failures: `footnote` secondary "Use your Mac to re-pair if Face ID + is unavailable." (There is deliberately no password fallback on-device.) +- Unlock is instant-out: crossfade `dur/base` directly into the restored + last screen. + +### 5.3 Workspace list (root) + +Nav: large-title pattern. Collapsed bar title "Workspaces"; scrolled to top, +`display` title with `gear` trailing and `squares` (switcher) next to it. +Subtitle = connection voice (§3). + +Search field pinned below title: height 40, `r/control`, `bg/raised`, search +glyph, placeholder "Search workspaces and chats" (`text/tertiary`). Search +matches workspace names AND chat titles (results grouped, chat hits shown +with workspace in `mono-micro`). Cancel text button appears on focus. + +Sections (only when non-empty, `micro-caps` headers): +1. **ACTIVE** - workspaces with `working` or `waiting` agents, sorted: + waiting first (they need Alex), then working, by recency. +2. **RECENT** - last 5 opened, minus those above. +3. **ALL** - alphabetical remainder. + +Row (56px): monogram; title = workspace name `headline`; subtitle = +`git-branch` glyph + branch in `mono-micro` secondary; trailing = status: +- `working`: pulse dot + count if >1 agent ("2" in `round-badge` secondary). +- `waiting`: waiting-badge "needs input" (or hourglass-only under 400px). +- unread: copper badge with count. +- idle: relative time `footnote` secondary. + +Pull-to-refresh: standard rubber-band, spinner in `text/secondary`; +re-requests workspace snapshot from relay. + +Empty state (no workspaces): centered, laptop glyph 36 secondary, `headline` +"No workspaces yet", `callout` secondary "Open Conductor on your Mac and +they'll appear here." No CTA (nothing to do from the phone). + +### 5.4 Chat list (inside a workspace) + +Nav: back chevron ("Workspaces"), title = workspace name, subtitle = +connection voice or branch. Trailing: `squares`. + +Optional "New chat" control: ONLY if the relay reports the `new-chat` +capability this session: a 44px `plus` in the nav bar. Absent otherwise - +never disabled-visible (§6.1 truthfulness). + +Row (64px, two-line): title = chat title `headline` (fall back to first +user message, ellipsized); subtitle = last message preview `callout` +secondary, 2 lines max, prefixed `You: ` when applicable; trailing stack = +relative time over status glyph/badge (taxonomy 4.1). + +Sort: pinned first, then by last activity. Swipe actions (both truthful, +local-only): leading swipe = Pin/Unpin (copper), trailing = Mark read +(graphite). No delete (destructive AX operations are out of scope v1). + +Empty: `headline` "No chats in this workspace", `callout` secondary "Start +one from your Mac." (or, with `new-chat` capability, primary button +"New chat"). + +### 5.5 Transcript + +The core surface. Full-bleed on `bg/canvas`. + +**Nav**: back chevron (workspace name as label when width allows), title = +chat title (tap → switcher sheet), subtitle = connection voice; when live +and the agent is active, subtitle shows `working` pulse + "Working · 0:24" +(tabular, live) or hourglass + "Needs your input". + +**Message layout** (gutter 16): +- **Assistant text**: no bubble. Left-aligned `body` `text/primary`, + max-width 100% of column. Paragraph spacing 12px. Markdown: bold/italic + inline; lists indented 16; headings render as `body-em`; links + `accent/copper` underlined-on-press; code inline in `mono-body` on + `bg/inset` chip; code blocks in a detail-well style card (`bg/inset`, + `r/control`, `mono-body`, horizontal scroll, copy button top-right, + language tag `mono-micro` secondary). +- **User messages**: copper bubble (`bubble/user`, `on-bubble`), `r/bubble` + with bottom-trailing corner 6px, right-aligned, max-width 85% (90% under + 400px). Text `body`. Below the bubble, right-aligned meta line + `footnote` secondary - the delivery truth (§5.6): `Delivering…` / + `Delivered 9:41` (check-double glyph) / `Queued · 2 ahead` (clock) / + `Failed to deliver · Retry` (warn, "Retry" is a copper text button). +- **Tool activity**: cards per §4.7, full column width, 8px vertical margin. +- **Status row** (while agent active): below the last content block: working + pulse + `footnote` secondary label: "Working · 0:24" or "Thinking…" or + "Waiting for permission on your Mac" (hourglass, `status/wait` text - + this one is critical; it means Conductor is showing a native prompt only + the Mac can answer. If relay exposes an approve capability in future it + becomes a card with actions; v1 it is information only, with copy telling + Alex to answer on the Mac or that it's waiting). +- **Separators**: day boundaries centered `caption` secondary ("Today", + "Yesterday", else "Jul 21"). +- **Vertical rhythm**: 16px between different speakers, 8px within a run. + +**Scrolling and streaming** (exact behavior, this gets tested): +- Pinned-to-bottom when the reader is within 48px of the bottom; appended + content keeps the view glued via bottom anchoring. +- If the user scrolls up beyond 120px from bottom: unpin. New content must + NOT move the viewport (use CSS `overflow-anchor` semantics or manual top + anchoring). Show the **Latest pill**: `r/full`, `bg/elevated` + hairline + + shadow/1 (light), chevron-down + "Latest" `caption`; plus an unread-count + dot if new messages arrived while unpinned. Position: bottom-right, 12px + above composer. Tap: smooth-scroll to bottom `dur/slow`, re-pin. +- Streaming text renders in append-only chunks (fade per §2.5). Never + reflow earlier lines during a stream. +- History: reaching the top loads earlier messages with a 32px spinner row; + prepended content preserves scroll position exactly. + +**Reconnect state within transcript**: banner "Reconnecting…" (`tint/wait`, +refresh glyph). Existing content stays fully readable (never grayed). +Composer send-path degrades per §5.6. On resume: relay backfills missed +events; a subtle inline shimmer block ("catching up…" `footnote` secondary +with pulse) sits at the bottom until backfill completes, then resolves per +§2.5 motion 6. + +### 5.6 Composer and sending + +Docked at bottom; respects keyboard and safe area (§6.3). + +**Anatomy** (gutter 12, 8px vertical padding, top hairline on the dock): +- Field: `bg/raised`, 1px `hairline/strong`, `r/bubble`. Min height 44 + (one line), grows to 6 lines (~144px) then scrolls internally. Font + `body` (16px - no iOS zoom). Placeholder: "Message" (`text/tertiary`). + This is a plain contenteditable/textarea - native iOS dictation via the + keyboard mic works untouched; never intercept or custom-render input. +- Send button: 36×36 circle (44×44 target), `fill/copper` with `on-copper` + arrow-up, trailing outside the field, bottom-aligned. Hidden until the + field has content OR a send is possible to retry. +- Stop control: when the agent is `working` and the relay has the `stop` + capability, the transcript status row (§5.5) gains a trailing "Stop" + text button (`status/down`, 44px target). Tapping → button becomes + spinner "Stopping…" until relay confirms. Without the capability, no + stop control exists anywhere. Send and Stop are separate: sending while + working queues (if Conductor queues) and the bubble meta says so. + +**Send-path truth states** (the composer never lies): + +| State | Field | Send | Explanation surface | +|---|---|---|---| +| Ready (relay live + Conductor running + AX verified) | editable | enabled | none | +| Stream live, send-path unverified | editable | enabled-amber ring | first tap runs a path check, then sends or fails honestly | +| Mac unreachable | editable (drafting allowed) | replaced by `wifi-slash` chip "Can't send - Mac unreachable" (`caption`, `status/down`) | tap chip → connection sheet (§5.7) | +| Conductor not running | editable | chip "Can't send - Conductor isn't open" | tap → sheet with "Open Conductor" action iff capability | +| AX revoked | editable | chip "Can't send - permission needed on Mac" | tap → sheet with exact System Settings path | +| Locked/auth revoked | gate screens (§5.2/§5.7) override | - | - | + +Drafts persist locally per-chat (survive app kill). A chip above the field +appears when a draft exists and sending is down: "Draft saved on this +phone" (`caption` secondary) - explicitly NOT "will send when online". +There is no auto-send-on-reconnect in v1: when the path recovers, the +draft sits in the field and Alex sends it. (Rationale: an AX injection +firing minutes later into an unknown Mac state is exactly the kind of lie +this product must not tell.) + +**Send lifecycle** (per message): tap send → bubble appears immediately +with meta `Delivering…` → relay confirms injection+submit → `Delivered +9:41` → agent events stream as normal. On failure at any step: meta becomes +`Failed to deliver · Retry`; the text is preserved verbatim; Retry re-runs +the full path check first. Multiple failed sends stack as failed bubbles; +each retries independently. + +### 5.7 Connection and failure surfaces + +**Connection sheet** (from any "Can't send" chip, subtitle tap when +degraded, or Security panel → Connection): auto-height sheet titled +"Connection". Content: a live checklist, each row 44px with status glyph: + +``` +✓ This phone → Tailscale (live dot) 12 ms +✓ Relay on Alex's MacBook Pro (live dot) v1.4.2 +✕ Conductor app (down) Not running +– Accessibility permission (secondary dash) Unknown until Conductor runs +``` + +Rows in `footnote` + `mono-micro` for values. Below: contextual action +(capability-gated: "Open Conductor" primary) and "Run check again" text +button. This sheet is the single diagnostic home; every failure copy in the +app deep-links here. + +**Hard states** (banner + composer chip; content stays readable): +- **Mac offline/asleep**: banner `tint/down`, wifi-slash: "Mac unreachable · + Last synced 9:41" + action "Details" → sheet. Reconnect attempts use + backoff; subtitle shows `Reconnecting…` during attempts. +- **Conductor closed**: banner `tint/wait`, warn: "Conductor isn't running + on your Mac" + "Details". +- **Accessibility unavailable**: banner `tint/wait`: "Reading is live, but + sending needs a permission on your Mac" + "How" → sheet row expands: + "On your Mac: System Settings → Privacy & Security → Accessibility → + enable Conductor Pocket Relay." in `footnote`, path segments in + `mono-micro`. +- **Auth expired/revoked**: full-screen gate (not a banner): lock glyph, + `title` "This device was signed out", `callout` secondary "Its access was + revoked or expired. Pair again with a fresh link from your Mac." Primary: + none (can't self-serve). Text: "How to re-pair" → expands the + Conductor → Settings → Pocket instruction. All cached content is purged + on revocation. + +### 5.8 Draft conflict + +Trigger: relay reports the Mac's Conductor composer already contains text +differing from the outgoing send. + +Auto-height sheet, title "Unsent text on your Mac": +- Card 1 (`bg/inset`, `r/card`): `micro-caps` secondary "ON YOUR MAC", + then the Mac draft in `mono-body`, max-height 160 internal scroll. +- Card 2: `micro-caps` "FROM THIS PHONE", your message in `body`. +- Buttons (stacked, full-width): Primary "Replace and send" (destructive + intent but primary styling - it's the expected path; label carries the + truth). Secondary "Keep the Mac draft" (dismisses; your text stays in the + phone composer untouched). Text button "Cancel". + +No append option: merging two drafts into one AX injection produces +garbage-order text nobody intended. + +### 5.9 Install-to-home-screen guidance + +Shown: (a) end of pairing when `display-mode` is browser, (b) anytime from +Security panel → "Install on this phone". + +Full screen: `title` "Install Conductor Pocket", `callout` secondary "Run +it full-screen, off the Home Screen, like an app." Then three numbered +steps, each a 56px row (number in monogram square, `callout` text, glyph): + +1. Tap the Share button in Safari (`share-ios` glyph inline) +2. Choose "Add to Home Screen" (`plus` glyph) +3. Open **Conductor Pocket** from your Home Screen + +Footer `footnote` secondary: "You can keep using it in Safari, but the +installed app hides browser chrome and remembers your session better." +Text button "Done". No fake progress; we cannot detect completion, so we +don't pretend to. + +If already standalone: this surface is only reachable from Security panel +and shows a green check + "Installed - you're running the app." state. + +### 5.10 Security panel + +Push (from root gear). ≤599px: full screen; ≥600px: 480px sheet/pane. +Title "Security & Devices". + +Sections (`micro-caps` headers, cards of rows on `bg/raised`): + +**THIS PHONE** +- Row: phone glyph, "This iPhone", subtitle `mono-micro` paired date + + tailnet IP. Chip: "Face ID on" (`tint/live`) or button "Enable Face ID". +- Row: "Auto-lock" with trailing value + chevron → options sheet: + Immediately / After 1 min / After 5 min / After 15 min (radio rows, 44px). +- Row: "Clear cached transcripts" - text-button styled `status/down`; + confirm sheet: "Removes transcript copies stored on this phone. Your Mac + keeps everything." / "Clear" destructive + Cancel. + +**PAIRED DEVICES** (from relay) +- Row per device: glyph (phone/laptop), name, subtitle `mono-micro` "Last + seen 2m ago · 100.84.x.x". Trailing "Revoke" destructive text button → + confirm sheet: "Revoke 'iPhone 15'? It signs out immediately and its + cached data is purged next time it connects." Current device labeled + "(this phone)" and its revoke reads "Sign out this phone". +- Footer action: "Revoke all other devices" (destructive, confirm). + +**CONNECTION** +- Live rows mirroring the connection sheet (§5.7): Mac name, relay version, + latency (`mono-micro`, tabular, updates ≤1Hz), stream status. +- Row: "Test send path" → runs the full checklist inline with per-row + spinners resolving to ✓/✕; failures show their fix copy. This is the + feature that makes the product trustworthy - it must be real, never + simulated. + +**APP** +- Row: "Install on this phone" (§5.9). Version `mono-micro` footer: + `pocket 1.0.0 · relay 1.4.2`. + +### 5.11 Empty / loading / error / success grammar + +- **Skeletons**: `bg/raised` blocks, `r/control`, opacity pulse 0.5→0.8, + 1.6s (no shimmer sweep). Lists: 6 skeleton rows. Transcript cold-open: + paint cached snapshot instantly; only a virgin chat shows 3 skeleton + lines. Skeletons never exceed 800ms without either content or an error. +- **Empty**: glyph 36 secondary + `headline` + one `callout` sentence, + centered, no illustration art. (Copy per surface, §9.) +- **Error**: same grammar with warn glyph + specific reason + one concrete + action. Generic "Something went wrong" is banned; every error names the + failing thing (§9). +- **Success**: transient only - green check crossfade on the acting control + or resolve-banner (§2.5 motion 6). No success modals, no confetti, ever. + +--- + +## 6. System behaviors + +### 6.1 Truthfulness doctrine (engineering-facing, testable) + +1. Any control whose backing capability is unverified this session is NOT + rendered. Never render disabled controls for missing capabilities. +2. Disabled-but-visible is reserved for temporarily invalid input (empty + composer) - and always with a visible reason within one tap. +3. "Delivered" means relay-confirmed injection AND submission. "Sent" is + never displayed as a state name (ambiguous). +4. Timestamps shown to the user are device-local formatted, sourced from + relay event time; never claim "now" for cached data - stale surfaces + show "Last synced H:MM". +5. No optimistic connection states: `live` requires an open SSE stream with + a heartbeat within the last 10s (assumption, §12). + +### 6.2 Safe areas and PWA chrome + +- `viewport-fit=cover`; all fixed chrome pads with + `env(safe-area-inset-*)`: nav top, composer/dock bottom, sheets bottom, + Latest pill (offset composer height + 12 + inset). +- Standalone: `black-translucent` status bar; canvas paints behind the + status bar; nav bar content starts below inset. Detect + `(display-mode: standalone)` for install-state logic. +- Landscape phone: insets left/right honored; composer max-width 680 + centered; otherwise unchanged (no special landscape layout in v1). + +### 6.3 Keyboard + +- `interactive-widget=resizes-content`; the composer rides the keyboard + with zero gap. +- If the transcript was pinned-to-bottom when the keyboard opens, it stays + pinned (re-scroll after resize). +- Tapping the transcript does NOT dismiss the keyboard; an explicit + downward drag on the transcript does (`overscroll` gesture), matching + iMessage muscle memory. +- Enter inserts newline on iOS (send is the button); on hardware keyboards + and desktop: Enter sends, Shift+Enter newlines. + +### 6.4 Scrolling + +- `overscroll-behavior: none` on body; `contain` on panes. Momentum + scrolling native. Rubber-band allowed inside transcript. +- Scroll anchoring rules as §5.5. This is acceptance-tested (§11). + +### 6.5 Local cache (device only - no cloud) + +Last 50 messages + metadata per chat, workspace/chat lists, and route are +snapshotted to device storage (IndexedDB) for instant resume, purged by +revocation and by the Security panel control. This is a privacy-relevant +default I am asserting, not something specified upstream - confirm (§12). + +### 6.6 Performance budgets + +- Cold launch (installed, cached) to painted last-transcript: < 600ms on + iPhone 17 Pro Max. +- Tap-to-pressed feedback: < 80ms. Route push: < 350ms total. +- Streaming append: no frame > 8ms scripting on mid-tier; chunk batching + ≥ 60ms windows (don't render per-token). +- Total asset weight (no external assets): < 300KB gzipped app shell. + +--- + +## 7. Responsive breakpoints + +| Range | Layout | +|---|---| +| 360-599px | Single pane stack (primary spec, as above). At 360-399: gutters 12, bubble max-width 90%, waiting-badge collapses to glyph-only, nav back label hidden (chevron only). | +| 600-1023px | Two panes: left 320px chat list (of the active workspace, workspace switcher via `squares`), right transcript. Nav bars merge into one 52px bar spanning both. Sheets become anchored popovers (switcher: 400×560 from the title). | +| ≥1024px (desktop) | Three panes: workspaces rail 260px (`bg/canvas`, rows as §5.3, collapsible to 72px icon rail via chevron at rail bottom) · chat list 320px (`bg/canvas` + trailing hairline) · transcript flex, content column max-width 720px centered, composer matches column. Top bar 48px: connection voice left (dot + "Live · alexs-mbp · 12ms" in `footnote`/`mono-micro`), gear right. ⌘K opens switcher (as a centered 560px modal), Esc closes, ↑↓ navigate, Enter opens. Hover states: rows tint `bg/raised`; tool cards show chevron on hover. Cursor: default pointer semantics; no custom cursors. | + +Transcript line length: at ≥600px, assistant text max-width 68ch even inside +wider columns. + +Desktop is a convenience surface; it must not grow features mobile lacks +(except keyboard shortcuts listed above). + +--- + +## 8. Accessibility (WCAG 2.1 AA) + +1. **Contrast**: all text/background pairs in §2.2 meet 4.5:1 (normal) or + 3:1 (≥18.66px bold/24px). `text/tertiary` is confined to placeholder and + disabled roles. Status glyph + label pairing means color is never the + sole channel. CI check: automated contrast test over the token matrix + for both themes (§11). +2. **Targets**: every interactive element ≥44×44 CSS px hit area (visual + size may be smaller; expand via padding/pseudo-element). List rows + full-bleed tappable. +3. **Dynamic Type-minded**: all type in rem; layout tested at 130% root + scale: rows grow, nothing truncates below 2 lines, composer and nav + remain usable; no fixed-height text containers. +4. **VoiceOver**: nav landmarks (banner/main); transcript is a list; + messages labeled "You said …" / "Conductor replied …"; tool cards + labeled "Activity: Edited composer/index.ts, 8 seconds, collapsed, + button"; connection voice is `aria-live="polite"`; streaming announces + at message completion, not per chunk (throttle); banners are + `role="status"`, auth gate `role="alert"`. Focus is trapped in sheets, + returned on dismissal. +5. **Reduced motion/transparency**: per §2.5; privacy shield and sheets use + solid fills, no blur dependence. + +--- + +## 9. Copy reference (verbatim strings) + +| Key | String | +|---|---| +| pairing.title | Pair with "{macName}" | +| pairing.words.help | Conductor on your Mac is showing the same four words. If they don't match, don't pair. | +| pairing.cta | The words match - Pair | +| pairing.expired.title | This link has expired | +| pairing.expired.body | Pairing links work once. Generate a new one on your Mac: Conductor → Settings → Pocket. | +| lock.title | Locked | +| lock.cta | Unlock with Face ID | +| enroll.title | Lock this remote | +| enroll.body | Anyone with this phone could drive your Mac. Face ID keeps it to you. | +| conn.reconnecting | Reconnecting… | +| conn.offline.banner | Mac unreachable · Last synced {time} | +| conn.conductorClosed.banner | Conductor isn't running on your Mac | +| conn.ax.banner | Reading is live, but sending needs a permission on your Mac | +| conn.ax.how | On your Mac: System Settings → Privacy & Security → Accessibility → enable Conductor Pocket Relay. | +| conn.live.resolve | Live | +| composer.placeholder | Message | +| composer.cant.offline | Can't send - Mac unreachable | +| composer.cant.conductor | Can't send - Conductor isn't open | +| composer.cant.ax | Can't send - permission needed on Mac | +| composer.draft.saved | Draft saved on this phone | +| msg.delivering | Delivering… | +| msg.delivered | Delivered {time} | +| msg.queued | Queued · {n} ahead | +| msg.failed | Failed to deliver · Retry | +| status.working | Working · {m:ss} | +| status.thinking | Thinking… | +| status.waiting | Waiting for permission on your Mac | +| status.stopping | Stopping… | +| conflict.title | Unsent text on your Mac | +| conflict.replace | Replace and send | +| conflict.keep | Keep the Mac draft | +| auth.revoked.title | This device was signed out | +| auth.revoked.body | Its access was revoked or expired. Pair again with a fresh link from your Mac. | +| empty.workspaces.title | No workspaces yet | +| empty.workspaces.body | Open Conductor on your Mac and they'll appear here. | +| empty.chats.title | No chats in this workspace | +| empty.chats.body | Start one from your Mac. | +| search.empty | No matches for "{query}" | +| install.title | Install Conductor Pocket | +| install.body | Run it full-screen, off the Home Screen, like an app. | +| security.clearCache.confirm | Removes transcript copies stored on this phone. Your Mac keeps everything. | +| security.revoke.confirm | Revoke "{device}"? It signs out immediately and its cached data is purged next time it connects. | +| catchup | catching up… | +| pill.latest | Latest | + +Tone rules: sentence case everywhere; no exclamation marks; failures name +the broken thing and the fix location; the Mac is always "your Mac"; the +agent is "Conductor", never "the AI". + +--- + +## 10. Wireframes + +### 10.1 iPhone 430×932, dark - Transcript (streaming), the hero screen + +``` +┌─────────────────────────────────────────┐ +│ ▂▂ status bar (canvas bleeds behind) ▂▂ │ safe-area top +│ ‹ chicago Fix relay auth ▣ │ nav 52px: back(label=workspace), +│ ● Working · 0:24 │ title(tap→switcher), squares glyph +│─────────────────────────────hairline────│ (hairline only when scrolled) +│ │ +│ Sounds right. The token refresh │ assistant `body` 16/24, +│ fails because the relay caches the │ no bubble, gutter 16 +│ old cert. Two fixes: │ +│ │ +│ ┌───────────────────────────────────┐ │ tool card collapsed 40px +│ │ ▤ Edited relay/auth.ts 4.1s ⌄ │ │ `bg/raised`, r10, mono path +│ └───────────────────────────────────┘ │ +│ ┌───────────────────────────────────┐ │ running card: copper 2px +│ │ ▮ Running npm test 0:12 ⌄ │ │ left edge, pulse glyph, +│ └───────────────────────────────────┘ │ live tabular elapsed +│ │ +│ ● Working · 0:24 Stop │ status row: pulse + footnote, +│ │ Stop = status/down text btn +│ ┌──────────────┐ │ +│ │ run the full │ │ user bubble: copper #7A4526, +│ │ suite after │ │ r18 (br 6), max-w 85% +│ └──────────────┘ │ +│ ✓✓ Delivered 9:41│ meta footnote secondary +│ ┌────────┐ │ +│ │⌄ Latest│ │ pill appears only unpinned +│ └────────┘ │ +│─────────────────────────────hairline────│ +│ ┌─────────────────────────────┐ ┌───┐ │ composer dock: field r18 +│ │ Message │ │ ↑ │ │ grows 1→6 lines; send 36px +│ └─────────────────────────────┘ └───┘ │ copper circle (44 target) +│ ▂▂▂▂▂▂▂▂ home indicator ▂▂▂▂▂▂▂▂▂▂ │ safe-area bottom +└─────────────────────────────────────────┘ +``` + +Same screen, reconnecting: subtitle → "Reconnecting…" amber; banner card +inset 8px below nav: `⟳ Reconnecting…`; send button → `⚠ Can't send - Mac +unreachable` chip; content unchanged and readable; "catching up…" pulse +line above composer once stream resumes. + +### 10.2 iPhone 430 - Workspace list (root) + +``` +┌─────────────────────────────────────────┐ +│ Workspaces ▣ ⚙ │ large title `display`, +│ ● Live · alexs-mbp │ connection voice footnote +│ ┌─────────────────────────────────────┐ │ +│ │ ⌕ Search workspaces and chats │ │ 40px, r10, raised +│ └─────────────────────────────────────┘ │ +│ ACTIVE │ micro-caps +│ ┌──┐ chicago ⏳ │ 56px row: monogram 36, +│ │Ch│ ⎇ understand-conductor… needs input│ waiting badge amber +│ ├──┤ innerdm ● │ +│ │In│ ⎇ main 2 │ pulse + agent count +│ RECENT │ +│ │Ov│ ovo-academy ❸ │ copper unread badge +│ │Ra│ radar-house 2h │ idle: relative time +│ ALL │ +│ │Bi│ biologix-site 1d │ +│ │… │ +└─────────────────────────────────────────┘ +``` + +### 10.3 iPhone 430 - Switcher sheet (65%) + +``` +│░░░░░░░░░ scrim 44% ░░░░░░░░░│ +┌──────── grabber ────────────┐ r16 top, bg/elevated +│ ┌─────────────────────────┐ │ +│ │ ⌕ Search │ │ autofocused? NO - focus on +│ └─────────────────────────┘ │ demand only (keyboard jump) +│ RECENT │ +│ Fix relay auth ● │ chat title headline +│ chicago · 2m mono │ workspace mono-micro +│ Passport sessions ❷ │ +│ biologix-site · 1h │ +│ … │ +└─────────────────────────────┘ +``` + +### 10.4 iPhone 430 - Pairing, Lock, Conflict (compact) + +Pairing: centered column per §5.1 - mark, names card with mono tailnet + +four mono verification words, primary CTA. Lock: solid canvas, mark, +"Locked", one primary button. Conflict sheet: two stacked cards (mono Mac +draft well / your text), three stacked buttons. + +### 10.5 Desktop ≥1024, light + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ ● Live · alexs-mbp · 12ms ⚙ │ 48px bar +├──────────────┬───────────────┬─────────────────────────────────────┤ +│ Workspaces │ chicago + │ (transcript column │ +│ ⌕ search │ ⌕ search │ max-width 720 centered) │ +│ ACTIVE │ Fix relay ● 2m│ assistant text 68ch max │ +│ chicago ⏳│ Passport 1h │ ┌ tool card ───────────┐ │ +│ innerdm ● │ Sess memo 1d │ └───────────────────────┘ │ +│ RECENT │ │ ┌ user bubble ┐ │ +│ ovo-academy ❸│ │ └─────────────┘ │ +│ … │ │ ● Working · 0:24 Stop │ +│ │ │ ┌ composer ──────────────┐ (↑) │ +│ ⌄ collapse │ │ └────────────────────────┘ │ +└──────────────┴───────────────┴─────────────────────────────────────┘ + 260px rail 320px list flex; white cards on #F7F5F1 +``` + +⌘K switcher modal centered 560×480. All tokens identical to mobile. + +--- + +## 11. Acceptance criteria + +Structure/navigation +1. Cold launch (installed) lands on the last open transcript with cached + content painted < 600ms, before the stream connects. +2. No tab bar exists at any width. Switcher sheet reachable from every + screen in ≤1 tap; any recent chat reachable in ≤2 taps from anywhere. +3. Edge-swipe back works on every pushed screen. + +Truthfulness +4. With SSE closed > 10s, the subtitle leaves "Live" and the send button is + replaced by a reason chip; both within 1s of detection. No message can + enter a "Delivering…" state while the path is down. +5. "Delivered" appears only after relay confirms injection + submission + (verify with a relay that ACKs late: meta must stay "Delivering…"). +6. Kill Conductor on the Mac: banner names Conductor specifically; "Open + Conductor" action appears only when the relay advertises the capability. +7. Revoke AX permission: reading continues, composer chip switches to the + permission reason, connection sheet row shows the exact settings path. +8. Revoke device from Mac: phone hits full-screen signed-out gate on next + event; cached data purged; no stale transcript visible after. +9. No disabled control exists without a visible reason within one tap; no + control renders for an unadvertised capability (audit all: new-chat, + stop, open-Conductor). + +Transcript/streaming +10. Pinned at bottom: streaming never causes visible jumps; unpinned: + viewport is stable while content streams, Latest pill appears with + count; tapping it re-pins. +11. Tool cards: collapsed by default including after reload; grouped runs + show count + total time; expanded detail scrolls internally and + horizontally; copy button works; failed calls visibly distinct. +12. History prepend preserves exact scroll position. + +Composer/keyboard +13. Field font ≥16px (no iOS zoom on focus); dictation via the system + keyboard works with no custom interception; field grows to 6 lines + then scrolls. +14. Keyboard open keeps composer attached (no gap, no overlap) with + `viewport-fit=cover` in standalone; transcript stays pinned if it was. +15. Draft persists across app kill; draft-conflict sheet shows both texts + verbatim; "Keep the Mac draft" leaves the phone text in the field. + +Security surfaces +16. Pairing link is single-use: second open shows the expired state. + Verification words render identically to the Mac's (string equality). +17. Lock gate shows no content whatsoever (solid fill) and auto-prompts + Face ID on foreground past the auto-lock threshold. +18. Per-device revoke and revoke-all require a confirm sheet naming the + device; "Test send path" performs real checks (verify by breaking each + link in the chain and watching the matching row fail). + +Visual/system +19. Both themes pass automated 4.5:1 checks for every text token pair used; + status is never color-only (glyph or label present in all instances). +20. All interactive targets ≥44×44 (automated audit); rows full-bleed + tappable. +21. Layout intact at 360px width and at 130% root font scale (no clipped + controls, no sub-2-line truncation of message text). +22. Safe areas: no content under the home indicator; status bar area shows + canvas color in standalone; Latest pill clears the composer at every + keyboard state. +23. Zero external network requests for fonts/assets (audit the network + panel); app shell < 300KB gzipped. +24. `prefers-reduced-motion` removes pulse/slide animations (crossfades + only) with all states still distinguishable. + +Screenshot review set (for my later visual review - capture exactly): +430px dark: workspace list, transcript streaming, transcript reconnecting, +composer 4-line + keyboard, switcher sheet, pairing, lock, conflict sheet, +security panel, install guidance. 430px light: workspace list, transcript +streaming. 360px dark: transcript. 1280px light + dark: three-pane with +switcher modal open. + +--- + +## 12. My assumptions (confirm before building the dependent parts) + +These are mine, not derived from the brief. Each gates specific UI: + +1. **Relay advertises capabilities per session** (`new-chat`, `stop`, + `open-conductor`, `read-mac-draft`). The truthfulness doctrine depends + on this handshake existing. If it doesn't, those controls ship absent, + not guessed. +2. **Heartbeat ≤10s on the SSE stream** defines "live". Adjust thresholds + to the real heartbeat interval. +3. **Device-local snapshot cache (§6.5) is acceptable** under "no cloud + transcript storage". If Alex wants zero at-rest copies on the phone, + drop the cache; cold launch then shows skeletons ~1-2s and criterion 1 + relaxes to "paint within 300ms of first stream data". +4. **No auto-send of drafts on reconnect** (deliberate, §5.6). If lived + experience demands queued sends, that's a v2 with an explicit armed + "will send when Mac returns" state, never silent. +5. **Waiting-for-permission is read-only in v1** (answer on the Mac). If + the relay can click permission prompts later, that state upgrades to an + action card - highest-value future addition. +6. **Verification words**: relay generates a 4-word SAS from the pairing + token and shows it in Conductor's Pocket pane. If the Mac side can't + display words, fall back to a 6-char mono code, same layout. +``` diff --git a/tools/conductor-pocket/README.md b/tools/conductor-pocket/README.md new file mode 100644 index 00000000..7b657f4f --- /dev/null +++ b/tools/conductor-pocket/README.md @@ -0,0 +1,113 @@ +# Conductor Pocket + +Conductor Pocket is a private, installable iPhone web app for reading and +steering the Conductor chats that already run on this Mac. + +It is deliberately not a second agent client: + +- Conductor stays the source of truth. +- Every existing Claude, Codex, Cursor, or ACP session keeps the account, + model, permissions, working directory, and branch Conductor assigned to it. +- Transcript reads use SQLite's read-only mode. +- Sends go through Conductor's real on-screen composer and are acknowledged + only after its Send control clears the draft. +- No transcript, credential, or agent token is uploaded to a hosting service. + +## What “live” means + +The relay watches Conductor's SQLite WAL and emits an event within roughly +60 ms of a write. The phone then requests only rows after its last cursor. +On a healthy tailnet, replies normally appear a few hundred milliseconds +after Conductor saves them. + +Phone sends are serialized through macOS Accessibility. Selecting the target +workspace/chat, setting its real draft, and confirming submit typically takes +1–3 seconds. A draft already present on the Mac produces a conflict sheet; +Pocket never overwrites or merges it silently. + +## Security model + +The production setup is defense in depth: + +1. The Node relay binds only to `127.0.0.1`. +2. Tailscale **Serve** provides private tailnet HTTPS. Funnel is refused. +3. Tailscale's authenticated identity header is captured at pairing and must + match on every request. +4. A 192-bit, single-use pairing link expires after 15 minutes and stays in + the URL fragment so it is not sent in HTTP request logs. +5. Pairing enrolls a WebAuthn platform passkey. Unlock requires user + verification (Face ID on iPhone). +6. The device session is a random `Secure`, `HttpOnly`, `SameSite=Strict`, + `__Host-` cookie stored only as a SHA-256 digest on the Mac. +7. Every mutation requires an exact Origin match and a device-specific CSRF + proof. Sends additionally require an idempotency key. +8. The app uses a restrictive CSP, has no CDN/fonts/analytics, and never + caches `/api/` responses in its service worker. +9. A server-side five-minute idle deadline requires Face ID again even if iOS + kills the PWA; a one-hour absolute deadline caps continuously active use. +10. Cached transcript snapshots stay in the PWA's device-local IndexedDB, + render only after Face ID unlock, retain at most 50 events per visited + chat, and can be cleared from Security & Devices. + +See [SECURITY.md](./SECURITY.md) for trust boundaries and failure behavior. + +## Requirements + +- macOS with Conductor installed and running. +- Node 22.5 or newer. +- Tailscale signed into the same tailnet on the Mac and iPhone. +- The Mac awake and online. Conductor can continue working with its window in + the background, but it must have a window available for phone sends. +- One-time macOS Accessibility permission for the relay's Node executable. + +## Setup + +From this directory: + +```sh +npm install +npm run setup +npm run install:relay +npm run doctor +``` + +`npm run setup` prints two values: + +- a single-use pairing URL to open on the iPhone; +- a six-character verification code that must match the phone. + +The installer refuses to continue if any Tailscale Funnel exists or if a +Serve configuration would be overwritten. It creates a user LaunchAgent and +a private Serve proxy only after those checks. It first copies the audited +runtime into `~/.config/conductor-pocket/runtimes/`, so archiving or deleting +this source workspace cannot break the installed relay. + +Open the pairing URL on the iPhone while Tailscale is connected, compare the +code, enroll Face ID, then use Safari's Share → Add to Home Screen. + +To pair another phone later: + +```sh +npm run pair +``` + +## Development + +An insecure loopback-only mode exists for automated browser tests: + +```sh +npm run setup -- --development --config /tmp/conductor-pocket-test/config.json +npm start -- --config /tmp/conductor-pocket-test/config.json +``` + +Development mode disables Tailscale identity enforcement and accepts HTTP +only on loopback. Never use it for phone access. + +## Known v1 limits + +- Agent permission prompts remain Mac-only. +- Stop, new-chat, and open-Conductor controls are absent because the relay + cannot yet prove those capabilities safely. +- Attachments are read-only metadata; phone uploads are not implemented. +- The Mac must not be asleep. A future Wake-on-LAN helper can improve this, + but it is intentionally outside the security boundary of v1. diff --git a/tools/conductor-pocket/SECURITY.md b/tools/conductor-pocket/SECURITY.md new file mode 100644 index 00000000..00da8cc7 --- /dev/null +++ b/tools/conductor-pocket/SECURITY.md @@ -0,0 +1,119 @@ +# Conductor Pocket security + +## Protected assets + +- Conductor transcript content and tool activity. +- The ability to inject a user message into an existing agent session. +- Session/account selection, workspace paths, branches, and model metadata. +- Device passkeys and relay session tokens. + +Codex, Claude, GitHub, and other provider credentials are not copied into +Pocket. They remain inside the processes and profiles Conductor already owns. + +## Trust boundaries + +### Trusted + +- Alex's logged-in macOS account and its local files. +- The installed Conductor application. +- The loopback interface. +- The authenticated Tailscale Serve proxy after its identity header is + matched to the paired identity. +- The iPhone platform authenticator after WebAuthn user verification. + +### Not trusted + +- The public internet. +- The LAN. +- Other tailnet users or shared-device users. +- Browser origins other than the configured tailnet HTTPS origin. +- User-controlled transcript content, workspace names, chat titles, and + message text. +- Requests replayed after uncertain network failures. + +## Controls + +### Network + +The HTTP server refuses non-loopback binds in config validation. It validates +the Host header and accepts only the configured tailnet host or explicit +loopback development hosts. Tailscale Serve strips spoofed identity headers +before adding authenticated values. The installer refuses all nonempty +Funnel configurations and will not replace an existing Serve configuration. + +### Authentication + +Pairing uses a random 192-bit secret stored only as a hash. The secret is +carried in a URL fragment, expires in 15 minutes, and is consumed after a +successful passkey registration. A short authentication cookie protects the +registration ceremony. + +Each paired device gets a WebAuthn credential and a separate 256-bit session +token. Only the token digest and public credential key are persisted. The +server forgets unlock state on restart. Face ID unlocks for at most one hour +and expires after five minutes without a heartbeat from a visible app. SSE +heartbeats never extend that deadline. The PWA stops its stream while hidden +and adds an opaque privacy shield before iOS takes an app-switcher snapshot. + +### Authorization and request integrity + +- Every protected request binds the cookie to its original Tailscale login. +- Mutations require the exact configured Origin. +- Mutations require an HMAC-derived, device-specific CSRF token. +- Send requests require an idempotency key and are serialized globally so + concurrent UI automation cannot target different chats at once. +- An explicit retry reuses its original idempotency key, so a lost HTTP + response does not normally create a second Conductor message. +- Pairing attempts are rate-limited. +- Input bodies and user messages have hard byte limits. + +### Conductor integrity + +The relay opens Conductor's SQLite database with `readOnly: true` and +`PRAGMA query_only = ON`. It does not insert queue rows, edit session state, +or connect to Conductor's sidecar socket. + +A send uses macOS Accessibility to: + +1. select the database-resolved workspace; +2. select the database-resolved session title and duplicate ordinal; +3. refuse a differing Mac draft; +4. set the real composer value; +5. identify the rightmost enabled Send control inside that composer group; +6. press it; +7. report delivery only after the composer clears. + +If any check fails, the phone sees a specific failure code and the message +remains available for explicit retry. Reconnect never auto-sends a draft. + +### Browser + +The CSP allows only same-origin code, styles, images, and connections. +Framing, object embedding, referrers, camera, microphone, location, payment, +USB, and serial access are disabled. The UI builds transcript nodes with +`textContent`; transcript Markdown is never injected as HTML. + +The service worker caches only the app shell and explicitly excludes `/api/`. +Device-local transcript snapshots are bounded and are not rendered until +after Face ID unlock. Revocation instructs the connected client to purge +them; a device that never reconnects remains protected by iOS Data +Protection and the app's Face ID gate. + +## Residual risks + +- A process already running as Alex can read the Conductor database or use + macOS UI automation. Pocket does not claim to defend against compromise of + the Mac user account. +- macOS Accessibility permission is broad. The relay's Node executable must + be trusted and should not be replaced by an untrusted binary. +- UI automation depends on Conductor's accessible structure. Version changes + fail closed: a missing workspace, session, composer, or enabled Send + control produces an error rather than a guessed click. +- Idempotency history is held in relay memory. An unlikely relay restart after + Conductor accepts a message but before the phone receives the response can + still make an explicit retry ambiguous; Pocket never retries automatically. +- Tailscale users with access to the Mac's Serve endpoint can reach the + unauthenticated app shell and pairing endpoint, but cannot pair without the + high-entropy one-time link and matching identity. +- Device-local browser storage cannot be remotely erased while the iPhone is + permanently offline. Revoke plus iOS device security is the recovery path. diff --git a/tools/conductor-pocket/package-lock.json b/tools/conductor-pocket/package-lock.json new file mode 100644 index 00000000..032da1f2 --- /dev/null +++ b/tools/conductor-pocket/package-lock.json @@ -0,0 +1,279 @@ +{ + "name": "@ovo/conductor-pocket", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@ovo/conductor-pocket", + "version": "0.1.0", + "dependencies": { + "@simplewebauthn/server": "13.3.2" + }, + "engines": { + "node": ">=22.5.0" + } + }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, + "node_modules/@peculiar/asn1-android": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz", + "integrity": "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", + "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", + "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", + "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", + "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-rsa": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", + "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", + "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pfx": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", + "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", + "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", + "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@simplewebauthn/server": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz", + "integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + } + } +} diff --git a/tools/conductor-pocket/package.json b/tools/conductor-pocket/package.json new file mode 100644 index 00000000..ad57aec3 --- /dev/null +++ b/tools/conductor-pocket/package.json @@ -0,0 +1,22 @@ +{ + "name": "@ovo/conductor-pocket", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22.5.0" + }, + "scripts": { + "check": "node --check src/server.mjs && node --check src/security.mjs && node --check src/conductor-db.mjs && node --check src/accessibility.mjs && node --check src/tailscale-config.mjs && node --check scripts/install-relay.mjs && node --check public/app.js && node --check public/service-worker.js && npm test", + "doctor": "node --no-warnings=ExperimentalWarning src/cli.mjs doctor", + "install:relay": "node --no-warnings=ExperimentalWarning scripts/install-relay.mjs", + "pair": "node --no-warnings=ExperimentalWarning src/cli.mjs pair", + "setup": "node --no-warnings=ExperimentalWarning src/cli.mjs setup", + "start": "node --no-warnings=ExperimentalWarning src/cli.mjs serve", + "start:ui-fixture": "node --no-warnings=ExperimentalWarning scripts/ui-fixture.mjs", + "test": "node --no-warnings=ExperimentalWarning --test" + }, + "dependencies": { + "@simplewebauthn/server": "13.3.2" + } +} diff --git a/tools/conductor-pocket/public/app.css b/tools/conductor-pocket/public/app.css new file mode 100644 index 00000000..bdf9836b --- /dev/null +++ b/tools/conductor-pocket/public/app.css @@ -0,0 +1,1444 @@ +:root { + color-scheme: dark light; + --font-text: -apple-system, BlinkMacSystemFont, system-ui, "Helvetica Neue", sans-serif; + --font-mono: ui-monospace, "SF Mono", Menlo, monospace; + --font-round: ui-rounded, -apple-system, system-ui, sans-serif; + --canvas: #141210; + --raised: #1c1a17; + --elevated: #242019; + --inset: #0e0d0b; + --hairline: #2e2a25; + --hairline-strong: #3d372f; + --text-primary: #f4f1ec; + --text-secondary: #a9a199; + --text-tertiary: #746d64; + --copper: #d08a54; + --copper-press: #b9754c; + --copper-fill: #c87e4c; + --on-copper: #211208; + --user-bubble: #7a4526; + --on-bubble: #f6ede4; + --live: #4cc38a; + --wait: #e3b341; + --down: #e5684f; + --live-tint: color-mix(in srgb, var(--live) 12%, transparent); + --wait-tint: color-mix(in srgb, var(--wait) 12%, transparent); + --down-tint: color-mix(in srgb, var(--down) 12%, transparent); + --scrim: rgba(0, 0, 0, 0.44); + --screen-gutter: 16px; + --nav-height: 52px; + --composer-height: 68px; + --sheet-shadow: 0 -12px 48px rgba(0, 0, 0, 0.5); + font-family: var(--font-text); + font-size: 16px; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +@media (prefers-color-scheme: light) { + :root { + --canvas: #f7f5f1; + --raised: #ffffff; + --elevated: #ffffff; + --inset: #efebe4; + --hairline: #e5e0d8; + --hairline-strong: #cfc7ba; + --text-primary: #211d19; + --text-secondary: #6b645a; + --text-tertiary: #9a9284; + --copper: #9c5730; + --copper-press: #7f4626; + --copper-fill: #9c5730; + --on-copper: #fff7f0; + --user-bubble: #9c5730; + --on-bubble: #fff7f0; + --live: #1d7a50; + --wait: #8a6116; + --down: #b3341f; + --live-tint: color-mix(in srgb, var(--live) 10%, transparent); + --wait-tint: color-mix(in srgb, var(--wait) 10%, transparent); + --down-tint: color-mix(in srgb, var(--down) 10%, transparent); + --scrim: rgba(33, 29, 25, 0.32); + --sheet-shadow: 0 -12px 48px rgba(33, 29, 25, 0.18); + } +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: var(--canvas); + color: var(--text-primary); +} + +body { + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: transparent; + overscroll-behavior: none; +} + +button, +input, +textarea { + color: inherit; + font: inherit; +} + +button { + border: 0; +} + +button, +[role="button"] { + touch-action: manipulation; +} + +button:focus-visible, +input:focus-visible, +textarea:focus-visible, +[tabindex]:focus-visible { + outline: 2px solid var(--copper); + outline-offset: 2px; +} + +button:active { + transform: scale(0.98); +} + +.icon-sprite { + position: absolute; + width: 0; + height: 0; + overflow: hidden; +} + +.icon { + display: inline-block; + width: 20px; + height: 20px; + flex: 0 0 auto; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.8; +} + +.sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +#app, +.app-shell, +.gate { + width: 100%; + height: 100%; +} + +.privacy-shield { + position: fixed; + z-index: 1000; + inset: 0; + background: var(--canvas); +} + +.app-shell { + display: grid; + grid-template-columns: minmax(0, 1fr); + background: var(--canvas); +} + +.panel { + position: relative; + display: none; + min-width: 0; + height: 100%; + overflow: hidden; + background: var(--canvas); +} + +.panel.is-active { + display: flex; + flex-direction: column; +} + +.panel-nav { + position: relative; + z-index: 5; + display: grid; + grid-template-columns: 48px minmax(0, 1fr) 48px; + align-items: center; + min-height: calc(var(--nav-height) + env(safe-area-inset-top)); + padding: env(safe-area-inset-top) 4px 0; + border-bottom: 1px solid transparent; + background: var(--canvas); +} + +.panel-nav.is-scrolled { + border-bottom-color: var(--hairline); +} + +.panel-nav-title { + min-width: 0; + text-align: center; +} + +.panel-nav-title button { + width: 100%; + min-height: 44px; + padding: 4px 8px; + background: transparent; + text-align: center; +} + +.nav-heading { + display: block; + overflow: hidden; + font-size: 1.0625rem; + font-weight: 600; + letter-spacing: -0.4px; + line-height: 1.375rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nav-subtitle { + display: flex; + align-items: center; + justify-content: center; + min-height: 16px; + overflow: hidden; + color: var(--text-secondary); + font-size: 0.75rem; + line-height: 1rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nav-subtitle.wait { + color: var(--wait); +} + +.nav-subtitle.down { + color: var(--down); +} + +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + padding: 0; + border-radius: 10px; + background: transparent; + color: var(--text-primary); +} + +.icon-button:hover { + background: var(--raised); +} + +.nav-back { + color: var(--copper); +} + +.nav-back-label { + display: none; +} + +.panel-content { + min-height: 0; + flex: 1 1 auto; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} + +.root-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px var(--screen-gutter) 8px; +} + +.root-title { + margin: 0; + font-size: 1.625rem; + font-weight: 700; + letter-spacing: -0.4px; + line-height: 2rem; +} + +.root-actions { + display: flex; + gap: 4px; +} + +.connection-voice { + display: flex; + align-items: center; + gap: 6px; + margin-top: 2px; + color: var(--text-secondary); + font-size: 0.8125rem; + line-height: 1.125rem; +} + +.connection-voice.is-down { + color: var(--down); +} + +.connection-voice.is-wait { + color: var(--wait); +} + +.status-dot { + display: inline-block; + width: 6px; + height: 6px; + flex: 0 0 auto; + border-radius: 999px; + background: currentColor; +} + +.status-dot.live { + color: var(--live); +} + +.status-dot.working { + color: var(--copper); + animation: working-pulse 1.8s ease-in-out infinite; +} + +.search-wrap { + position: relative; + margin: 4px var(--screen-gutter) 16px; +} + +.search-wrap .icon { + position: absolute; + top: 13px; + left: 12px; + width: 18px; + height: 18px; + color: var(--text-secondary); + pointer-events: none; +} + +.search-input { + width: 100%; + height: 44px; + padding: 0 12px 0 38px; + border: 1px solid transparent; + border-radius: 10px; + outline: none; + background: var(--raised); + font-size: 1rem; +} + +.search-input::placeholder { + color: var(--text-tertiary); +} + +.search-input:focus { + border-color: var(--hairline-strong); +} + +.section-heading { + margin: 18px var(--screen-gutter) 6px; + color: var(--text-secondary); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.6px; + line-height: 0.875rem; + text-transform: uppercase; +} + +.row-list { + margin: 0; + padding: 0; + list-style: none; +} + +.data-row { + position: relative; + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + width: 100%; + min-height: 60px; + padding: 8px var(--screen-gutter); + border-radius: 0; + background: transparent; + text-align: left; +} + +.data-row::after { + position: absolute; + right: 0; + bottom: 0; + left: calc(var(--screen-gutter) + 48px); + height: 1px; + background: var(--hairline); + content: ""; +} + +.data-row:hover, +.data-row:active, +.data-row[aria-current="true"] { + background: var(--raised); +} + +.row-monogram { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 1px solid var(--hairline); + border-radius: 10px; + background: var(--raised); + color: var(--text-secondary); + font-size: 0.9375rem; + font-weight: 600; + letter-spacing: -0.3px; +} + +.row-copy { + min-width: 0; +} + +.row-title { + display: block; + overflow: hidden; + color: var(--text-primary); + font-size: 1.0625rem; + font-weight: 600; + letter-spacing: -0.4px; + line-height: 1.375rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-subtitle { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + overflow: hidden; + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 500; + line-height: 1rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-subtitle .icon { + width: 13px; + height: 13px; +} + +.row-meta { + display: flex; + min-width: 28px; + flex-direction: column; + align-items: flex-end; + gap: 4px; + color: var(--text-secondary); + font-size: 0.8125rem; + line-height: 1.125rem; +} + +.row-status { + display: flex; + align-items: center; + gap: 5px; +} + +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 999px; + background: var(--copper-fill); + color: var(--on-copper); + font-family: var(--font-round); + font-size: 0.75rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 1rem; +} + +.badge.wait { + background: var(--wait); + color: var(--canvas); +} + +.chat-row { + min-height: 64px; +} + +.chat-row .row-subtitle { + font-family: var(--font-text); + font-size: 0.9375rem; + line-height: 1.25rem; +} + +.banner { + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; + margin: 8px; + padding: 8px 12px; + border-radius: 14px; + font-size: 0.8125rem; + line-height: 1.125rem; +} + +.banner.wait { + background: var(--wait-tint); + color: var(--wait); +} + +.banner.down { + background: var(--down-tint); + color: var(--down); +} + +.banner.live { + background: var(--live-tint); + color: var(--live); +} + +.banner-copy { + color: var(--text-primary); +} + +.banner-action { + min-width: 44px; + min-height: 44px; + margin: -8px -8px -8px auto; + padding: 0 8px; + background: transparent; + color: currentColor; + font-weight: 600; +} + +.empty-state { + display: flex; + min-height: 320px; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 24px; + text-align: center; +} + +.empty-state .icon { + width: 36px; + height: 36px; + color: var(--text-secondary); +} + +.empty-state h2 { + margin: 14px 0 4px; + font-size: 1.0625rem; + line-height: 1.375rem; +} + +.empty-state p { + max-width: 34ch; + margin: 0; + color: var(--text-secondary); + font-size: 0.9375rem; + line-height: 1.25rem; +} + +.transcript-panel { + position: relative; +} + +.transcript-scroll { + min-height: 0; + flex: 1 1 auto; + overflow-x: hidden; + overflow-y: auto; + padding: 16px var(--screen-gutter) calc(var(--composer-height) + 20px); + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} + +.transcript-column { + width: 100%; + max-width: 720px; + margin: 0 auto; +} + +.message-list { + display: flex; + flex-direction: column; + gap: 16px; + margin: 0; + padding: 0; + list-style: none; +} + +.message { + min-width: 0; +} + +.message.is-new { + animation: chunk-in 120ms cubic-bezier(0.2, 0, 0, 1); +} + +.message.assistant { + width: min(100%, 68ch); + max-width: 100%; + align-self: flex-start; + color: var(--text-primary); + font-size: 1rem; + line-height: 1.5rem; +} + +.message.assistant p { + margin: 0 0 12px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.message.assistant p:last-child { + margin-bottom: 0; +} + +.message.assistant ul { + margin: 0 0 12px; + padding-left: 20px; +} + +.message.assistant li { + overflow-wrap: anywhere; +} + +.message.assistant pre { + position: relative; + max-width: 100%; + margin: 8px 0 12px; + overflow: auto; + border: 1px solid var(--hairline); + border-radius: 10px; + background: var(--inset); +} + +.message.assistant code { + display: block; + min-width: max-content; + padding: 12px; + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.1875rem; + white-space: pre; +} + +.message.user { + max-width: 85%; + align-self: flex-end; +} + +.user-bubble { + padding: 9px 13px; + border-radius: 18px 18px 6px 18px; + background: var(--user-bubble); + color: var(--on-bubble); + font-size: 1rem; + line-height: 1.5rem; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.message-meta { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + min-height: 18px; + margin-top: 4px; + color: var(--text-secondary); + font-size: 0.8125rem; + line-height: 1.125rem; +} + +.message-meta .icon { + width: 14px; + height: 14px; +} + +.message-meta.failed { + color: var(--down); +} + +.message-retry { + min-height: 44px; + margin: -12px -8px -12px 0; + padding: 0 8px; + background: transparent; + color: var(--copper); + font-size: 0.8125rem; + font-weight: 600; +} + +.tool-card { + width: 100%; + overflow: hidden; + border: 1px solid var(--hairline); + border-radius: 10px; + background: var(--raised); +} + +.tool-card.running { + border-left: 2px solid var(--copper); +} + +.tool-card.failed { + border-left: 2px solid var(--down); +} + +.tool-summary { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto 18px; + gap: 8px; + align-items: center; + width: 100%; + min-height: 44px; + padding: 6px 10px; + background: transparent; + color: var(--text-secondary); + text-align: left; +} + +.tool-summary-copy { + overflow: hidden; + color: var(--text-primary); + font-size: 0.8125rem; + line-height: 1.125rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tool-duration { + font-family: var(--font-mono); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + line-height: 1rem; +} + +.tool-details { + display: none; + max-height: 240px; + margin: 0 10px 10px 36px; + overflow: auto; + padding: 10px; + border-radius: 8px; + background: var(--inset); + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1rem; +} + +.tool-card[open] .tool-details { + display: block; +} + +.tool-card[open] .tool-summary .chevron { + transform: rotate(180deg); +} + +.status-row { + display: flex; + align-items: center; + gap: 8px; + min-height: 40px; + color: var(--text-secondary); + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; + line-height: 1.125rem; +} + +.composer-dock { + position: absolute; + z-index: 6; + right: 0; + bottom: 0; + left: 0; + padding: 8px max(12px, env(safe-area-inset-right)) + calc(8px + env(safe-area-inset-bottom)) + max(12px, env(safe-area-inset-left)); + border-top: 1px solid var(--hairline); + background: var(--canvas); +} + +.composer-inner { + display: flex; + max-width: 720px; + margin: 0 auto; + align-items: flex-end; + gap: 8px; +} + +.composer-field { + display: block; + width: 100%; + min-height: 44px; + max-height: 144px; + resize: none; + overflow-y: auto; + padding: 9px 13px; + border: 1px solid var(--hairline-strong); + border-radius: 18px; + outline: none; + background: var(--raised); + font-size: 1rem; + field-sizing: content; + line-height: 1.5rem; +} + +.composer-field::placeholder { + color: var(--text-tertiary); +} + +.composer-field:focus { + border-color: var(--copper); +} + +.send-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + flex: 0 0 auto; + padding: 4px; + border-radius: 999px; + background: transparent; + color: var(--on-copper); +} + +.send-button::before { + position: absolute; +} + +.send-button .button-disc { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 999px; + background: var(--copper-fill); +} + +.send-button:disabled { + color: var(--text-tertiary); + pointer-events: none; +} + +.send-button:disabled .button-disc { + background: var(--raised); +} + +.send-button.unverified .button-disc { + box-shadow: inset 0 0 0 2px var(--wait); + background: var(--raised); + color: var(--wait); +} + +.send-reason { + display: inline-flex; + min-height: 44px; + align-items: center; + gap: 6px; + padding: 0 10px; + border-radius: 10px; + background: var(--down-tint); + color: var(--down); + font-size: 0.75rem; + font-weight: 500; + white-space: nowrap; +} + +.draft-note { + max-width: 720px; + margin: 0 auto 6px; + color: var(--text-secondary); + font-size: 0.75rem; + line-height: 1rem; +} + +.latest-button { + position: absolute; + z-index: 7; + right: calc(12px + env(safe-area-inset-right)); + bottom: calc(var(--composer-height) + 12px + env(safe-area-inset-bottom)); + display: inline-flex; + min-height: 36px; + align-items: center; + gap: 4px; + padding: 0 12px; + border: 1px solid var(--hairline); + border-radius: 999px; + background: var(--elevated); + color: var(--text-primary); + font-size: 0.75rem; + font-weight: 500; + box-shadow: 0 1px 2px rgba(33, 29, 25, 0.06), 0 4px 16px rgba(33, 29, 25, 0.05); +} + +.latest-button[hidden] { + display: none; +} + +.gate { + display: flex; + overflow-y: auto; + align-items: center; + justify-content: center; + padding: calc(32px + env(safe-area-inset-top)) 24px + calc(32px + env(safe-area-inset-bottom)); + background: var(--canvas); +} + +.gate-column { + width: min(100%, 430px); + text-align: center; +} + +.app-mark { + display: flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + margin: 0 auto 16px; + border: 1px solid var(--hairline); + border-radius: 14px; + background: var(--raised); + color: var(--copper); +} + +.app-mark .icon { + width: 28px; + height: 28px; +} + +.gate h1 { + margin: 0; + font-size: 1.625rem; + font-weight: 700; + letter-spacing: -0.4px; + line-height: 2rem; +} + +.gate-lede { + max-width: 34ch; + margin: 6px auto 24px; + color: var(--text-secondary); + font-size: 0.9375rem; + line-height: 1.25rem; +} + +.pair-card { + padding: 20px; + border: 1px solid var(--hairline); + border-radius: 14px; + background: var(--raised); + text-align: left; +} + +.micro-caps { + color: var(--text-secondary); + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.6px; + line-height: 0.875rem; + text-transform: uppercase; +} + +.pair-mac { + margin: 6px 0 2px; + font-size: 1.25rem; + font-weight: 600; + letter-spacing: -0.3px; + line-height: 1.5625rem; +} + +.machine-fact { + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1rem; + overflow-wrap: anywhere; +} + +.pair-divider { + height: 1px; + margin: 16px 0; + background: var(--hairline); +} + +.verification-code { + margin: 8px 0; + font-family: var(--font-mono); + font-size: 1.5rem; + font-weight: 600; + letter-spacing: 0.18em; + line-height: 2rem; + text-align: center; +} + +.pair-help { + margin: 10px 0 0; + color: var(--text-secondary); + font-size: 0.8125rem; + line-height: 1.125rem; +} + +.primary-button, +.secondary-button, +.destructive-button { + width: 100%; + min-height: 50px; + margin-top: 16px; + padding: 10px 16px; + border-radius: 10px; + font-size: 1rem; + font-weight: 600; +} + +.primary-button { + background: var(--copper-fill); + color: var(--on-copper); +} + +.primary-button:hover { + background: var(--copper-press); +} + +.primary-button:disabled { + background: var(--raised); + color: var(--text-tertiary); +} + +.secondary-button { + border: 1px solid var(--hairline-strong); + background: transparent; + color: var(--text-primary); +} + +.destructive-button { + border: 1px solid var(--down); + background: transparent; + color: var(--down); +} + +.text-button { + min-height: 44px; + padding: 8px 12px; + background: transparent; + color: var(--copper); + font-size: 1rem; + font-weight: 600; +} + +.inline-error { + margin: 10px 0 0; + color: var(--down); + font-size: 0.8125rem; + line-height: 1.125rem; +} + +.faceid-mark { + width: 44px; + height: 44px; + margin: 0 auto 14px; + color: var(--copper); +} + +.overlay { + position: fixed; + z-index: 20; + inset: 0; + display: flex; + align-items: flex-end; + justify-content: center; + padding: 0; + background: var(--scrim); +} + +.overlay[hidden] { + display: none; +} + +.sheet { + position: relative; + width: 100%; + max-height: calc(100% - env(safe-area-inset-top) - 20px); + overflow: hidden; + padding: 20px 16px calc(16px + env(safe-area-inset-bottom)); + border-radius: 16px 16px 0 0; + background: var(--elevated); + box-shadow: var(--sheet-shadow); + animation: sheet-in 380ms cubic-bezier(0.32, 0.72, 0, 1); +} + +.sheet.switcher { + height: 65%; +} + +.sheet-grabber { + position: absolute; + top: 8px; + left: 50%; + width: 36px; + height: 4px; + border-radius: 999px; + background: var(--hairline-strong); + transform: translateX(-50%); +} + +.sheet-header { + display: flex; + min-height: 44px; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.sheet-title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + letter-spacing: -0.3px; + line-height: 1.5625rem; +} + +.sheet-scroll { + max-height: calc(100% - 56px); + overflow-y: auto; + overscroll-behavior: contain; +} + +.connection-check { + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + min-height: 44px; + border-bottom: 1px solid var(--hairline); + font-size: 0.8125rem; + line-height: 1.125rem; +} + +.connection-check .value { + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 0.75rem; +} + +.settings-section { + margin-top: 20px; +} + +.settings-card { + overflow: hidden; + border: 1px solid var(--hairline); + border-radius: 14px; + background: var(--raised); +} + +.settings-row { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-height: 56px; + padding: 8px 12px; + border-bottom: 1px solid var(--hairline); +} + +.settings-row:last-child { + border-bottom: 0; +} + +.settings-row-copy { + min-width: 0; +} + +.settings-row-title { + display: block; + font-size: 0.9375rem; + font-weight: 600; + line-height: 1.25rem; +} + +.settings-row-subtitle { + display: block; + overflow: hidden; + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-row-action { + min-width: 44px; + min-height: 44px; + padding: 0 8px; + background: transparent; + color: var(--copper); + font-size: 0.8125rem; + font-weight: 600; +} + +.settings-row-action.destructive { + color: var(--down); +} + +.install-steps { + margin: 24px 0 0; + padding: 0; + list-style: none; + text-align: left; +} + +.install-step { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) 24px; + gap: 12px; + align-items: center; + min-height: 64px; + border-bottom: 1px solid var(--hairline); +} + +.install-number { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 1px solid var(--hairline); + border-radius: 10px; + background: var(--raised); + color: var(--text-secondary); + font-family: var(--font-round); + font-weight: 600; +} + +.skeleton-row { + display: grid; + grid-template-columns: 36px 1fr; + gap: 12px; + align-items: center; + min-height: 60px; + padding: 8px var(--screen-gutter); +} + +.skeleton-block { + border-radius: 10px; + background: var(--raised); + animation: skeleton-pulse 1.6s ease-in-out infinite; +} + +.skeleton-monogram { + width: 36px; + height: 36px; +} + +.skeleton-copy { + width: 75%; + height: 16px; +} + +.skeleton-copy.short { + width: 42%; + height: 12px; + margin-top: 7px; +} + +[hidden] { + display: none !important; +} + +@keyframes working-pulse { + 0%, + 100% { + opacity: 0.45; + } + 50% { + opacity: 1; + } +} + +@keyframes chunk-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes sheet-in { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +@keyframes skeleton-pulse { + 0%, + 100% { + opacity: 0.5; + } + 50% { + opacity: 0.8; + } +} + +@media (max-width: 399px) { + :root { + --screen-gutter: 12px; + } + + .message.user { + max-width: 90%; + } +} + +@media (max-width: 599px) { + .security-overlay { + align-items: stretch; + background: var(--canvas); + } + + .sheet.security { + height: 100%; + max-height: 100%; + padding-top: calc(8px + env(safe-area-inset-top)); + border-radius: 0; + box-shadow: none; + } + + .sheet.security .sheet-grabber { + display: none; + } +} + +@media (min-width: 600px) { + :root { + --screen-gutter: 20px; + } + + .app-shell { + grid-template-columns: 320px minmax(0, 1fr); + } + + .workspace-panel { + display: none !important; + } + + .sessions-panel, + .transcript-panel { + display: flex !important; + flex-direction: column; + } + + .sessions-panel { + border-right: 1px solid var(--hairline); + } + + .overlay { + align-items: center; + } + + .sheet { + width: min(480px, calc(100% - 40px)); + max-height: min(720px, calc(100% - 40px)); + border-radius: 16px; + box-shadow: 0 16px 64px rgba(0, 0, 0, 0.35); + } + + .sheet.switcher { + width: 400px; + height: 560px; + } +} + +@media (min-width: 1024px) { + :root { + --screen-gutter: 24px; + } + + .app-shell { + grid-template-columns: 260px 320px minmax(0, 1fr); + } + + .workspace-panel { + display: flex !important; + flex-direction: column; + border-right: 1px solid var(--hairline); + } + + .panel-nav { + min-height: 48px; + padding-top: 0; + } + + .transcript-scroll { + padding-top: 28px; + } + + .data-row { + border-radius: 8px; + } + + .data-row::after { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/tools/conductor-pocket/public/app.js b/tools/conductor-pocket/public/app.js new file mode 100644 index 00000000..c54cc9d2 --- /dev/null +++ b/tools/conductor-pocket/public/app.js @@ -0,0 +1,2098 @@ +const app = document.querySelector('#app'); +const overlayRoot = document.querySelector('#overlay-root'); +const announcer = document.querySelector('#announcer'); +const AWAY_LOCK_MS = 5 * 60 * 1000; +const ACTIVITY_HEARTBEAT_MS = 60 * 1000; +const HIDDEN_AT_KEY = 'cp:hidden-at:v1'; + +const state = { + auth: null, + csrfToken: null, + connection: 'connecting', + lastHeartbeat: 0, + connectionProbe: null, + eventSource: null, + heartbeatTimer: null, + activityTimer: null, + workspaces: [], + workspacesLoaded: false, + recentSessions: [], + sessionsByWorkspace: new Map(), + messagesBySession: new Map(), + cursorsBySession: new Map(), + route: { view: 'workspaces', workspaceId: null, sessionId: null }, + optimistic: [], + searchQuery: '', + shell: null, + hiddenAt: null, + seenMessageIds: new Set(), +}; + +const ICONS = { + arrowUp: 'i-arrow-up', + back: 'i-back', + bolt: 'i-bolt', + check: 'i-check', + checkDouble: 'i-check-double', + chevronDown: 'i-chevron-down', + close: 'i-close', + faceid: 'i-faceid', + gear: 'i-gear', + laptop: 'i-laptop', + branch: 'i-branch', + lock: 'i-lock', + phone: 'i-phone', + plus: 'i-plus', + refresh: 'i-refresh', + search: 'i-search', + share: 'i-share', + squares: 'i-squares', + terminal: 'i-terminal', + warn: 'i-warn', + wifiOff: 'i-wifi-off', +}; + +function icon(name, className = '') { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.classList.add('icon'); + if (className) svg.classList.add(...className.split(' ')); + svg.setAttribute('aria-hidden', 'true'); + const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); + use.setAttribute('href', `#${ICONS[name] || name}`); + svg.append(use); + return svg; +} + +function node(tag, attributes = {}, children = []) { + const element = document.createElement(tag); + for (const [key, value] of Object.entries(attributes)) { + if (value == null) continue; + if (key === 'className') element.className = value; + else if (key === 'text') element.textContent = value; + else if (key === 'on') { + for (const [eventName, listener] of Object.entries(value)) { + element.addEventListener(eventName, listener); + } + } else if (key in element && !key.startsWith('aria')) { + element[key] = value; + } else { + element.setAttribute(key, value); + } + } + for (const child of Array.isArray(children) ? children : [children]) { + if (child == null) continue; + element.append(child instanceof Node ? child : document.createTextNode(String(child))); + } + return element; +} + +function button(label, { iconName, className = 'icon-button', onClick } = {}) { + const attributes = { + className, + type: 'button', + 'aria-label': label, + }; + if (typeof onClick === 'function') attributes.on = { click: onClick }; + const control = node('button', attributes); + if (iconName) control.append(icon(iconName)); + else control.textContent = label; + return control; +} + +function announce(message) { + announcer.textContent = ''; + requestAnimationFrame(() => { + announcer.textContent = message; + }); +} + +function formatRelative(value) { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return ''; + const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000)); + if (seconds < 60) return 'now'; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h`; + const days = Math.round(hours / 24); + return `${days}d`; +} + +function formatTime(value = new Date().toISOString()) { + const date = new Date(value); + return new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: '2-digit', + }).format(date); +} + +function initials(value) { + return ( + String(value || '?') + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]) + .join('') + .toUpperCase() || '?' + ); +} + +function cappedCount(count) { + return count > 99 ? '99+' : String(count); +} + +function randomIdempotencyKey() { + if (crypto.randomUUID) return crypto.randomUUID(); + const bytes = crypto.getRandomValues(new Uint8Array(24)); + return toBase64Url(bytes); +} + +function fromBase64Url(value) { + const base64 = value.replaceAll('-', '+').replaceAll('_', '/'); + const padded = `${base64}${'='.repeat((4 - (base64.length % 4)) % 4)}`; + const binary = atob(padded); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)).buffer; +} + +function toBase64Url(value) { + const bytes = value instanceof Uint8Array ? value : new Uint8Array(value); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); +} + +function registrationOptions(options) { + return { + ...options, + challenge: fromBase64Url(options.challenge), + user: { ...options.user, id: fromBase64Url(options.user.id) }, + excludeCredentials: (options.excludeCredentials || []).map((credential) => ({ + ...credential, + id: fromBase64Url(credential.id), + })), + }; +} + +function authenticationOptions(options) { + return { + ...options, + challenge: fromBase64Url(options.challenge), + allowCredentials: (options.allowCredentials || []).map((credential) => ({ + ...credential, + id: fromBase64Url(credential.id), + })), + }; +} + +function registrationResponse(credential) { + return { + id: credential.id, + rawId: toBase64Url(credential.rawId), + type: credential.type, + authenticatorAttachment: credential.authenticatorAttachment, + clientExtensionResults: credential.getClientExtensionResults(), + response: { + clientDataJSON: toBase64Url(credential.response.clientDataJSON), + attestationObject: toBase64Url(credential.response.attestationObject), + transports: credential.response.getTransports?.() || [], + publicKeyAlgorithm: credential.response.getPublicKeyAlgorithm?.(), + publicKey: credential.response.getPublicKey?.() + ? toBase64Url(credential.response.getPublicKey()) + : undefined, + authenticatorData: credential.response.getAuthenticatorData?.() + ? toBase64Url(credential.response.getAuthenticatorData()) + : undefined, + }, + }; +} + +function authenticationResponse(credential) { + return { + id: credential.id, + rawId: toBase64Url(credential.rawId), + type: credential.type, + authenticatorAttachment: credential.authenticatorAttachment, + clientExtensionResults: credential.getClientExtensionResults(), + response: { + clientDataJSON: toBase64Url(credential.response.clientDataJSON), + authenticatorData: toBase64Url(credential.response.authenticatorData), + signature: toBase64Url(credential.response.signature), + userHandle: credential.response.userHandle + ? toBase64Url(credential.response.userHandle) + : null, + }, + }; +} + +async function request(pathname, { method = 'GET', body, csrf = false } = {}) { + const headers = { Accept: 'application/json' }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + if (csrf && state.csrfToken) headers['X-CSRF-Token'] = state.csrfToken; + const response = await fetch(pathname, { + method, + headers, + credentials: 'same-origin', + cache: 'no-store', + body: body === undefined ? undefined : JSON.stringify(body), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const error = new Error(payload.error?.code || `http_${response.status}`); + error.code = payload.error?.code || `http_${response.status}`; + error.status = response.status; + throw error; + } + return payload; +} + +function gateView({ mark = 'bolt', title, body, content, action, secondary }) { + state.shell?.composer.observer?.disconnect(); + state.shell = null; + const markNode = node('div', { className: 'app-mark' }, icon(mark)); + const column = node('div', { className: 'gate-column' }, [ + markNode, + node('h1', { text: title }), + body ? node('p', { className: 'gate-lede', text: body }) : null, + content, + action, + secondary, + ]); + app.replaceChildren(node('main', { className: 'gate' }, column)); +} + +function skeletonRows(count = 6) { + const fragment = document.createDocumentFragment(); + for (let index = 0; index < count; index += 1) { + fragment.append( + node('div', { className: 'skeleton-row', 'aria-hidden': 'true' }, [ + node('div', { className: 'skeleton-block skeleton-monogram' }), + node('div', {}, [ + node('div', { className: 'skeleton-block skeleton-copy' }), + node('div', { className: 'skeleton-block skeleton-copy short' }), + ]), + ]), + ); + } + return fragment; +} + +async function initializePairing(code) { + gateView({ + title: 'Conductor Pocket', + body: "Remote for your Mac's Conductor", + content: node('div', { className: 'pair-card' }, skeletonRows(3)), + }); + try { + const pairing = await request('/api/pair/start', { + method: 'POST', + body: { code, deviceName: /iPhone/i.test(navigator.userAgent) ? 'This iPhone' : 'This device' }, + }); + renderPairing(pairing); + } catch (error) { + renderPairingExpired(error.code); + } +} + +function renderPairing(pairing) { + const card = node('section', { className: 'pair-card' }, [ + node('div', { className: 'micro-caps', text: 'Pairing with' }), + node('div', { className: 'pair-mac', text: pairing.macName }), + node('div', { className: 'machine-fact', text: pairing.hostname }), + node('div', { className: 'pair-divider' }), + node('div', { className: 'micro-caps', text: 'Verification code' }), + node('div', { className: 'verification-code', text: pairing.verificationCode }), + node('p', { + className: 'pair-help', + text: "The Mac terminal is showing the same code. If they don't match, don't pair.", + }), + ]); + const action = node('button', { + className: 'primary-button', + type: 'button', + text: 'The code matches - Pair', + on: { + click: async (event) => { + const control = event.currentTarget; + control.disabled = true; + control.textContent = 'Waiting for Face ID…'; + try { + if (!window.PublicKeyCredential) throw new Error('passkey_unavailable'); + const credential = await navigator.credentials.create({ + publicKey: registrationOptions(pairing.options), + }); + const result = await request('/api/pair/finish', { + method: 'POST', + body: { response: registrationResponse(credential) }, + }); + history.replaceState({}, '', '/'); + state.auth = result; + state.csrfToken = result.csrfToken; + if (isStandalone()) { + await startApplication(); + } else { + renderInstallGuidance({ firstRun: true }); + } + } catch (error) { + control.disabled = false; + control.textContent = 'The code matches - Pair'; + let errorLine = card.querySelector('.inline-error'); + if (!errorLine) { + errorLine = node('p', { className: 'inline-error' }); + card.append(errorLine); + } + errorLine.textContent = + error.name === 'NotAllowedError' + ? "Face ID wasn't set up. Try again." + : 'Pairing could not be completed. Generate a new link on your Mac.'; + } + }, + }, + }); + gateView({ + title: 'Conductor Pocket', + body: "Remote for your Mac's Conductor", + content: card, + action, + }); +} + +function renderPairingExpired(code) { + const card = node('section', { className: 'pair-card' }, [ + icon('warn'), + node('div', { className: 'pair-mac', text: 'This link has expired' }), + node('p', { + className: 'pair-help', + text: + code === 'tailscale_identity_required' + ? 'Connect this phone to the same Tailscale network, then open a fresh pairing link.' + : 'Pairing links work once. Generate a new one from the Conductor Pocket setup command on your Mac.', + }), + ]); + gateView({ + mark: 'warn', + title: 'Pairing unavailable', + content: card, + }); +} + +async function bootstrap() { + try { + const auth = await request('/api/auth/bootstrap'); + state.auth = auth; + state.csrfToken = auth.csrfToken; + if (auth.unlocked) await startApplication(); + else renderLock(); + } catch (error) { + if (error.status === 401 || error.code === 'device_revoked') { + await purgeLocalData(); + renderSignedOut(); + } else { + renderConnectionGate(error.code); + } + } +} + +function renderLock({ errorMessage = '' } = {}) { + stopEvents(); + state.shell = null; + state.messagesBySession.clear(); + state.cursorsBySession.clear(); + state.optimistic = []; + state.seenMessageIds.clear(); + const action = node('button', { + className: 'primary-button', + type: 'button', + text: 'Unlock with Face ID', + on: { + click: async (event) => { + const control = event.currentTarget; + control.disabled = true; + control.textContent = 'Waiting for Face ID…'; + try { + const options = await request('/api/auth/options', { + method: 'POST', + body: {}, + csrf: true, + }); + const credential = await navigator.credentials.get({ + publicKey: authenticationOptions(options), + }); + const auth = await request('/api/auth/verify', { + method: 'POST', + body: { response: authenticationResponse(credential) }, + csrf: true, + }); + state.auth = { ...state.auth, ...auth }; + state.csrfToken = auth.csrfToken; + await startApplication(); + } catch (error) { + renderLock({ + errorMessage: + error.name === 'NotAllowedError' + ? 'Face ID was canceled. Try again.' + : 'Face ID could not unlock this remote. Try again.', + }); + } + }, + }, + }); + const content = errorMessage + ? node('p', { className: 'inline-error', text: errorMessage }) + : null; + gateView({ + mark: 'bolt', + title: 'Locked', + body: 'Your Conductor chats stay hidden until you verify.', + content, + action, + }); +} + +function renderSignedOut() { + stopEvents(); + gateView({ + mark: 'lock', + title: 'This device was signed out', + body: + 'Its access was revoked or expired. Pair again with a fresh link from your Mac.', + content: node('p', { + className: 'machine-fact', + text: 'npm run pair', + }), + }); +} + +function renderConnectionGate(code) { + gateView({ + mark: 'wifiOff', + title: 'Mac unreachable', + body: + code === 'tailscale_identity_required' + ? 'Connect this phone to your private Tailscale network, then try again.' + : 'Conductor Pocket could not reach the relay on your Mac.', + action: node('button', { + className: 'primary-button', + type: 'button', + text: 'Try again', + on: { click: () => bootstrap() }, + }), + }); +} + +function isStandalone() { + return ( + window.matchMedia('(display-mode: standalone)').matches || + window.navigator.standalone === true + ); +} + +function renderInstallGuidance({ firstRun = false } = {}) { + const steps = node('ol', { className: 'install-steps' }); + const values = [ + ['Tap the Share button in Safari', 'share'], + ['Choose “Add to Home Screen”', 'plus'], + ['Open Conductor Pocket from your Home Screen', 'bolt'], + ]; + values.forEach(([copy, iconName], index) => { + steps.append( + node('li', { className: 'install-step' }, [ + node('span', { className: 'install-number', text: String(index + 1) }), + node('span', { text: copy }), + icon(iconName), + ]), + ); + }); + gateView({ + title: 'Install Conductor Pocket', + body: 'Run it full-screen, off the Home Screen, like an app.', + content: steps, + action: node('button', { + className: 'primary-button', + type: 'button', + text: firstRun ? 'Continue in Safari' : 'Done', + on: { click: () => startApplication() }, + }), + }); +} + +function loadRoute() { + try { + const parsed = JSON.parse(localStorage.getItem('cp:last-route:v1')); + if (parsed && ['workspaces', 'sessions', 'transcript'].includes(parsed.view)) { + return parsed; + } + } catch { + // Ignore malformed local state. + } + return { view: 'workspaces', workspaceId: null, sessionId: null }; +} + +function persistRoute() { + localStorage.setItem('cp:last-route:v1', JSON.stringify(state.route)); +} + +function loadDrafts() { + try { + return JSON.parse(localStorage.getItem('cp:drafts:v1')) || {}; + } catch { + return {}; + } +} + +function draftFor(sessionId) { + return loadDrafts()[sessionId] || ''; +} + +function saveDraft(sessionId, value) { + if (!sessionId) return; + const drafts = loadDrafts(); + if (value) drafts[sessionId] = value.slice(0, 16 * 1024); + else delete drafts[sessionId]; + localStorage.setItem('cp:drafts:v1', JSON.stringify(drafts)); +} + +let cacheDatabasePromise; + +function cacheDatabase() { + if (!cacheDatabasePromise) { + cacheDatabasePromise = new Promise((resolve, reject) => { + const open = indexedDB.open('conductor-pocket-v1', 1); + open.onupgradeneeded = () => { + if (!open.result.objectStoreNames.contains('snapshots')) { + open.result.createObjectStore('snapshots'); + } + }; + open.onsuccess = () => resolve(open.result); + open.onerror = () => reject(open.error); + }); + } + return cacheDatabasePromise; +} + +async function cacheGet(key) { + try { + const database = await cacheDatabase(); + return await new Promise((resolve, reject) => { + const transaction = database.transaction('snapshots', 'readonly'); + const requestValue = transaction.objectStore('snapshots').get(key); + requestValue.onsuccess = () => resolve(requestValue.result); + requestValue.onerror = () => reject(requestValue.error); + }); + } catch { + return null; + } +} + +async function cacheSet(key, value) { + try { + const database = await cacheDatabase(); + await new Promise((resolve, reject) => { + const transaction = database.transaction('snapshots', 'readwrite'); + transaction.objectStore('snapshots').put(value, key); + transaction.oncomplete = resolve; + transaction.onerror = () => reject(transaction.error); + }); + } catch { + // Cache failures never block the live path. + } +} + +async function purgeLocalData() { + localStorage.removeItem('cp:last-route:v1'); + localStorage.removeItem('cp:drafts:v1'); + localStorage.removeItem(HIDDEN_AT_KEY); + try { + const database = await cacheDatabasePromise; + database?.close(); + } catch { + // A failed cache open has nothing to close. + } + cacheDatabasePromise = null; + await new Promise((resolve) => { + const requestValue = indexedDB.deleteDatabase('conductor-pocket-v1'); + requestValue.onsuccess = resolve; + requestValue.onerror = resolve; + requestValue.onblocked = resolve; + }); +} + +async function startApplication() { + localStorage.removeItem(HIDDEN_AT_KEY); + state.hiddenAt = null; + state.route = loadRoute(); + state.workspacesLoaded = false; + ensureShell(); + updateRoutePanels(); + const cachedWorkspaces = await cacheGet('workspaces'); + if (Array.isArray(cachedWorkspaces)) { + state.workspaces = cachedWorkspaces; + renderWorkspacePanel(); + } + await refreshWorkspaces(); + await loadRecentSessions(); + await restoreRoute(); + startEvents(); +} + +async function restoreRoute() { + if (!state.route.workspaceId && state.route.sessionId) { + const recent = state.recentSessions.find((session) => session.id === state.route.sessionId); + state.route.workspaceId = recent?.workspaceId || null; + } + const workspace = state.workspaces.find((item) => item.id === state.route.workspaceId); + if (!workspace) { + navigate({ view: 'workspaces', workspaceId: null, sessionId: null }, false); + return; + } + await loadSessions(workspace.id); + if (state.route.view === 'transcript') { + const session = sessionsFor(workspace.id).find( + (item) => item.id === state.route.sessionId, + ); + if (!session) { + navigate({ view: 'sessions', workspaceId: workspace.id, sessionId: null }, false); + return; + } + await openSession(session.id, { push: false }); + } else { + renderSessionsPanel(); + } +} + +function ensureShell() { + if (state.shell && app.contains(state.shell.root)) return; + const workspacePanel = node('aside', { + className: 'panel workspace-panel', + 'aria-label': 'Workspaces', + }); + const sessionPanel = node('aside', { + className: 'panel sessions-panel', + 'aria-label': 'Chats', + }); + const transcriptPanel = node('main', { + className: 'panel transcript-panel', + 'aria-label': 'Transcript', + }); + const root = node('div', { className: 'app-shell' }, [ + workspacePanel, + sessionPanel, + transcriptPanel, + ]); + app.replaceChildren(root); + + const sessionNav = createPanelNav({ + backLabel: 'Workspaces', + onBack: () => navigate({ view: 'workspaces', workspaceId: null, sessionId: null }), + onSwitcher: openSwitcher, + }); + const sessionContent = node('div', { className: 'panel-content' }); + sessionPanel.append(sessionNav.root, sessionContent); + + const transcriptNav = createPanelNav({ + backLabel: 'Chats', + onBack: () => + navigate({ + view: 'sessions', + workspaceId: state.route.workspaceId, + sessionId: null, + }), + onSwitcher: openSwitcher, + titleClick: openSwitcher, + }); + const transcriptBanner = node('div'); + const transcriptScroll = node('div', { className: 'transcript-scroll' }); + const transcriptColumn = node('div', { className: 'transcript-column' }); + const messageList = node('ol', { + className: 'message-list', + 'aria-label': 'Conversation', + }); + const statusRow = node('div'); + transcriptColumn.append(messageList, statusRow); + transcriptScroll.append(transcriptColumn); + const latestButton = node( + 'button', + { + className: 'latest-button', + type: 'button', + hidden: true, + on: { + click: () => { + transcriptScroll.scrollTo({ top: transcriptScroll.scrollHeight, behavior: 'smooth' }); + latestButton.hidden = true; + }, + }, + }, + [icon('chevronDown'), 'Latest'], + ); + const composer = createComposer(); + transcriptPanel.append( + transcriptNav.root, + transcriptBanner, + transcriptScroll, + latestButton, + composer.root, + ); + transcriptScroll.addEventListener('scroll', () => { + const distance = + transcriptScroll.scrollHeight - + transcriptScroll.clientHeight - + transcriptScroll.scrollTop; + latestButton.hidden = distance < 120; + transcriptNav.root.classList.toggle('is-scrolled', transcriptScroll.scrollTop > 1); + }); + + state.shell = { + root, + workspacePanel, + sessionPanel, + sessionContent, + sessionNav, + transcriptPanel, + transcriptNav, + transcriptBanner, + transcriptScroll, + messageList, + statusRow, + latestButton, + composer, + }; + renderWorkspacePanel(); + renderSessionsPanel(); +} + +function createPanelNav({ backLabel, onBack, onSwitcher, titleClick }) { + const back = button(backLabel, { + iconName: 'back', + className: 'icon-button nav-back', + onClick: onBack, + }); + const titleButton = node('button', { + type: 'button', + 'aria-label': titleClick ? 'Open chat switcher' : undefined, + on: titleClick ? { click: titleClick } : undefined, + }); + const heading = node('span', { className: 'nav-heading' }); + const subtitle = node('span', { className: 'nav-subtitle' }); + titleButton.append(heading, subtitle); + const title = node('div', { className: 'panel-nav-title' }, titleButton); + const switcher = button('Open chat switcher', { + iconName: 'squares', + onClick: onSwitcher, + }); + const root = node('header', { className: 'panel-nav' }, [back, title, switcher]); + return { root, heading, subtitle, back, switcher }; +} + +function createComposer() { + const draftNote = node('div', { className: 'draft-note', hidden: true }); + const field = node('textarea', { + className: 'composer-field', + placeholder: 'Message', + rows: 1, + maxlength: 16384, + 'aria-label': 'Message', + }); + const disc = node('span', { className: 'button-disc' }, icon('arrowUp')); + const send = node('button', { + className: 'send-button', + type: 'button', + disabled: true, + 'aria-label': 'Send message', + }, disc); + const reason = node('button', { + className: 'send-reason', + type: 'button', + hidden: true, + on: { click: openConnectionSheet }, + }); + const inner = node('div', { className: 'composer-inner' }, [field, send, reason]); + const root = node('div', { className: 'composer-dock' }, [draftNote, inner]); + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + const borderBox = Array.isArray(entry?.borderBoxSize) + ? entry.borderBoxSize[0] + : entry?.borderBoxSize; + const height = Math.ceil(borderBox?.blockSize || root.getBoundingClientRect().height); + if (height > 0) { + document.documentElement.style.setProperty( + '--composer-height', + `${height}px`, + ); + } + }); + observer.observe(root); + const resize = () => { + field.rows = Math.max(1, Math.min(6, field.value.split('\n').length)); + }; + field.addEventListener('input', () => { + saveDraft(state.route.sessionId, field.value); + resize(); + renderComposerState(); + }); + field.addEventListener('keydown', (event) => { + const touchAppleDevice = + /iPhone|iPad|iPod/i.test(navigator.userAgent) || + (/Macintosh/i.test(navigator.userAgent) && navigator.maxTouchPoints > 1); + const hardwareKeyboard = !touchAppleDevice && event.key === 'Enter'; + if (hardwareKeyboard && !event.shiftKey) { + event.preventDefault(); + sendCurrentMessage(); + } + }); + send.addEventListener('click', sendCurrentMessage); + return { root, field, send, reason, draftNote, resize, observer }; +} + +function updateRoutePanels() { + if (!state.shell) return; + const { view } = state.route; + state.shell.workspacePanel.classList.toggle('is-active', view === 'workspaces'); + state.shell.sessionPanel.classList.toggle('is-active', view === 'sessions'); + state.shell.transcriptPanel.classList.toggle('is-active', view === 'transcript'); +} + +function navigate(route, push = true) { + state.route = route; + persistRoute(); + if (push) history.pushState({ pocketRoute: route }, '', '/'); + updateRoutePanels(); + renderWorkspacePanel(); + renderSessionsPanel(); + if (route.view === 'transcript' && route.sessionId) openSession(route.sessionId, { push: false }); +} + +async function refreshWorkspaces() { + try { + const data = await request('/api/workspaces'); + state.workspaces = data.workspaces; + state.workspacesLoaded = true; + await cacheSet('workspaces', state.workspaces); + renderWorkspacePanel(); + } catch (error) { + handleRuntimeError(error); + } +} + +async function loadRecentSessions() { + try { + const data = await request('/api/sessions/recent?limit=100'); + state.recentSessions = data.sessions; + await cacheSet('recent-sessions', data.sessions); + } catch { + const cached = await cacheGet('recent-sessions'); + if (Array.isArray(cached)) state.recentSessions = cached; + } +} + +async function loadSessions(workspaceId) { + const cached = await cacheGet(`sessions:${workspaceId}`); + if (Array.isArray(cached) && !state.sessionsByWorkspace.has(workspaceId)) { + state.sessionsByWorkspace.set(workspaceId, cached); + renderSessionsPanel(); + } + try { + const data = await request(`/api/workspaces/${encodeURIComponent(workspaceId)}/sessions`); + state.sessionsByWorkspace.set(workspaceId, data.sessions); + await cacheSet(`sessions:${workspaceId}`, data.sessions); + renderSessionsPanel(); + } catch (error) { + handleRuntimeError(error); + } +} + +function sessionsFor(workspaceId) { + return state.sessionsByWorkspace.get(workspaceId) || []; +} + +function connectionVoice() { + if (state.connection === 'live') return { text: 'Live', className: '', dot: 'live' }; + if (state.connection === 'offline') { + return { text: 'Mac unreachable', className: 'down', iconName: 'wifiOff' }; + } + return { text: 'Reconnecting…', className: 'wait', iconName: 'refresh' }; +} + +function renderConnectionVoice(container) { + const voice = connectionVoice(); + container.replaceChildren(); + container.className = `connection-voice ${voice.className === 'down' ? 'is-down' : voice.className === 'wait' ? 'is-wait' : ''}`; + if (voice.dot) container.append(node('span', { className: `status-dot ${voice.dot}` })); + else container.append(icon(voice.iconName)); + container.append(document.createTextNode(voice.text)); +} + +function renderWorkspacePanel() { + if (!state.shell) return; + const panel = state.shell.workspacePanel; + const connection = node('div', { className: 'connection-voice' }); + renderConnectionVoice(connection); + const header = node('header', { className: 'root-header' }, [ + node('div', {}, [node('h1', { className: 'root-title', text: 'Workspaces' }), connection]), + node('div', { className: 'root-actions' }, [ + button('Open chat switcher', { iconName: 'squares', onClick: openSwitcher }), + button('Security and devices', { iconName: 'gear', onClick: openSecurity }), + ]), + ]); + const searchInput = node('input', { + className: 'search-input', + type: 'search', + value: state.searchQuery, + placeholder: 'Search workspaces and chats', + 'aria-label': 'Search workspaces and chats', + on: { + input: (event) => { + state.searchQuery = event.currentTarget.value; + renderWorkspacePanel(); + requestAnimationFrame(() => { + const replacement = state.shell.workspacePanel.querySelector('.search-input'); + replacement?.focus({ preventScroll: true }); + replacement?.setSelectionRange( + state.searchQuery.length, + state.searchQuery.length, + ); + }); + }, + }, + }); + const search = node('div', { className: 'search-wrap' }, [ + icon('search'), + searchInput, + ]); + const content = node('div', { className: 'panel-content' }, [header, search]); + + if (!state.workspacesLoaded && state.workspaces.length === 0) { + content.append(skeletonRows(6)); + } else if (state.workspaces.length === 0) { + content.append( + node('div', { className: 'empty-state' }, [ + icon('laptop'), + node('h2', { text: 'No workspaces yet' }), + node('p', { + text: "Open Conductor on your Mac and they'll appear here.", + }), + ]), + ); + } else if (state.searchQuery.trim()) { + renderSearchResults(content); + } else { + const active = state.workspaces.filter((workspace) => workspace.workingCount > 0); + const recent = state.workspaces.filter((workspace) => !active.includes(workspace)).slice(0, 5); + const remainder = state.workspaces + .filter((workspace) => !active.includes(workspace) && !recent.includes(workspace)) + .sort((left, right) => left.name.localeCompare(right.name)); + appendWorkspaceSection(content, 'Active', active); + appendWorkspaceSection(content, 'Recent', recent); + appendWorkspaceSection(content, 'All', remainder); + } + panel.replaceChildren(content); +} + +function renderSearchResults(content) { + const query = state.searchQuery.trim().toLowerCase(); + const workspaces = state.workspaces.filter((workspace) => + `${workspace.name} ${workspace.branch || ''}`.toLowerCase().includes(query), + ); + const sessions = state.recentSessions.filter((session) => + `${session.title} ${session.workspaceName}`.toLowerCase().includes(query), + ); + appendWorkspaceSection(content, 'Workspaces', workspaces); + if (sessions.length) { + const list = node('ul', { className: 'row-list' }); + for (const session of sessions) list.append(sessionRow(session, { crossWorkspace: true })); + content.append(node('h2', { className: 'section-heading', text: 'Chats' }), list); + } + if (!workspaces.length && !sessions.length) { + content.append( + node('div', { className: 'empty-state' }, [ + icon('search'), + node('h2', { text: `No matches for “${state.searchQuery.trim()}”` }), + ]), + ); + } +} + +function appendWorkspaceSection(content, title, workspaces) { + if (!workspaces.length) return; + const list = node('ul', { className: 'row-list' }); + for (const workspace of workspaces) list.append(workspaceRow(workspace)); + content.append(node('h2', { className: 'section-heading', text: title }), list); +} + +function workspaceRow(workspace) { + const subtitle = node('span', { className: 'row-subtitle' }, [ + icon('branch'), + workspace.branch || 'No branch', + ]); + const meta = node('span', { className: 'row-meta' }); + if (workspace.workingCount > 0) { + meta.append( + node('span', { className: 'row-status' }, [ + node('span', { className: 'status-dot working' }), + workspace.workingCount > 1 ? String(workspace.workingCount) : null, + ]), + ); + } else if (workspace.unreadCount > 0) { + meta.append(node('span', { className: 'badge', text: cappedCount(workspace.unreadCount) })); + } else { + meta.textContent = formatRelative(workspace.activityAt); + } + const control = node('button', { + className: 'data-row', + type: 'button', + 'aria-current': state.route.workspaceId === workspace.id ? 'true' : 'false', + on: { + click: async () => { + await loadSessions(workspace.id); + navigate({ view: 'sessions', workspaceId: workspace.id, sessionId: null }); + }, + }, + }, [ + node('span', { className: 'row-monogram', text: initials(workspace.name) }), + node('span', { className: 'row-copy' }, [ + node('span', { className: 'row-title', text: workspace.name }), + subtitle, + ]), + meta, + ]); + return node('li', {}, control); +} + +function renderSessionsPanel() { + if (!state.shell) return; + const workspace = state.workspaces.find((item) => item.id === state.route.workspaceId); + const { sessionNav, sessionContent } = state.shell; + sessionNav.heading.textContent = workspace?.name || 'Chats'; + updateNavSubtitle(sessionNav.subtitle, workspace?.branch || connectionVoice().text); + sessionContent.replaceChildren(); + if (!workspace) { + sessionContent.append( + node('div', { className: 'empty-state' }, [ + icon('terminal'), + node('h2', { text: 'Choose a workspace' }), + ]), + ); + return; + } + const sessions = sessionsFor(workspace.id); + if (!state.sessionsByWorkspace.has(workspace.id)) { + sessionContent.append(skeletonRows(6)); + return; + } + if (!sessions.length) { + sessionContent.append( + node('div', { className: 'empty-state' }, [ + icon('terminal'), + node('h2', { text: 'No chats in this workspace' }), + node('p', { text: 'Start one from your Mac.' }), + ]), + ); + return; + } + const list = node('ul', { className: 'row-list' }); + sessions.forEach((session) => list.append(sessionRow(session))); + sessionContent.append(list); +} + +function sessionRow(session, { crossWorkspace = false } = {}) { + const isWorking = session.status === 'working'; + const subtitleText = crossWorkspace + ? session.workspaceName + : `${session.agentType || 'agent'}${session.model ? ` · ${session.model}` : ''}`; + const meta = node('span', { className: 'row-meta' }, [ + node('span', { text: formatRelative(session.activityAt) }), + isWorking + ? node('span', { className: 'status-dot working' }) + : session.unreadCount > 0 + ? node('span', { className: 'badge', text: cappedCount(session.unreadCount) }) + : null, + ]); + const control = node('button', { + className: 'data-row chat-row', + type: 'button', + 'aria-current': state.route.sessionId === session.id ? 'true' : 'false', + on: { + click: async () => { + if (crossWorkspace && session.workspaceId !== state.route.workspaceId) { + await loadSessions(session.workspaceId); + } + closeOverlay(); + await openSession(session.id, { + workspaceId: session.workspaceId, + push: true, + }); + }, + }, + }, [ + node('span', { className: 'row-monogram', text: initials(session.agentType) }), + node('span', { className: 'row-copy' }, [ + node('span', { className: 'row-title', text: session.title }), + node('span', { className: 'row-subtitle', text: subtitleText }), + ]), + meta, + ]); + return node('li', {}, control); +} + +function updateNavSubtitle(element, fallback) { + const voice = connectionVoice(); + element.className = `nav-subtitle ${voice.className}`; + element.replaceChildren(); + if (state.connection === 'live') element.textContent = fallback || 'Live'; + else { + element.append(icon(voice.iconName), document.createTextNode(voice.text)); + } +} + +async function openSession(sessionId, { workspaceId = state.route.workspaceId, push = true } = {}) { + state.route = { view: 'transcript', workspaceId, sessionId }; + persistRoute(); + if (push) history.pushState({ pocketRoute: state.route }, '', '/'); + updateRoutePanels(); + renderWorkspacePanel(); + renderSessionsPanel(); + const cached = await cacheGet(`messages:${sessionId}`); + if (cached?.messages && !state.messagesBySession.has(sessionId)) { + state.messagesBySession.set(sessionId, cached.messages); + state.cursorsBySession.set(sessionId, cached.cursor || 0); + renderTranscript(); + } + const composer = state.shell.composer; + composer.field.value = draftFor(sessionId); + composer.resize(); + renderComposerState(); + await refreshMessages(sessionId, { full: true }); + state.connectionProbe = null; + renderTranscript(); +} + +async function refreshMessages(sessionId, { full = false } = {}) { + if (!sessionId) return; + const cursor = full ? 0 : state.cursorsBySession.get(sessionId) || 0; + try { + const data = await request( + `/api/sessions/${encodeURIComponent(sessionId)}/messages?after=${cursor}`, + ); + const existing = full ? [] : state.messagesBySession.get(sessionId) || []; + const messages = full ? data.messages : [...existing, ...data.messages]; + state.messagesBySession.set(sessionId, dedupeMessages(messages)); + state.cursorsBySession.set(sessionId, data.cursor); + reconcileOptimistic(sessionId); + await cacheSet(`messages:${sessionId}`, { + cursor: data.cursor, + messages: state.messagesBySession.get(sessionId).slice(-50), + }); + if (state.route.sessionId === sessionId) renderTranscript(); + } catch (error) { + handleRuntimeError(error); + } +} + +function dedupeMessages(messages) { + const seen = new Set(); + return messages.filter((message) => { + if (seen.has(message.id)) return false; + seen.add(message.id); + return true; + }); +} + +function reconcileOptimistic(sessionId) { + const serverMessages = state.messagesBySession.get(sessionId) || []; + for (const optimistic of state.optimistic.filter( + (item) => item.sessionId === sessionId && item.delivery === 'delivered', + )) { + const found = serverMessages.some( + (message) => message.kind === 'user' && message.text === optimistic.text, + ); + if (found) state.optimistic = state.optimistic.filter((item) => item !== optimistic); + } +} + +function currentSession() { + return sessionsFor(state.route.workspaceId).find( + (session) => session.id === state.route.sessionId, + ) || state.recentSessions.find((session) => session.id === state.route.sessionId); +} + +function renderTranscript() { + if (!state.shell) return; + const session = currentSession(); + const workspace = state.workspaces.find((item) => item.id === state.route.workspaceId); + const { transcriptNav, transcriptBanner, transcriptScroll, messageList, statusRow } = + state.shell; + transcriptNav.heading.textContent = session?.title || 'Transcript'; + updateNavSubtitle( + transcriptNav.subtitle, + session?.status === 'working' ? 'Working' : workspace?.name || 'Live', + ); + renderBanner(transcriptBanner); + + const distanceBefore = + transcriptScroll.scrollHeight - + transcriptScroll.clientHeight - + transcriptScroll.scrollTop; + const pinned = distanceBefore < 48; + const messages = [ + ...(state.messagesBySession.get(state.route.sessionId) || []), + ...state.optimistic.filter((item) => item.sessionId === state.route.sessionId), + ]; + const toolResults = new Map( + messages + .filter((message) => message.kind === 'tool-result' && message.toolCallId) + .map((message) => [message.toolCallId, message.state]), + ); + const existingNodes = new Map( + [...messageList.children].map((element) => [ + element.dataset.messageId, + element, + ]), + ); + const fragment = document.createDocumentFragment(); + for (const message of messages) { + if (message.kind === 'tool-result' || message.kind === 'status') continue; + const messageId = String(message.id); + const renderKey = messageRenderKey(message, toolResults); + let rendered = existingNodes.get(messageId); + if (!rendered || rendered.__pocketRenderKey !== renderKey) { + rendered = renderMessage(message, toolResults); + if (!rendered) continue; + rendered.dataset.messageId = messageId; + rendered.__pocketRenderKey = renderKey; + if (!state.seenMessageIds.has(messageId)) { + rendered.classList.add('is-new'); + rendered.addEventListener( + 'animationend', + () => rendered.classList.remove('is-new'), + { once: true }, + ); + } + } + state.seenMessageIds.add(messageId); + fragment.append(rendered); + } + messageList.replaceChildren(fragment); + renderAgentStatus(statusRow, session); + requestAnimationFrame(() => { + if (!state.shell) return; + if (pinned) { + transcriptScroll.scrollTo({ + top: transcriptScroll.scrollHeight, + behavior: 'auto', + }); + } + else state.shell.latestButton.hidden = false; + }); + renderComposerState(); +} + +function messageRenderKey(message, toolResults) { + return JSON.stringify([ + message.kind, + message.text, + message.delivery, + message.errorCode, + message.kind === 'tool' + ? toolResults.get(message.toolCallId) || message.state + : message.state, + ]); +} + +function renderBanner(container) { + container.replaceChildren(); + if (state.connection === 'live') return; + const down = state.connection === 'offline'; + const banner = node('div', { + className: `banner ${down ? 'down' : 'wait'}`, + role: 'status', + }, [ + icon(down ? 'wifiOff' : 'refresh'), + node('span', { + className: 'banner-copy', + text: down + ? state.lastHeartbeat + ? `Mac unreachable · Last synced ${formatTime(state.lastHeartbeat)}` + : 'Mac unreachable' + : 'Reconnecting…', + }), + node('button', { + className: 'banner-action', + type: 'button', + text: 'Details', + on: { click: openConnectionSheet }, + }), + ]); + container.append(banner); +} + +function renderMessage(message, toolResults) { + if (message.kind === 'assistant') { + const item = node('li', { + className: 'message assistant', + 'aria-label': `Conductor replied ${message.text}`, + }); + item.append(renderRichText(message.text)); + return item; + } + if (message.kind === 'user' || message.kind === 'optimistic') { + const meta = node('div', { className: 'message-meta' }); + if (message.delivery === 'delivering') { + meta.textContent = 'Delivering…'; + } else if (message.delivery === 'failed') { + meta.classList.add('failed'); + meta.append( + icon('warn'), + document.createTextNode('Failed to deliver · '), + node('button', { + className: 'message-retry', + type: 'button', + text: 'Retry', + on: { click: () => retryMessage(message) }, + }), + ); + } else if (message.queued) { + meta.textContent = 'Queued'; + } else { + meta.append( + icon('checkDouble'), + document.createTextNode(`Delivered ${formatTime(message.deliveredAt || message.sentAt || message.createdAt)}`), + ); + } + return node('li', { + className: 'message user', + 'aria-label': `You said ${message.text}`, + }, [ + node('div', { className: 'user-bubble', text: message.text }), + meta, + ]); + } + if (message.kind === 'tool') { + const stateValue = toolResults.get(message.toolCallId) || message.state; + const details = node('details', { + className: `tool-card ${stateValue === 'failed' ? 'failed' : stateValue === 'running' ? 'running' : ''}`, + }); + const summary = node('summary', { className: 'tool-summary' }, [ + stateValue === 'running' ? node('span', { className: 'status-dot working' }) : icon('terminal'), + node('span', { className: 'tool-summary-copy', text: message.name }), + node('span', { + className: 'tool-duration', + text: stateValue === 'running' ? 'running' : stateValue || 'done', + }), + icon('chevronDown', 'chevron'), + ]); + const detail = node('div', { + className: 'tool-details', + text: + stateValue === 'failed' + ? 'This action failed. Open Conductor on the Mac for its full output.' + : 'Full tool inputs and outputs stay on the Mac.', + }); + details.append(summary, detail); + return node('li', { className: 'message tool' }, details); + } + return null; +} + +function renderRichText(text) { + const fragment = document.createDocumentFragment(); + const sections = String(text).split(/```/); + sections.forEach((section, index) => { + if (index % 2 === 1) { + const firstBreak = section.indexOf('\n'); + const code = firstBreak >= 0 ? section.slice(firstBreak + 1) : section; + fragment.append(node('pre', {}, node('code', { text: code.trimEnd() }))); + return; + } + const paragraphs = section.split(/\n{2,}/); + for (const paragraph of paragraphs) { + if (!paragraph.trim()) continue; + const lines = paragraph.split('\n'); + if (lines.every((line) => /^\s*[-*]\s+/.test(line))) { + const list = node('ul'); + lines.forEach((line) => + list.append(node('li', { text: line.replace(/^\s*[-*]\s+/, '') })), + ); + fragment.append(list); + } else { + fragment.append(node('p', { text: paragraph })); + } + } + }); + return fragment; +} + +function renderAgentStatus(container, session) { + container.replaceChildren(); + container.className = ''; + if (session?.status !== 'working') return; + container.className = 'status-row'; + container.append( + node('span', { className: 'status-dot working' }), + document.createTextNode('Working'), + ); +} + +function renderComposerState() { + if (!state.shell) return; + const { field, send, reason, draftNote } = state.shell.composer; + const hasText = field.value.trim().length > 0; + const down = state.connection === 'offline'; + send.hidden = down; + reason.hidden = !down; + if (down) { + reason.setAttribute('aria-label', "Can't send - Mac unreachable"); + reason.replaceChildren(icon('wifiOff'), document.createTextNode("Can't send")); + } else { + reason.removeAttribute('aria-label'); + } + send.disabled = !hasText; + send.classList.toggle('unverified', hasText && !state.connectionProbe); + draftNote.hidden = !(down && hasText); + draftNote.textContent = down && hasText ? 'Draft saved on this phone' : ''; +} + +async function sendCurrentMessage() { + const sessionId = state.route.sessionId; + const field = state.shell?.composer.field; + if (!sessionId || !field) return; + const text = field.value.replace(/\r\n?/g, '\n'); + if (!text.trim() || state.connection === 'offline') return; + const optimistic = { + id: `optimistic:${randomIdempotencyKey()}`, + idempotencyKey: randomIdempotencyKey(), + kind: 'optimistic', + sessionId, + text, + delivery: 'delivering', + createdAt: new Date().toISOString(), + }; + state.optimistic.push(optimistic); + field.value = ''; + saveDraft(sessionId, ''); + state.shell.composer.resize(); + renderTranscript(); + await deliverOptimistic(optimistic); +} + +async function deliverOptimistic( + optimistic, + { replaceDraft = false, expectedMacDraft = optimistic.macDraft } = {}, +) { + optimistic.replaceDraft = replaceDraft; + if (replaceDraft && !optimistic.replaceIdempotencyKey) { + optimistic.replaceIdempotencyKey = randomIdempotencyKey(); + } + const deliveryKey = replaceDraft + ? optimistic.replaceIdempotencyKey + : optimistic.idempotencyKey; + try { + const result = await fetch( + `/api/sessions/${encodeURIComponent(optimistic.sessionId)}/messages`, + { + method: 'POST', + credentials: 'same-origin', + cache: 'no-store', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-Token': state.csrfToken, + 'Idempotency-Key': deliveryKey, + }, + body: JSON.stringify({ + message: optimistic.text, + replaceDraft, + expectedMacDraft: replaceDraft ? expectedMacDraft : undefined, + }), + }, + ); + const payload = await result.json().catch(() => ({})); + if (!result.ok) { + const error = new Error(payload.error?.code || `http_${result.status}`); + error.code = payload.error?.code || `http_${result.status}`; + error.status = result.status; + error.draft = payload.error?.draft; + throw error; + } + optimistic.delivery = 'delivered'; + optimistic.deliveredAt = payload.deliveredAt; + state.connectionProbe = { + sendPath: true, + capabilities: { send: true }, + }; + renderTranscript(); + announce('Message delivered'); + setTimeout(() => refreshMessages(optimistic.sessionId, { full: true }), 120); + } catch (error) { + optimistic.delivery = 'failed'; + optimistic.errorCode = error.code; + renderTranscript(); + if (error.code === 'draft_conflict') { + optimistic.macDraft = error.draft; + if (replaceDraft) optimistic.replaceIdempotencyKey = null; + optimistic.replaceDraft = false; + openDraftConflict(optimistic); + } + else if (error.status === 401 || error.status === 423) handleRuntimeError(error); + } +} + +function retryMessage(message) { + message.delivery = 'delivering'; + renderTranscript(); + deliverOptimistic(message, { replaceDraft: message.replaceDraft === true }); +} + +function openDraftConflict(message) { + const content = node('div', {}, [ + node('p', { + className: 'gate-lede', + text: + 'The Conductor composer on your Mac already contains unsent text. Pocket did not overwrite it.', + }), + node('section', { className: 'pair-card' }, [ + node('div', { className: 'micro-caps', text: 'On your Mac' }), + node('p', { + className: 'machine-fact', + text: message.macDraft || 'Conductor has an unsent draft.', + }), + node('div', { className: 'pair-divider' }), + node('div', { className: 'micro-caps', text: 'From this phone' }), + node('p', { text: message.text }), + ]), + node('button', { + className: 'primary-button', + type: 'button', + text: 'Replace and send', + on: { + click: () => { + message.delivery = 'delivering'; + closeOverlay(); + renderTranscript(); + deliverOptimistic(message, { + replaceDraft: true, + expectedMacDraft: message.macDraft, + }); + }, + }, + }), + node('button', { + className: 'secondary-button', + type: 'button', + text: 'Keep the Mac draft', + on: { + click: () => { + state.shell.composer.field.value = message.text; + saveDraft(message.sessionId, message.text); + state.optimistic = state.optimistic.filter((item) => item !== message); + closeOverlay(); + renderTranscript(); + }, + }, + }), + ]); + openSheet('Unsent text on your Mac', content); +} + +function startEvents() { + stopEvents(); + const eventSource = new EventSource('/api/events'); + state.eventSource = eventSource; + const live = () => { + state.lastHeartbeat = Date.now(); + if (state.connection !== 'live') { + state.connection = 'live'; + renderConnectionState(); + } + }; + eventSource.addEventListener('ready', live); + eventSource.addEventListener('heartbeat', live); + eventSource.addEventListener('change', async () => { + live(); + await Promise.all([ + refreshWorkspaces(), + state.route.workspaceId ? loadSessions(state.route.workspaceId) : Promise.resolve(), + state.route.sessionId ? refreshMessages(state.route.sessionId) : Promise.resolve(), + ]); + }); + eventSource.addEventListener('locked', async (event) => { + let code = 'device_locked'; + try { + code = JSON.parse(event.data).code || code; + } catch { + // Keep the locked fallback. + } + if (code === 'device_revoked' || code === 'authentication_required') { + await purgeLocalData(); + renderSignedOut(); + } else { + renderLock(); + } + }); + eventSource.onerror = () => { + if (Date.now() - state.lastHeartbeat > 10_000) { + state.connection = state.lastHeartbeat ? 'offline' : 'connecting'; + renderConnectionState(); + } + }; + state.heartbeatTimer = setInterval(() => { + if (Date.now() - state.lastHeartbeat > 10_000) { + state.connection = state.lastHeartbeat ? 'offline' : 'connecting'; + renderConnectionState(); + } + }, 1_000); + state.activityTimer = setInterval(() => { + if (document.hidden || !state.auth || !state.shell) return; + request('/api/auth/touch', { method: 'POST', body: {}, csrf: true }).catch( + (error) => { + if ( + error.status === 401 || + error.status === 423 || + error.code === 'device_revoked' + ) { + handleRuntimeError(error); + } + }, + ); + }, ACTIVITY_HEARTBEAT_MS); +} + +function stopEvents() { + state.eventSource?.close(); + state.eventSource = null; + if (state.heartbeatTimer) clearInterval(state.heartbeatTimer); + if (state.activityTimer) clearInterval(state.activityTimer); + state.heartbeatTimer = null; + state.activityTimer = null; +} + +function renderConnectionState() { + renderWorkspacePanel(); + renderSessionsPanel(); + renderTranscript(); +} + +function handleRuntimeError(error) { + if (error.status === 401 || error.code === 'device_revoked') { + purgeLocalData().finally(renderSignedOut); + } else if (error.status === 423 || error.code === 'device_locked') { + renderLock(); + } +} + +function openSheet(title, content, { className = '', onClose } = {}) { + closeOverlay(); + const close = button('Close', { + iconName: 'close', + onClick: () => closeOverlay(onClose), + }); + const sheet = node('section', { + className: `sheet ${className}`, + role: 'dialog', + 'aria-modal': 'true', + 'aria-label': title, + }, [ + node('div', { className: 'sheet-grabber', 'aria-hidden': 'true' }), + node('header', { className: 'sheet-header' }, [ + node('h2', { className: 'sheet-title', text: title }), + close, + ]), + node('div', { className: 'sheet-scroll' }, content), + ]); + const overlay = node('div', { + className: `overlay${className ? ` ${className}-overlay` : ''}`, + on: { + pointerdown: (event) => { + if (event.target === overlay) closeOverlay(onClose); + }, + }, + }, sheet); + overlayRoot.replaceChildren(overlay); + document.addEventListener('keydown', sheetEscape); + close.focus(); +} + +function sheetEscape(event) { + if (event.key === 'Escape') closeOverlay(); +} + +function closeOverlay(onClose) { + document.removeEventListener('keydown', sheetEscape); + overlayRoot.replaceChildren(); + onClose?.(); +} + +async function openSwitcher() { + await loadRecentSessions(); + const search = node('input', { + className: 'search-input', + type: 'search', + placeholder: 'Search', + 'aria-label': 'Search recent chats', + }); + const list = node('ul', { className: 'row-list' }); + const render = () => { + list.replaceChildren(); + const query = search.value.trim().toLowerCase(); + const sessions = state.recentSessions.filter((session) => + `${session.title} ${session.workspaceName}`.toLowerCase().includes(query), + ); + sessions.forEach((session) => list.append(sessionRow(session, { crossWorkspace: true }))); + if (!sessions.length) { + list.append( + node('li', { className: 'empty-state' }, [ + node('h2', { text: `No matches for “${search.value.trim()}”` }), + ]), + ); + } + }; + search.addEventListener('input', render); + render(); + openSheet( + 'Recent chats', + node('div', {}, [ + node('div', { className: 'search-wrap' }, [icon('search'), search]), + node('div', { className: 'micro-caps', text: 'Recent' }), + list, + ]), + { className: 'switcher' }, + ); +} + +async function openConnectionSheet() { + const startedAt = performance.now(); + const content = node('div', {}, skeletonRows(4)); + openSheet('Connection', content); + try { + const probe = await request('/api/connection?force=1'); + state.connectionProbe = probe; + const latency = Math.round(performance.now() - startedAt); + const rows = [ + ['Private round trip', state.connection === 'live', `${latency} ms`], + ['Relay on your Mac', true, probe.relayVersion], + ['Conductor app', probe.conductor, probe.conductor ? 'Ready' : 'Not ready'], + ['Accessibility send path', probe.sendPath, probe.sendPath ? 'Verified' : probe.reason], + ]; + content.replaceChildren(); + rows.forEach(([label, ok, value]) => { + content.append( + node('div', { className: 'connection-check' }, [ + ok ? icon('check') : icon('warn'), + node('span', { text: label }), + node('span', { className: 'value', text: value }), + ]), + ); + }); + content.append( + node('button', { + className: 'text-button', + type: 'button', + text: 'Run check again', + on: { click: () => openConnectionSheet() }, + }), + ); + renderComposerState(); + } catch (error) { + content.replaceChildren( + node('div', { className: 'empty-state' }, [ + icon('warn'), + node('h2', { text: 'The relay check failed' }), + node('p', { text: 'Confirm Tailscale and the relay are running on your Mac.' }), + ]), + ); + } +} + +async function openSecurity() { + const content = node('div', {}, skeletonRows(4)); + openSheet('Security & Devices', content, { className: 'security' }); + try { + const [devicesResult, connection] = await Promise.all([ + request('/api/devices'), + request('/api/connection'), + ]); + content.replaceChildren(); + const thisPhoneSection = settingsSection('This phone'); + thisPhoneSection.card.append( + settingsRow({ + iconName: 'phone', + title: state.auth?.device?.name || 'This iPhone', + subtitle: `Paired ${formatRelative(state.auth?.device?.createdAt)} · Face ID on`, + }), + settingsRow({ + iconName: 'lock', + title: 'Auto-lock', + subtitle: 'After 5 minutes away', + }), + settingsRow({ + iconName: 'warn', + title: 'Clear cached transcripts', + actionLabel: 'Clear', + destructive: true, + onAction: confirmClearCache, + }), + ); + content.append(thisPhoneSection.root); + + const devicesSection = settingsSection('Paired devices'); + devicesResult.devices.forEach((device) => { + devicesSection.card.append( + settingsRow({ + iconName: 'phone', + title: device.name, + subtitle: `Last seen ${formatRelative(device.lastSeenAt)} · ${device.tailscaleLogin}`, + actionLabel: + device.id === state.auth?.device?.id ? 'Sign out' : 'Revoke', + destructive: true, + onAction: () => confirmRevoke(device), + }), + ); + }); + content.append(devicesSection.root); + + const connectionSection = settingsSection('Connection'); + connectionSection.card.append( + settingsRow({ + iconName: connection.sendPath ? 'check' : 'warn', + title: 'Send path', + subtitle: connection.sendPath ? 'Verified' : connection.reason || 'Unavailable', + }), + settingsRow({ + iconName: 'refresh', + title: 'Test send path', + actionLabel: 'Run', + onAction: openConnectionSheet, + }), + ); + content.append(connectionSection.root); + + const appSection = settingsSection('App'); + appSection.card.append( + settingsRow({ + iconName: 'share', + title: 'Install on this phone', + subtitle: isStandalone() ? "Installed - you're running the app" : 'Safari instructions', + actionLabel: isStandalone() ? null : 'View', + onAction: isStandalone() + ? null + : () => { + closeOverlay(); + renderInstallGuidance(); + }, + }), + settingsRow({ + iconName: 'bolt', + title: 'Conductor Pocket', + subtitle: `pocket ${connection.relayVersion || 'unknown'} · private relay`, + }), + ); + content.append(appSection.root); + } catch (error) { + handleRuntimeError(error); + } +} + +function settingsSection(title) { + const card = node('div', { className: 'settings-card' }); + return { + root: node('section', { className: 'settings-section' }, [ + node('h3', { className: 'section-heading', text: title }), + card, + ]), + card, + }; +} + +function settingsRow({ + iconName, + title, + subtitle, + actionLabel, + destructive = false, + onAction, +}) { + return node('div', { className: 'settings-row' }, [ + icon(iconName), + node('div', { className: 'settings-row-copy' }, [ + node('span', { className: 'settings-row-title', text: title }), + subtitle + ? node('span', { className: 'settings-row-subtitle', text: subtitle }) + : null, + ]), + actionLabel + ? node('button', { + className: `settings-row-action${destructive ? ' destructive' : ''}`, + type: 'button', + text: actionLabel, + on: { click: onAction }, + }) + : null, + ]); +} + +function confirmClearCache() { + openSheet( + 'Clear cached transcripts?', + node('div', {}, [ + node('p', { + className: 'gate-lede', + text: 'Removes transcript copies stored on this phone. Your Mac keeps everything.', + }), + node('button', { + className: 'destructive-button', + type: 'button', + text: 'Clear', + on: { + click: async () => { + await purgeLocalData(); + state.messagesBySession.clear(); + closeOverlay(); + await startApplication(); + }, + }, + }), + node('button', { + className: 'secondary-button', + type: 'button', + text: 'Cancel', + on: { click: () => closeOverlay() }, + }), + ]), + ); +} + +function confirmRevoke(device) { + openSheet( + device.id === state.auth?.device?.id ? 'Sign out this phone?' : `Revoke “${device.name}”?`, + node('div', {}, [ + node('p', { + className: 'gate-lede', + text: + device.id === state.auth?.device?.id + ? 'This phone will lose access immediately. Your Mac keeps everything.' + : `“${device.name}” will be signed out immediately.`, + }), + node('button', { + className: 'destructive-button', + type: 'button', + text: device.id === state.auth?.device?.id ? 'Sign out' : 'Revoke', + on: { + click: async () => { + try { + const result = await request( + `/api/devices/${encodeURIComponent(device.id)}/revoke`, + { method: 'POST', body: {}, csrf: true }, + ); + if (result.currentDevice) { + await purgeLocalData(); + renderSignedOut(); + } else { + openSecurity(); + } + } catch (error) { + handleRuntimeError(error); + } + }, + }, + }), + node('button', { + className: 'secondary-button', + type: 'button', + text: 'Cancel', + on: { click: () => closeOverlay() }, + }), + ]), + ); +} + +window.addEventListener('popstate', (event) => { + if (event.state?.pocketRoute) { + state.route = event.state.pocketRoute; + persistRoute(); + updateRoutePanels(); + renderWorkspacePanel(); + renderSessionsPanel(); + if (state.route.sessionId) openSession(state.route.sessionId, { push: false }); + } +}); + +function shieldApplication() { + const hiddenAt = Date.now(); + state.hiddenAt = hiddenAt; + localStorage.setItem(HIDDEN_AT_KEY, String(hiddenAt)); + stopEvents(); + app.setAttribute('aria-hidden', 'true'); + if (!document.querySelector('#privacy-shield')) { + document.body.append( + node('div', { + id: 'privacy-shield', + className: 'privacy-shield', + 'aria-hidden': 'true', + }), + ); + } +} + +async function revealApplication() { + const persistedHiddenAt = Number(localStorage.getItem(HIDDEN_AT_KEY) || 0); + const hiddenAt = Math.max(state.hiddenAt || 0, persistedHiddenAt); + const awayTooLong = hiddenAt > 0 && Date.now() - hiddenAt >= AWAY_LOCK_MS; + localStorage.removeItem(HIDDEN_AT_KEY); + state.hiddenAt = null; + + if (state.auth && state.shell) { + if (awayTooLong) { + await request('/api/auth/lock', { + method: 'POST', + body: {}, + csrf: true, + }).catch(() => {}); + renderLock(); + } else { + try { + await request('/api/auth/touch', { + method: 'POST', + body: {}, + csrf: true, + }); + startEvents(); + } catch (error) { + if ( + error.status === 401 || + error.status === 423 || + error.code === 'device_revoked' + ) { + handleRuntimeError(error); + } else { + renderConnectionGate(error.code); + } + } + } + } + + document.querySelector('#privacy-shield')?.remove(); + app.removeAttribute('aria-hidden'); +} + +document.addEventListener('visibilitychange', () => { + if (document.hidden) shieldApplication(); + else revealApplication(); +}); + +window.addEventListener('pagehide', shieldApplication); +window.addEventListener('pageshow', () => { + if ( + !document.hidden && + (state.hiddenAt || localStorage.getItem(HIDDEN_AT_KEY)) + ) { + revealApplication(); + } +}); + +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/service-worker.js').catch(() => {}); +} + +const pairingCode = new URLSearchParams(location.hash.slice(1)).get('pair'); +if (pairingCode) initializePairing(pairingCode); +else bootstrap(); diff --git a/tools/conductor-pocket/public/icon.svg b/tools/conductor-pocket/public/icon.svg new file mode 100644 index 00000000..b6e6e0e5 --- /dev/null +++ b/tools/conductor-pocket/public/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/tools/conductor-pocket/public/index.html b/tools/conductor-pocket/public/index.html new file mode 100644 index 00000000..5135b83b --- /dev/null +++ b/tools/conductor-pocket/public/index.html @@ -0,0 +1,48 @@ + + + + + + + + + + + + Conductor Pocket + + + + + + + + +
+
+
+ + diff --git a/tools/conductor-pocket/public/manifest.webmanifest b/tools/conductor-pocket/public/manifest.webmanifest new file mode 100644 index 00000000..6b73b605 --- /dev/null +++ b/tools/conductor-pocket/public/manifest.webmanifest @@ -0,0 +1,18 @@ +{ + "name": "Conductor Pocket", + "short_name": "Pocket", + "description": "Private remote access to Conductor on your Mac.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#141210", + "theme_color": "#141210", + "icons": [ + { + "src": "/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/tools/conductor-pocket/public/service-worker.js b/tools/conductor-pocket/public/service-worker.js new file mode 100644 index 00000000..7f124bbd --- /dev/null +++ b/tools/conductor-pocket/public/service-worker.js @@ -0,0 +1,39 @@ +const CACHE = 'conductor-pocket-shell-v1'; +const SHELL = ['/', '/index.html', '/app.css', '/app.js', '/icon.svg', '/manifest.webmanifest']; + +self.addEventListener('install', (event) => { + event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL))); + self.skipWaiting(); +}); +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key))), + ), + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + const requestUrl = new URL(event.request.url); + if ( + event.request.method !== 'GET' || + requestUrl.origin !== self.location.origin || + requestUrl.pathname.startsWith('/api/') + ) { + return; + } + event.respondWith( + fetch(event.request) + .then((response) => { + if (response.ok) { + const clone = response.clone(); + caches.open(CACHE).then((cache) => cache.put(event.request, clone)); + } + return response; + }) + .catch(() => caches.match(event.request).then((response) => response || caches.match('/'))), + ); +}); diff --git a/tools/conductor-pocket/scripts/install-relay.mjs b/tools/conductor-pocket/scripts/install-relay.mjs new file mode 100644 index 00000000..8aee6a54 --- /dev/null +++ b/tools/conductor-pocket/scripts/install-relay.mjs @@ -0,0 +1,298 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { loadConfig } from '../src/config.mjs'; +import { APP_VERSION } from '../src/constants.mjs'; +import { + assertEmptyTailscaleConfig, + assertPrivateServeStatus, +} from '../src/tailscale-config.mjs'; + +const execFileAsync = promisify(execFile); +const packageRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))); +const configPath = + process.env.CONDUCTOR_POCKET_CONFIG || + path.join(os.homedir(), '.config', 'conductor-pocket', 'config.json'); +const label = 'com.ovo.conductor-pocket'; +const launchAgentPath = path.join( + os.homedir(), + 'Library', + 'LaunchAgents', + `${label}.plist`, +); + +function xml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +async function tailscaleExecutable() { + const candidates = [ + path.join(os.homedir(), '.local', 'bin', 'tailscale'), + '/opt/homebrew/bin/tailscale', + '/usr/local/bin/tailscale', + '/Applications/Tailscale.app/Contents/MacOS/Tailscale', + ]; + for (const candidate of candidates) { + try { + await fs.access(candidate); + return candidate; + } catch { + // Try the next location. + } + } + throw new Error('Tailscale CLI was not found'); +} + +async function run(executable, argumentsList, options = {}) { + return execFileAsync(executable, argumentsList, { + timeout: 30_000, + maxBuffer: 2 * 1024 * 1024, + ...options, + }); +} + +async function npmInvocation() { + const npmExecPath = process.env.npm_execpath; + if (npmExecPath && path.isAbsolute(npmExecPath)) { + try { + await fs.access(npmExecPath); + return { + executable: process.execPath, + prefix: [npmExecPath], + }; + } catch { + // Fall through to standalone npm executables. + } + } + const candidates = [ + path.join(path.dirname(process.execPath), 'npm'), + '/opt/homebrew/bin/npm', + '/usr/local/bin/npm', + ]; + for (const candidate of candidates) { + try { + await fs.access(candidate); + return { executable: candidate, prefix: [] }; + } catch { + // Try the next known location. + } + } + throw new Error('npm was not found; it is required to build the stable relay runtime'); +} + +async function installStableRuntime(dataDirectory) { + const runtimeParent = path.join(dataDirectory, 'runtimes'); + await fs.mkdir(runtimeParent, { recursive: true, mode: 0o700 }); + await fs.chmod(runtimeParent, 0o700); + const suffix = `${APP_VERSION}-${Date.now()}-${process.pid}`; + const stagingDirectory = path.join(runtimeParent, `.installing-${suffix}`); + const runtimeDirectory = path.join(runtimeParent, `runtime-${suffix}`); + await fs.mkdir(stagingDirectory, { mode: 0o700 }); + for (const entry of ['package.json', 'package-lock.json']) { + await fs.copyFile( + path.join(packageRoot, entry), + path.join(stagingDirectory, entry), + ); + } + for (const entry of ['src', 'public']) { + await fs.cp(path.join(packageRoot, entry), path.join(stagingDirectory, entry), { + recursive: true, + errorOnExist: true, + force: false, + }); + } + const npm = await npmInvocation(); + await run( + npm.executable, + [ + ...npm.prefix, + 'ci', + '--omit=dev', + '--ignore-scripts', + '--no-audit', + '--no-fund', + ], + { cwd: stagingDirectory, timeout: 120_000 }, + ); + await fs.rename(stagingDirectory, runtimeDirectory); + return runtimeDirectory; +} + +async function waitForRelay(config) { + const url = `http://127.0.0.1:${config.port}/api/health`; + for (let attempt = 0; attempt < 30; attempt += 1) { + try { + const response = await fetch(url, { + headers: { Host: `127.0.0.1:${config.port}` }, + signal: AbortSignal.timeout(1_000), + }); + if (response.ok) return; + } catch { + // launchd may still be starting the process. + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw new Error('The loopback relay did not become healthy after launch'); +} + +async function install() { + process.umask(0o077); + const config = await loadConfig(configPath); + const configStat = await fs.stat(configPath); + if ((configStat.mode & 0o077) !== 0) { + throw new Error('Refusing to install: relay config is readable outside this user'); + } + if ( + config.developmentMode || + !config.requireTailscaleIdentity || + !config.publicOrigin.startsWith('https://') + ) { + throw new Error( + 'Refusing to install: production HTTPS and Tailscale identity are required', + ); + } + const tailscale = await tailscaleExecutable(); + const { stdout: statusOutput } = await run(tailscale, ['status', '--json']); + const status = JSON.parse(statusOutput); + const dnsName = status.Self?.DNSName?.replace(/\.$/, '') || null; + if (status.BackendState !== 'Running' || !dnsName) { + throw new Error('Tailscale is not connected on this Mac'); + } + if ( + config.publicOrigin !== `https://${dnsName}` || + config.rpId !== dnsName + ) { + throw new Error( + 'Refusing to install: config origin does not match this Mac Tailscale identity', + ); + } + const { stdout: funnelOutput } = await run(tailscale, ['funnel', 'status', '--json']); + const funnel = JSON.parse(funnelOutput || '{}'); + assertEmptyTailscaleConfig(funnel, 'Funnel'); + const { stdout: serveOutput } = await run(tailscale, ['serve', 'status', '--json']); + const serve = JSON.parse(serveOutput || '{}'); + assertEmptyTailscaleConfig(serve, 'Serve'); + + const dataDirectory = path.dirname(configPath); + await fs.mkdir(dataDirectory, { recursive: true, mode: 0o700 }); + await fs.chmod(dataDirectory, 0o700); + const runtimeDirectory = await installStableRuntime(dataDirectory); + const stdoutPath = path.join(dataDirectory, 'relay.out.log'); + const stderrPath = path.join(dataDirectory, 'relay.err.log'); + const cliPath = path.join(runtimeDirectory, 'src', 'cli.mjs'); + const plist = ` + + + + Label + ${label} + ProgramArguments + + ${xml(process.execPath)} + --no-warnings=ExperimentalWarning + ${xml(cliPath)} + serve + --config + ${xml(configPath)} + + WorkingDirectory + ${xml(runtimeDirectory)} + RunAtLoad + + KeepAlive + + SuccessfulExit + + + ProcessType + Background + StandardOutPath + ${xml(stdoutPath)} + StandardErrorPath + ${xml(stderrPath)} + + +`; + await fs.mkdir(path.dirname(launchAgentPath), { recursive: true }); + const launchAgentTemporaryPath = `${launchAgentPath}.tmp-${process.pid}`; + await fs.writeFile(launchAgentTemporaryPath, plist, { + mode: 0o600, + flag: 'wx', + }); + await fs.rename(launchAgentTemporaryPath, launchAgentPath); + await fs.chmod(launchAgentPath, 0o600); + + const serviceTarget = `gui/${process.getuid()}/${label}`; + try { + await run('/bin/launchctl', ['bootout', serviceTarget]); + } catch { + // The service was not installed yet. + } + await run('/bin/launchctl', [ + 'bootstrap', + `gui/${process.getuid()}`, + launchAgentPath, + ]); + await waitForRelay(config); + + let configuredServe = false; + try { + await run(tailscale, [ + 'serve', + '--bg', + '--yes', + `http://127.0.0.1:${config.port}`, + ]); + configuredServe = true; + const { stdout: finalServeOutput } = await run(tailscale, [ + 'serve', + 'status', + '--json', + ]); + assertPrivateServeStatus(JSON.parse(finalServeOutput || '{}'), { + rpId: config.rpId, + port: config.port, + }); + const { stdout: finalFunnelOutput } = await run(tailscale, [ + 'funnel', + 'status', + '--json', + ]); + assertEmptyTailscaleConfig( + JSON.parse(finalFunnelOutput || '{}'), + 'Funnel', + ); + } catch (error) { + if (configuredServe) { + await run(tailscale, ['serve', 'off']).catch(() => {}); + } + throw error; + } + + process.stdout.write(`Conductor Pocket relay installed. + +Private URL: ${config.publicOrigin} +LaunchAgent: ${launchAgentPath} +Stable runtime: ${runtimeDirectory} + +One Mac permission remains: +System Settings → Privacy & Security → Accessibility +Allow the Node executable used by this relay: +${process.execPath} + +Then run: npm run doctor +`); +} + +install().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/tools/conductor-pocket/scripts/ui-fixture.mjs b/tools/conductor-pocket/scripts/ui-fixture.mjs new file mode 100644 index 00000000..ffeac1ef --- /dev/null +++ b/tools/conductor-pocket/scripts/ui-fixture.mjs @@ -0,0 +1,496 @@ +import http from 'node:http'; +import { createConfig } from '../src/config.mjs'; +import { createPocketServer } from '../src/server.mjs'; + +const port = Number(process.env.POCKET_UI_PORT || 4320); +const fixtureMode = process.env.POCKET_UI_MODE || 'unlocked'; +const now = Date.now(); +const at = (minutesAgo) => new Date(now - minutesAgo * 60_000).toISOString(); + +const workspaces = [ + { + id: 'w-pocket', + name: 'iphone conductor', + repositoryName: 'Quickstart', + branch: 'understand-conductor-setup', + state: 'active', + pinned: true, + sessionCount: 3, + unreadCount: 0, + workingCount: 1, + activityAt: at(0), + }, + { + id: 'w-academy', + name: 'OVO Academy', + repositoryName: 'ovo-academy', + branch: 'main', + state: 'active', + pinned: true, + sessionCount: 8, + unreadCount: 4, + workingCount: 0, + activityAt: at(12), + }, + { + id: 'w-finance', + name: 'ovo finances', + repositoryName: 'finance', + branch: 'reconcile-july', + state: 'active', + pinned: false, + sessionCount: 2, + unreadCount: 1, + workingCount: 0, + activityAt: at(38), + }, + { + id: 'w-outbound', + name: 'outbound', + repositoryName: 'outbound', + branch: 'agent-routing', + state: 'active', + pinned: false, + sessionCount: 5, + unreadCount: 0, + workingCount: 0, + activityAt: at(88), + }, + { + id: 'w-social', + name: 'social media', + repositoryName: 'social', + branch: 'main', + state: 'active', + pinned: false, + sessionCount: 4, + unreadCount: 0, + workingCount: 0, + activityAt: at(210), + }, + { + id: 'w-sync', + name: 'card sync', + repositoryName: 'media', + branch: 'sony-import', + state: 'active', + pinned: false, + sessionCount: 1, + unreadCount: 0, + workingCount: 0, + activityAt: at(1_440), + }, +]; + +const sessionsByWorkspace = new Map([ + [ + 'w-pocket', + [ + { + id: 's-pocket', + workspaceId: 'w-pocket', + title: 'Explain conductor operations', + agentType: 'codex', + model: 'gpt-5.6-sol', + status: 'working', + unreadCount: 0, + queuedCount: 0, + queuePaused: false, + contextUsedPercent: 42, + activityAt: at(0), + }, + { + id: 's-security', + workspaceId: 'w-pocket', + title: 'Threat model the iPhone relay', + agentType: 'claude', + model: 'claude-opus-4.6', + status: 'idle', + unreadCount: 2, + queuedCount: 0, + queuePaused: false, + contextUsedPercent: 18, + activityAt: at(14), + }, + { + id: 's-research', + workspaceId: 'w-pocket', + title: 'Research Conductor transport options', + agentType: 'codex', + model: 'gpt-5.4', + status: 'idle', + unreadCount: 0, + queuedCount: 0, + queuePaused: false, + contextUsedPercent: 61, + activityAt: at(43), + }, + ], + ], + [ + 'w-academy', + [ + { + id: 's-academy', + workspaceId: 'w-academy', + title: 'Review checkout funnel', + agentType: 'claude', + model: 'claude-fable-5', + status: 'idle', + unreadCount: 4, + queuedCount: 0, + queuePaused: false, + contextUsedPercent: 27, + activityAt: at(12), + }, + ], + ], +]); + +for (const workspace of workspaces) { + if (!sessionsByWorkspace.has(workspace.id)) { + sessionsByWorkspace.set(workspace.id, [ + { + id: `s-${workspace.id}`, + workspaceId: workspace.id, + title: `Continue ${workspace.name}`, + agentType: 'codex', + model: 'gpt-5.4', + status: 'idle', + unreadCount: workspace.unreadCount, + queuedCount: 0, + queuePaused: false, + contextUsedPercent: 24, + activityAt: workspace.activityAt, + }, + ]); + } +} + +const recentSessions = [...sessionsByWorkspace.values()] + .flat() + .map((session) => ({ + ...session, + workspaceName: + workspaces.find((workspace) => workspace.id === session.workspaceId)?.name || + 'Workspace', + })) + .sort((left, right) => Date.parse(right.activityAt) - Date.parse(left.activityAt)); + +const messages = [ + { + id: 'm-1', + rowId: 1, + kind: 'user', + text: + 'I want a clean way to use my actual Conductor chats from my iPhone. It needs to feel instant and the security has to be extremely tight.', + createdAt: at(9), + sentAt: at(9), + queued: false, + }, + { + id: 'm-2', + rowId: 2, + kind: 'assistant', + text: + 'Yes. The right shape is a private companion, not another model client. Conductor stays the source of truth on your Mac, while this phone becomes a secure window into the same workspaces and sessions.', + createdAt: at(8), + model: 'gpt-5.6-sol', + }, + { + id: 'm-3', + rowId: 3, + kind: 'tool', + toolCallId: 'tool-1', + name: 'Inspect Conductor Database', + state: 'completed', + createdAt: at(7), + }, + { + id: 'm-4', + rowId: 4, + kind: 'tool-result', + toolCallId: 'tool-1', + state: 'completed', + createdAt: at(7), + }, + { + id: 'm-5', + rowId: 5, + kind: 'assistant', + text: + 'The relay now has three hard boundaries:\n\n- it reads the Conductor database in read-only mode\n- it accepts traffic only through private Tailscale Serve\n- every unlock requires the phone passkey and Face ID\n\n```text\nphone → tailnet HTTPS → loopback relay → Conductor\n```\n\nNothing is copied to a public host.', + createdAt: at(5), + model: 'gpt-5.6-sol', + }, + { + id: 'm-6', + rowId: 6, + kind: 'user', + text: 'Will the two-way sync actually be fast?', + createdAt: at(3), + sentAt: at(3), + queued: false, + }, + { + id: 'm-7', + rowId: 7, + kind: 'assistant', + text: + 'On a healthy tailnet, new transcript rows should appear in hundreds of milliseconds. Phone sends are serialized through the real Conductor composer, so account and model selection remain exactly where you configured them.', + createdAt: at(1), + model: 'gpt-5.6-sol', + }, +]; + +const { config } = createConfig({ + publicOrigin: `http://127.0.0.1:${port}`, + port, + developmentMode: true, +}); +config.macName = "Alex's MacBook Pro"; + +const watcher = { + subscribe() { + return () => {}; + }, + stop() {}, +}; + +const database = { + listWorkspaces() { + return fixtureMode === 'empty' ? [] : workspaces; + }, + listRecentSessions(limit) { + if (fixtureMode === 'empty') return []; + return recentSessions.slice(0, Number(limit) || 50); + }, + listSessions(workspaceId) { + return sessionsByWorkspace.get(workspaceId) || []; + }, + getSessionRoute(sessionId) { + const session = recentSessions.find((candidate) => candidate.id === sessionId); + if (!session) return null; + return { + id: session.id, + workspaceId: session.workspaceId, + workspaceName: session.workspaceName, + title: session.title, + titleOrdinal: 1, + status: session.status, + agentType: session.agentType, + model: session.model, + }; + }, + listMessages(sessionId, { after = 0 } = {}) { + if (!recentSessions.some((candidate) => candidate.id === sessionId)) return null; + const source = sessionId === 's-pocket' ? messages : []; + return { + cursor: source.at(-1)?.rowId || 0, + messages: source.filter((message) => message.rowId > Number(after || 0)), + }; + }, +}; + +const security = { + async startPairing() { + return { + options: { + challenge: 'Zml4dHVyZS1jaGFsbGVuZ2U', + rp: { id: '127.0.0.1', name: 'Conductor Pocket' }, + user: { + id: 'Zml4dHVyZS11c2Vy', + name: 'This iPhone', + displayName: 'This iPhone', + }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + timeout: 60_000, + authenticatorSelection: { + authenticatorAttachment: 'platform', + residentKey: 'required', + userVerification: 'required', + }, + attestation: 'none', + }, + setCookie: + '__Host-cp_pair=fixture; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=300', + }; + }, + bootstrap() { + return { + authenticated: true, + unlocked: fixtureMode !== 'locked', + unlockedUntil: + fixtureMode === 'locked' + ? null + : new Date(Date.now() + 5 * 60_000).toISOString(), + csrfToken: 'ui-fixture-csrf', + device: { + id: 'device-this-phone', + name: "Alex's iPhone", + createdAt: at(14_400), + lastSeenAt: at(0), + tailscaleLogin: 'alex@example.com', + passkeyBackedUp: true, + }, + }; + }, + session() { + return { + device: { id: 'device-this-phone' }, + csrfToken: 'ui-fixture-csrf', + unlocked: true, + }; + }, + assertOrigin() {}, + lock() { + return { locked: true }; + }, + touch() { + return { + unlocked: true, + unlockedUntil: new Date(Date.now() + 5 * 60_000).toISOString(), + }; + }, + listDevices() { + return [ + { + id: 'device-this-phone', + name: "Alex's iPhone", + createdAt: at(14_400), + lastSeenAt: at(0), + tailscaleLogin: 'alex@example.com', + passkeyBackedUp: true, + }, + { + id: 'device-spare', + name: 'Travel iPhone', + createdAt: at(43_200), + lastSeenAt: at(1_440), + tailscaleLogin: 'alex@example.com', + passkeyBackedUp: true, + }, + ]; + }, + async revokeDevice(_request, deviceId) { + return { + revoked: true, + currentDevice: deviceId === 'device-this-phone', + setCookie: null, + }; + }, +}; + +const transport = { + async doctor() { + return { ok: true, code: 'ready' }; + }, + async send({ message }) { + if (fixtureMode === 'conflict') { + return { + ok: false, + code: 'draft_conflict', + draftBase64: Buffer.from( + 'Draft already being written on the Mac', + 'utf8', + ).toString('base64'), + }; + } + if (fixtureMode === 'sendfail') { + return { ok: false, code: 'send_failed' }; + } + messages.push({ + id: `m-${messages.length + 1}`, + rowId: messages.length + 1, + kind: 'user', + text: message, + createdAt: new Date().toISOString(), + sentAt: new Date().toISOString(), + queued: false, + }); + return { ok: true, code: 'sent' }; + }, +}; + +const server = createPocketServer({ + configStore: { value: config }, + security, + database, + watcher, + transport, +}); + +const usesConnectionProxy = + fixtureMode === 'offline' || fixtureMode === 'reconnecting'; +const relayPort = usesConnectionProxy ? port + 1_000 : port; +let proxy = null; + +function startConnectionProxy() { + let servedInitialStream = false; + proxy = http.createServer((request, response) => { + const isEventStream = request.url?.startsWith('/api/events'); + if ( + isEventStream && + (fixtureMode === 'reconnecting' || servedInitialStream) + ) { + response.writeHead(503, { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json; charset=utf-8', + }); + response.end('{"error":{"code":"fixture_mac_unreachable"}}'); + return; + } + + if (isEventStream) servedInitialStream = true; + const upstream = http.request( + { + host: config.bindHost, + port: relayPort, + path: request.url, + method: request.method, + headers: request.headers, + }, + (upstreamResponse) => { + response.writeHead( + upstreamResponse.statusCode || 502, + upstreamResponse.headers, + ); + upstreamResponse.pipe(response); + if (isEventStream) { + setTimeout(() => { + upstream.destroy(); + response.destroy(); + }, 250).unref(); + } + }, + ); + upstream.on('error', () => { + if (!response.headersSent) { + response.writeHead(502, { + 'Content-Type': 'application/json; charset=utf-8', + }); + } + response.end('{"error":{"code":"fixture_upstream_unavailable"}}'); + }); + request.pipe(upstream); + }); + proxy.listen(port, config.bindHost, () => { + process.stdout.write( + `Conductor Pocket ${fixtureMode} UI fixture: ${config.publicOrigin}\n`, + ); + }); +} + +server.listen(relayPort, config.bindHost, () => { + if (usesConnectionProxy) startConnectionProxy(); + else process.stdout.write(`Conductor Pocket UI fixture: ${config.publicOrigin}\n`); +}); + +function shutdown() { + const closeRelay = () => server.close(() => process.exit(0)); + if (proxy) proxy.close(closeRelay); + else closeRelay(); +} + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); diff --git a/tools/conductor-pocket/src/accessibility.mjs b/tools/conductor-pocket/src/accessibility.mjs new file mode 100644 index 00000000..b6d36c89 --- /dev/null +++ b/tools/conductor-pocket/src/accessibility.mjs @@ -0,0 +1,125 @@ +import { execFile } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { MAX_MESSAGE_BYTES } from './constants.mjs'; +import { normalizeText } from './encoding.mjs'; + +const execFileAsync = promisify(execFile); +const scriptPath = fileURLToPath(new URL('./conductor-send.applescript', import.meta.url)); + +function byteLength(value) { + return Buffer.byteLength(value, 'utf8'); +} + +function parseResult(stdout) { + const trimmed = stdout.trim(); + try { + const result = JSON.parse(trimmed); + if (!result || typeof result.ok !== 'boolean' || typeof result.code !== 'string') { + throw new Error('Unexpected result shape'); + } + return result; + } catch { + return { ok: false, code: 'automation_invalid_response' }; + } +} + +function mapAutomationError(error) { + const details = `${error?.stderr || ''}\n${error?.message || ''}`.toLowerCase(); + if ( + details.includes('not authorized to send apple events') || + details.includes('not allowed assistive access') || + details.includes('(-1743)') || + details.includes('(-25211)') + ) { + return { ok: false, code: 'accessibility_disabled' }; + } + if (error?.killed || error?.signal === 'SIGTERM') { + return { ok: false, code: 'automation_timeout' }; + } + return { ok: false, code: 'automation_failed' }; +} + +export class AccessibilityTransport { + #queue = Promise.resolve(); + + doctor() { + return this.#run({ operation: 'doctor' }); + } + + send({ + workspaceName, + sessionTitle, + sessionOrdinal, + message, + replaceDraft = false, + expectedMacDraft, + }) { + const normalized = normalizeText(message); + if (!normalized.trim()) { + return Promise.resolve({ ok: false, code: 'message_empty' }); + } + if (normalized.includes('\0')) { + return Promise.resolve({ ok: false, code: 'message_invalid' }); + } + if (byteLength(normalized) > MAX_MESSAGE_BYTES) { + return Promise.resolve({ ok: false, code: 'message_too_large' }); + } + const normalizedExpectedDraft = + typeof expectedMacDraft === 'string' + ? normalizeText(expectedMacDraft) + : null; + if (replaceDraft && normalizedExpectedDraft === null) { + return Promise.resolve({ ok: false, code: 'draft_recheck_required' }); + } + if ( + normalizedExpectedDraft !== null && + byteLength(normalizedExpectedDraft) > MAX_MESSAGE_BYTES + ) { + return Promise.resolve({ ok: false, code: 'draft_recheck_required' }); + } + const task = () => + this.#run({ + operation: 'send', + workspaceName, + sessionTitle, + sessionOrdinal, + message: normalized, + replaceDraft, + expectedMacDraft: normalizedExpectedDraft || '', + }); + this.#queue = this.#queue.then(task, task); + return this.#queue; + } + + async #run({ + operation, + workspaceName = '', + sessionTitle = '', + sessionOrdinal = 1, + message = '', + replaceDraft = false, + expectedMacDraft = '', + }) { + try { + const { stdout } = await execFileAsync('/usr/bin/osascript', [scriptPath], { + encoding: 'utf8', + timeout: 20_000, + maxBuffer: 64 * 1024, + env: { + ...process.env, + POCKET_OPERATION: operation, + POCKET_WORKSPACE_NAME: workspaceName, + POCKET_SESSION_TITLE: sessionTitle, + POCKET_SESSION_ORDINAL: String(sessionOrdinal), + POCKET_MESSAGE: message, + POCKET_REPLACE_DRAFT: replaceDraft ? 'true' : 'false', + POCKET_EXPECTED_DRAFT: expectedMacDraft, + }, + }); + return parseResult(stdout); + } catch (error) { + return mapAutomationError(error); + } + } +} diff --git a/tools/conductor-pocket/src/cli.mjs b/tools/conductor-pocket/src/cli.mjs new file mode 100644 index 00000000..29741f27 --- /dev/null +++ b/tools/conductor-pocket/src/cli.mjs @@ -0,0 +1,289 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { AccessibilityTransport } from './accessibility.mjs'; +import { + ConfigStore, + createConfig, + getVerificationCode, + loadConfig, + rotatePairing, + saveConfig, +} from './config.mjs'; +import { + APP_NAME, + APP_VERSION, + DEFAULT_CONFIG_PATH, + DEFAULT_PORT, +} from './constants.mjs'; +import { ConductorDatabase, DatabaseWatcher } from './conductor-db.mjs'; +import { SecurityManager } from './security.mjs'; +import { createPocketServer } from './server.mjs'; + +const execFileAsync = promisify(execFile); +process.umask(0o077); + +function parseArguments(values) { + const parsed = { _: [] }; + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (!value.startsWith('--')) { + parsed._.push(value); + continue; + } + const [key, inlineValue] = value.slice(2).split('=', 2); + if (inlineValue !== undefined) { + parsed[key] = inlineValue; + } else if (values[index + 1] && !values[index + 1].startsWith('--')) { + parsed[key] = values[index + 1]; + index += 1; + } else { + parsed[key] = true; + } + } + return parsed; +} + +function integerOption(value, fallback) { + if (value == null) return fallback; + const parsed = Number(value); + if (!Number.isInteger(parsed)) throw new Error(`Expected an integer, got ${value}`); + return parsed; +} + +async function existingExecutable(candidates) { + for (const candidate of candidates) { + try { + await fs.access(candidate); + return candidate; + } catch { + // Try the next known location. + } + } + return null; +} + +async function tailscaleExecutable() { + return existingExecutable([ + path.join(os.homedir(), '.local', 'bin', 'tailscale'), + '/opt/homebrew/bin/tailscale', + '/usr/local/bin/tailscale', + '/Applications/Tailscale.app/Contents/MacOS/Tailscale', + ]); +} + +async function tailscaleStatus() { + const executable = await tailscaleExecutable(); + if (!executable) return { ok: false, reason: 'tailscale_not_installed' }; + try { + const { stdout } = await execFileAsync(executable, ['status', '--json'], { + timeout: 10_000, + maxBuffer: 2 * 1024 * 1024, + }); + const status = JSON.parse(stdout); + const dnsName = status.Self?.DNSName?.replace(/\.$/, '') || null; + if (status.BackendState !== 'Running' || !dnsName) { + return { ok: false, reason: 'tailscale_not_running', executable, status }; + } + return { + ok: true, + executable, + dnsName, + publicOrigin: `https://${dnsName}`, + self: status.Self, + }; + } catch { + return { ok: false, reason: 'tailscale_status_failed', executable }; + } +} + +async function macName() { + try { + const { stdout } = await execFileAsync('/usr/sbin/scutil', [ + '--get', + 'ComputerName', + ]); + if (stdout.trim()) return stdout.trim(); + } catch { + // The hostname is a safe fallback. + } + return os.hostname(); +} + +function pairingOutput(config, pairingCode) { + const url = `${config.publicOrigin}/#pair=${encodeURIComponent(pairingCode)}`; + return [ + '', + 'Pairing link (single use, expires in 15 minutes):', + url, + '', + `Verification code shown on both devices: ${getVerificationCode(config)}`, + '', + 'Do not post this link or put it in a shared password manager.', + ].join('\n'); +} + +async function setup(options) { + const configPath = path.resolve(options.config || DEFAULT_CONFIG_PATH); + const port = integerOption(options.port, DEFAULT_PORT); + const developmentMode = options.development === true; + let publicOrigin = options.origin; + if (!publicOrigin && developmentMode) { + publicOrigin = `http://127.0.0.1:${port}`; + } + if (!publicOrigin) { + const tailscale = await tailscaleStatus(); + if (!tailscale.ok) { + throw new Error( + 'Tailscale must be connected before setup. Open Tailscale on the Mac and rerun npm run setup.', + ); + } + publicOrigin = tailscale.publicOrigin; + } + + try { + await fs.access(configPath); + throw new Error( + `Config already exists at ${configPath}. Use the pair command to create another one-time link.`, + ); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + + const created = createConfig({ + publicOrigin, + port, + developmentMode, + requireTailscaleIdentity: !developmentMode, + }); + created.config.macName = await macName(); + await saveConfig(configPath, created.config); + process.stdout.write( + `${APP_NAME} config created at ${configPath}\n${pairingOutput( + created.config, + created.pairingCode, + )}\n`, + ); +} + +async function pair(options) { + const configPath = path.resolve(options.config || DEFAULT_CONFIG_PATH); + const config = await loadConfig(configPath); + const rotated = rotatePairing(config); + await saveConfig(configPath, rotated.config); + process.stdout.write(`${pairingOutput(rotated.config, rotated.pairingCode)}\n`); +} + +async function serve(options) { + const configPath = path.resolve(options.config || DEFAULT_CONFIG_PATH); + const config = await loadConfig(configPath); + const store = new ConfigStore(configPath, config); + const database = new ConductorDatabase(config.dbPath); + const watcher = new DatabaseWatcher(config.dbPath); + const transport = new AccessibilityTransport(); + const security = new SecurityManager(store); + const server = createPocketServer({ + configStore: store, + security, + database, + watcher, + transport, + }); + watcher.start(); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(config.port, config.bindHost, resolve); + }); + process.stdout.write( + `${APP_NAME} ${APP_VERSION} listening on http://${config.bindHost}:${config.port}\nPrivate URL: ${config.publicOrigin}\n`, + ); + + const shutdown = () => { + server.close(() => { + database.close(); + process.exit(0); + }); + setTimeout(() => process.exit(1), 5_000).unref(); + }; + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); +} + +async function doctor(options) { + const configPath = path.resolve(options.config || DEFAULT_CONFIG_PATH); + let config; + try { + config = await loadConfig(configPath); + } catch { + config = null; + } + const tailscale = await tailscaleStatus(); + const transport = new AccessibilityTransport(); + const accessibility = await transport.doctor(); + let database = { ok: false, reason: 'config_missing' }; + if (config) { + try { + const conductor = new ConductorDatabase(config.dbPath); + const counts = conductor.listWorkspaces().length; + conductor.close(); + database = { ok: true, workspaceCount: counts }; + } catch { + database = { ok: false, reason: 'database_unavailable' }; + } + } + process.stdout.write( + `${JSON.stringify( + { + config: config + ? { + ok: true, + path: configPath, + publicOrigin: config.publicOrigin, + loopbackOnly: config.bindHost === '127.0.0.1', + tailscaleIdentityRequired: config.requireTailscaleIdentity, + pairedDevices: config.devices.length, + } + : { ok: false, path: configPath }, + tailscale: { + ok: tailscale.ok, + reason: tailscale.ok ? null : tailscale.reason, + dnsName: tailscale.ok ? tailscale.dnsName : null, + }, + conductorDatabase: database, + accessibility, + }, + null, + 2, + )}\n`, + ); +} + +function usage() { + process.stdout.write(`Usage: + node src/cli.mjs setup [--origin https://mac.tailnet.ts.net] [--port 4317] + node src/cli.mjs pair + node src/cli.mjs serve + node src/cli.mjs doctor + +All commands accept --config /absolute/path/config.json. +`); +} + +const options = parseArguments(process.argv.slice(2)); +const command = options._[0]; + +try { + if (command === 'setup') await setup(options); + else if (command === 'pair') await pair(options); + else if (command === 'serve') await serve(options); + else if (command === 'doctor') await doctor(options); + else usage(); +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} diff --git a/tools/conductor-pocket/src/conductor-db.mjs b/tools/conductor-pocket/src/conductor-db.mjs new file mode 100644 index 00000000..366a9ce8 --- /dev/null +++ b/tools/conductor-pocket/src/conductor-db.mjs @@ -0,0 +1,440 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { DB_POLL_MS } from './constants.mjs'; + +function workspaceDisplayName(row) { + return ( + row.workspace_name || + row.placeholder_branch_name || + row.branch || + row.directory_name || + 'Untitled workspace' + ); +} + +function parseJson(value) { + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function toolLabel(name) { + if (!name || typeof name !== 'string') return 'Tool'; + return name + .replace(/^mcp__/, '') + .replaceAll('__', ' · ') + .replaceAll('_', ' ') + .replace(/\b\w/g, (character) => character.toUpperCase()); +} + +function parseAssistantRow(row) { + if (row.role === 'user') { + return [ + { + id: row.id, + rowId: row.row_id, + kind: 'user', + text: row.content, + createdAt: row.created_at, + sentAt: row.sent_at, + cancelledAt: row.cancelled_at, + queued: row.sent_at == null && row.cancelled_at == null, + }, + ]; + } + + const event = parseJson(row.content); + if (!event || typeof event !== 'object') return []; + + if (event.type === 'assistant' && Array.isArray(event.message?.content)) { + const messages = []; + for (let index = 0; index < event.message.content.length; index += 1) { + const item = event.message.content[index]; + if (!item || typeof item !== 'object') continue; + if (item.type === 'text' && typeof item.text === 'string' && item.text.trim()) { + messages.push({ + id: `${row.id}:${index}`, + rowId: row.row_id, + kind: 'assistant', + text: item.text, + createdAt: row.created_at, + model: row.model || null, + turnId: row.turn_id || null, + }); + } else if (item.type === 'tool_use') { + messages.push({ + id: `${row.id}:${index}`, + rowId: row.row_id, + kind: 'tool', + toolCallId: item.id || null, + name: toolLabel(item.name), + state: 'running', + createdAt: row.created_at, + turnId: row.turn_id || null, + }); + } + } + return messages; + } + + if (event.type === 'user' && Array.isArray(event.message?.content)) { + return event.message.content.flatMap((item, index) => { + if (!item || item.type !== 'tool_result') return []; + return [ + { + id: `${row.id}:${index}`, + rowId: row.row_id, + kind: 'tool-result', + toolCallId: item.tool_use_id || null, + state: item.is_error ? 'failed' : 'completed', + createdAt: row.created_at, + turnId: row.turn_id || null, + }, + ]; + }); + } + + if (event.type === 'system') { + const subtype = typeof event.subtype === 'string' ? event.subtype : 'status'; + const status = typeof event.status === 'string' ? event.status : null; + if (subtype === 'init') return []; + return [ + { + id: row.id, + rowId: row.row_id, + kind: 'status', + label: status || subtype.replaceAll('_', ' '), + createdAt: row.created_at, + }, + ]; + } + + return []; +} + +export class ConductorDatabase { + #db; + #dbPath; + #statements; + + constructor(dbPath) { + this.#dbPath = dbPath; + this.#db = new DatabaseSync(dbPath, { readOnly: true }); + this.#db.exec('PRAGMA query_only = ON; PRAGMA busy_timeout = 1000;'); + this.#statements = { + workspaces: this.#db.prepare(` + SELECT + w.id, + w.workspace_name, + w.placeholder_branch_name, + w.branch, + w.directory_name, + w.state, + w.unread, + w.pinned_at, + w.updated_at, + r.name AS repository_name, + COUNT(s.id) AS session_count, + COALESCE(SUM(s.unread_count), 0) AS unread_count, + MAX(COALESCE(s.last_user_message_at, s.updated_at, s.created_at)) AS activity_at, + SUM(CASE WHEN s.status = 'working' THEN 1 ELSE 0 END) AS working_count + FROM workspaces w + JOIN repos r ON r.id = w.repository_id + LEFT JOIN sessions s ON s.workspace_id = w.id AND s.is_hidden = 0 + WHERE w.state != 'archived' AND r.hidden = 0 + GROUP BY w.id + ORDER BY + CASE WHEN w.pinned_at IS NULL THEN 1 ELSE 0 END, + w.pinned_at DESC, + activity_at DESC, + w.updated_at DESC + `), + sessions: this.#db.prepare(` + SELECT + s.id, + s.workspace_id, + s.title, + s.agent_type, + s.model, + s.status, + s.unread_count, + s.created_at, + s.updated_at, + s.last_user_message_at, + s.context_used_percent, + s.queue_paused_at, + ( + SELECT COUNT(*) + FROM session_messages queued + WHERE queued.session_id = s.id + AND queued.role = 'user' + AND queued.sent_at IS NULL + AND queued.cancelled_at IS NULL + ) AS queued_count + FROM sessions s + WHERE s.workspace_id = ? AND s.is_hidden = 0 + ORDER BY COALESCE(s.last_user_message_at, s.updated_at, s.created_at) DESC + `), + recentSessions: this.#db.prepare(` + SELECT + s.id, + s.workspace_id, + s.title, + s.agent_type, + s.model, + s.status, + s.unread_count, + s.created_at, + s.updated_at, + s.last_user_message_at, + w.workspace_name, + w.placeholder_branch_name, + w.branch, + w.directory_name + FROM sessions s + JOIN workspaces w ON w.id = s.workspace_id + JOIN repos r ON r.id = w.repository_id + WHERE s.is_hidden = 0 AND w.state != 'archived' AND r.hidden = 0 + ORDER BY COALESCE(s.last_user_message_at, s.updated_at, s.created_at) DESC + LIMIT ? + `), + sessionRoute: this.#db.prepare(` + WITH ranked AS ( + SELECT + s.id, + s.workspace_id, + s.title, + s.status, + s.agent_type, + s.model, + w.workspace_name, + w.placeholder_branch_name, + w.branch, + w.directory_name, + ROW_NUMBER() OVER ( + PARTITION BY s.workspace_id, s.title + ORDER BY s.created_at, s.id + ) AS title_ordinal + FROM sessions s + JOIN workspaces w ON w.id = s.workspace_id + WHERE s.is_hidden = 0 + ) + SELECT * FROM ranked WHERE id = ? + `), + latestMessages: this.#db.prepare(` + SELECT + rowid AS row_id, + id, + role, + content, + created_at, + sent_at, + cancelled_at, + model, + turn_id + FROM session_messages + WHERE session_id = ? + ORDER BY rowid DESC + LIMIT ? + `), + messagesAfter: this.#db.prepare(` + SELECT + rowid AS row_id, + id, + role, + content, + created_at, + sent_at, + cancelled_at, + model, + turn_id + FROM session_messages + WHERE session_id = ? AND rowid > ? + ORDER BY rowid + LIMIT ? + `), + maxRowId: this.#db.prepare(` + SELECT COALESCE(MAX(rowid), 0) AS row_id + FROM session_messages + WHERE session_id = ? + `), + }; + } + + get path() { + return this.#dbPath; + } + + close() { + this.#db.close(); + } + + listWorkspaces() { + return this.#statements.workspaces.all().map((row) => ({ + id: row.id, + name: workspaceDisplayName(row), + repositoryName: row.repository_name, + branch: row.branch || null, + state: row.state, + pinned: row.pinned_at != null, + sessionCount: Number(row.session_count), + unreadCount: Number(row.unread_count), + workingCount: Number(row.working_count), + activityAt: row.activity_at || row.updated_at, + })); + } + + listSessions(workspaceId) { + return this.#statements.sessions.all(workspaceId).map((row) => ({ + id: row.id, + workspaceId: row.workspace_id, + title: row.title || 'Untitled chat', + agentType: row.agent_type || 'unknown', + model: row.model || null, + status: row.status || 'unknown', + unreadCount: Number(row.unread_count || 0), + queuedCount: Number(row.queued_count || 0), + queuePaused: row.queue_paused_at != null, + contextUsedPercent: + typeof row.context_used_percent === 'number' ? row.context_used_percent : null, + activityAt: row.last_user_message_at || row.updated_at || row.created_at, + })); + } + + listRecentSessions(limit = 50) { + const safeLimit = Math.max(1, Math.min(Number(limit) || 50, 200)); + return this.#statements.recentSessions.all(safeLimit).map((row) => ({ + id: row.id, + workspaceId: row.workspace_id, + workspaceName: workspaceDisplayName(row), + title: row.title || 'Untitled chat', + agentType: row.agent_type || 'unknown', + model: row.model || null, + status: row.status || 'unknown', + unreadCount: Number(row.unread_count || 0), + activityAt: row.last_user_message_at || row.updated_at || row.created_at, + })); + } + + getSessionRoute(sessionId) { + const row = this.#statements.sessionRoute.get(sessionId); + if (!row) return null; + return { + id: row.id, + workspaceId: row.workspace_id, + workspaceName: workspaceDisplayName(row), + title: row.title || 'Untitled chat', + titleOrdinal: Number(row.title_ordinal), + status: row.status || 'unknown', + agentType: row.agent_type || 'unknown', + model: row.model || null, + }; + } + + listMessages(sessionId, { after = 0, limit = 500 } = {}) { + if (!this.getSessionRoute(sessionId)) return null; + const safeLimit = Math.max(1, Math.min(Number(limit) || 500, 1000)); + const safeAfter = Math.max(0, Number(after) || 0); + let rows; + if (safeAfter > 0) { + rows = this.#statements.messagesAfter.all(sessionId, safeAfter, safeLimit); + } else { + rows = this.#statements.latestMessages.all(sessionId, safeLimit).reverse(); + } + const messages = rows.flatMap(parseAssistantRow); + const cursor = + rows.length > 0 + ? Number(rows.at(-1).row_id) + : Number(this.#statements.maxRowId.get(sessionId).row_id); + return { cursor, messages }; + } +} + +export class DatabaseWatcher { + #dbPath; + #listeners = new Set(); + #watcher; + #pollTimer; + #debounceTimer; + #lastSignature = ''; + + constructor(dbPath) { + this.#dbPath = dbPath; + } + + subscribe(listener) { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + start() { + if (this.#watcher || this.#pollTimer) return; + const directory = path.dirname(this.#dbPath); + const relevantNames = new Set([ + path.basename(this.#dbPath), + `${path.basename(this.#dbPath)}-wal`, + `${path.basename(this.#dbPath)}-shm`, + ]); + try { + this.#watcher = fs.watch(directory, { persistent: false }, (_event, filename) => { + if (!filename || relevantNames.has(String(filename))) this.#schedule(); + }); + this.#watcher.on('error', () => { + this.#watcher?.close(); + this.#watcher = undefined; + }); + } catch { + this.#watcher = undefined; + } + this.#pollTimer = setInterval(() => this.#poll(), DB_POLL_MS); + this.#pollTimer.unref(); + this.#poll(); + } + + stop() { + this.#watcher?.close(); + this.#watcher = undefined; + if (this.#pollTimer) clearInterval(this.#pollTimer); + if (this.#debounceTimer) clearTimeout(this.#debounceTimer); + this.#pollTimer = undefined; + this.#debounceTimer = undefined; + } + + #schedule() { + if (this.#debounceTimer) clearTimeout(this.#debounceTimer); + this.#debounceTimer = setTimeout(() => { + this.#debounceTimer = undefined; + this.#emit(); + }, 60); + } + + #poll() { + const files = [this.#dbPath, `${this.#dbPath}-wal`]; + const signature = files + .map((file) => { + try { + const stat = fs.statSync(file); + return `${file}:${stat.mtimeMs}:${stat.size}`; + } catch { + return `${file}:missing`; + } + }) + .join('|'); + if (this.#lastSignature && signature !== this.#lastSignature) this.#schedule(); + this.#lastSignature = signature; + } + + #emit() { + const event = { type: 'database-change', at: new Date().toISOString() }; + for (const listener of this.#listeners) { + try { + listener(event); + } catch { + // A failed client must not interrupt updates to the other clients. + } + } + } +} diff --git a/tools/conductor-pocket/src/conductor-send.applescript b/tools/conductor-pocket/src/conductor-send.applescript new file mode 100644 index 00000000..cb355ae9 --- /dev/null +++ b/tools/conductor-pocket/src/conductor-send.applescript @@ -0,0 +1,288 @@ +on workspaceMatches(workspaceName, candidateName) + if candidateName is workspaceName then return true + if candidateName starts with (workspaceName & " +") then return true + return false +end workspaceMatches + +on draftConflict(existingDraft) + set encodedDraft to do shell script "/usr/bin/printf %s " & quoted form of existingDraft & " | /usr/bin/base64 | /usr/bin/tr -d '\\n'" + return "{\"ok\":false,\"code\":\"draft_conflict\",\"draftBase64\":\"" & encodedDraft & "\"}" +end draftConflict + +on getWebArea() + tell application "System Events" + tell process "Conductor" + try + return UI element 1 of scroll area 1 of group 1 of group 1 of front window + end try + end tell + end tell + return missing value +end getWebArea + +on getSidebarGroup() + set webArea to getWebArea() + if webArea is missing value then return missing value + tell application "System Events" + try + set rootElements to UI elements of webArea + if (count of rootElements) is greater than or equal to 2 then return item 2 of rootElements + end try + end tell + return missing value +end getSidebarGroup + +on getMainGroup() + set webArea to getWebArea() + if webArea is missing value then return missing value + tell application "System Events" + try + set rootElements to UI elements of webArea + if (count of rootElements) is greater than or equal to 3 then return item 3 of rootElements + end try + end tell + return missing value +end getMainGroup + +on getWorkspaceContainer() + set sidebarGroup to getSidebarGroup() + if sidebarGroup is missing value then return missing value + tell application "System Events" + set sidebarElements to UI elements of sidebarGroup + repeat with candidate in sidebarElements + try + set candidateChildren to UI elements of candidate + repeat with childElement in candidateChildren + try + if (role of childElement as text) is "AXLink" then return candidate + end try + end repeat + end try + end repeat + end tell + return missing value +end getWorkspaceContainer + +on getSessionTabs() + set mainGroup to getMainGroup() + if mainGroup is missing value then return {} + tell application "System Events" + try + set mainElements to UI elements of mainGroup + repeat with candidate in mainElements + try + if (role of candidate as text) is "AXTabGroup" then + set tabGroupChildren to UI elements of candidate + if (count of tabGroupChildren) is greater than 0 then return UI elements of item 1 of tabGroupChildren + end if + end try + end repeat + end try + end tell + return {} +end getSessionTabs + +on getComposerGroup() + set mainGroup to getMainGroup() + if mainGroup is missing value then return missing value + tell application "System Events" + set mainElements to UI elements of mainGroup + repeat with candidate in mainElements + try + if (description of candidate as text) is "composer" then return candidate + end try + end repeat + end tell + return missing value +end getComposerGroup + +on getTextArea() + set composerGroup to getComposerGroup() + if composerGroup is missing value then return missing value + tell application "System Events" + try + set composerElements to UI elements of composerGroup + repeat with candidate in composerElements + try + if (role of candidate as text) is "AXTextArea" then return candidate + end try + end repeat + end try + end tell + return missing value +end getTextArea + +on commitTextAreaValue(textArea, requestedValue) + tell application "System Events" + tell process "Conductor" to set frontmost to true + set focused of textArea to true + set value of attribute "AXValue" of textArea to requestedValue + -- Conductor's composer is controlled by React. Toggling one real key + -- forces its state to adopt the accessibility value without using the + -- clipboard or leaving an extra character in the message. + keystroke "x" + key code 51 + end tell + delay 0.1 + considering case + return (value of textArea as text) is requestedValue + end considering +end commitTextAreaValue + +on clearOwnedDraft(expectedMessage) + try + set textArea to getTextArea() + if textArea is not missing value then + tell application "System Events" + set currentValue to value of textArea as text + end tell + considering case + if currentValue is expectedMessage then my commitTextAreaValue(textArea, "") + end considering + end if + end try +end clearOwnedDraft + +set operationMode to system attribute "POCKET_OPERATION" + +tell application "System Events" + if UI elements enabled is false then return "{\"ok\":false,\"code\":\"accessibility_disabled\"}" + if not (exists process "Conductor") then return "{\"ok\":false,\"code\":\"conductor_not_running\"}" + tell process "Conductor" + if not (exists front window) then return "{\"ok\":false,\"code\":\"conductor_window_unavailable\"}" + end tell +end tell + +if operationMode is "doctor" then + set textArea to getTextArea() + if textArea is missing value then + return "{\"ok\":false,\"code\":\"composer_unavailable\"}" + end if + return "{\"ok\":true,\"code\":\"ready\"}" +end if + +set workspaceName to system attribute "POCKET_WORKSPACE_NAME" +set sessionTitle to system attribute "POCKET_SESSION_TITLE" +set sessionOrdinal to (system attribute "POCKET_SESSION_ORDINAL") as integer +set messageText to system attribute "POCKET_MESSAGE" +set replaceDraft to (system attribute "POCKET_REPLACE_DRAFT") is "true" +set expectedDraft to system attribute "POCKET_EXPECTED_DRAFT" + +set workspaceContainer to getWorkspaceContainer() +if workspaceContainer is missing value then return "{\"ok\":false,\"code\":\"workspace_list_unavailable\"}" +set workspaceLink to missing value +tell application "System Events" + set workspaceElements to UI elements of workspaceContainer + repeat with candidate in workspaceElements + try + if (role of candidate as text) is "AXLink" then + set candidateName to name of candidate as text + if my workspaceMatches(workspaceName, candidateName) then + set workspaceLink to candidate + exit repeat + end if + end if + end try + end repeat + if workspaceLink is missing value then return "{\"ok\":false,\"code\":\"workspace_not_visible\"}" + perform action "AXPress" of workspaceLink +end tell + +set sessionFound to false +repeat with waitIndex from 1 to 50 + delay 0.1 + tell application "System Events" + set matchedCount to 0 + set sessionTabs to my getSessionTabs() + repeat with candidate in sessionTabs + try + set candidateName to name of candidate as text + if candidateName is ("Close chat " & sessionTitle) then + set matchedCount to matchedCount + 1 + if matchedCount is sessionOrdinal then + if (value of candidate as boolean) is false then perform action "AXPress" of candidate + set sessionFound to true + exit repeat + end if + end if + end try + end repeat + end tell + if sessionFound then exit repeat +end repeat + +if sessionFound is false then return "{\"ok\":false,\"code\":\"session_not_visible\"}" + +set textArea to missing value +repeat with waitIndex from 1 to 50 + set textArea to getTextArea() + if textArea is not missing value then exit repeat + delay 0.1 +end repeat +if textArea is missing value then return "{\"ok\":false,\"code\":\"composer_unavailable\"}" + +tell application "System Events" + set existingDraft to value of textArea as text + considering case + if existingDraft is not messageText then + if replaceDraft is false and existingDraft is not "" then return my draftConflict(existingDraft) + if replaceDraft is true and existingDraft is not expectedDraft then return my draftConflict(existingDraft) + end if + end considering +end tell + +if my commitTextAreaValue(textArea, messageText) is false then + clearOwnedDraft(messageText) + return "{\"ok\":false,\"code\":\"composer_update_failed\"}" +end if + +set sendButton to missing value +repeat with waitIndex from 1 to 40 + tell application "System Events" + set composerGroup to my getComposerGroup() + if composerGroup is not missing value then + set bestX to -1 + set composerElements to UI elements of composerGroup + repeat with candidate in composerElements + try + if (role of candidate as text) is "AXButton" and (enabled of candidate as boolean) is true then + set buttonPosition to position of candidate + set buttonX to item 1 of buttonPosition + if buttonX is greater than bestX then + set bestX to buttonX + set sendButton to candidate + end if + end if + end try + end repeat + end if + end tell + if sendButton is not missing value then exit repeat + delay 0.1 +end repeat + +if sendButton is missing value then + clearOwnedDraft(messageText) + return "{\"ok\":false,\"code\":\"send_unavailable\"}" +end if + +try + tell application "System Events" to perform action "AXPress" of sendButton +on error + clearOwnedDraft(messageText) + return "{\"ok\":false,\"code\":\"send_failed\"}" +end try + +repeat with waitIndex from 1 to 40 + delay 0.1 + set currentTextArea to getTextArea() + if currentTextArea is not missing value then + tell application "System Events" + if (value of currentTextArea as text) is "" then + return "{\"ok\":true,\"code\":\"sent\"}" + end if + end tell + end if +end repeat + +return "{\"ok\":false,\"code\":\"send_not_confirmed\"}" diff --git a/tools/conductor-pocket/src/config.mjs b/tools/conductor-pocket/src/config.mjs new file mode 100644 index 00000000..b3a72f3e --- /dev/null +++ b/tools/conductor-pocket/src/config.mjs @@ -0,0 +1,168 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomToken, sha256 } from './encoding.mjs'; +import { + APP_NAME, + DEFAULT_CONFIG_PATH, + DEFAULT_DB_PATH, + DEFAULT_PORT, + LOOPBACK_HOST, + PAIRING_TTL_MS, +} from './constants.mjs'; + +function assertObject(value, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } +} + +function parsePublicOrigin(value, allowInsecureLocalhost = false) { + const url = new URL(value); + const isLocal = + url.hostname === '127.0.0.1' || + url.hostname === 'localhost' || + url.hostname === '[::1]'; + if (url.protocol !== 'https:' && !(allowInsecureLocalhost && isLocal && url.protocol === 'http:')) { + throw new Error('publicOrigin must use HTTPS'); + } + if (url.pathname !== '/' || url.search || url.hash) { + throw new Error('publicOrigin must not include a path, query, or fragment'); + } + return url.origin; +} + +export function validateConfig(raw) { + assertObject(raw, 'config'); + if (raw.version !== 1) throw new Error('Unsupported config version'); + if (raw.bindHost !== LOOPBACK_HOST) { + throw new Error(`bindHost must be ${LOOPBACK_HOST}; LAN/public binding is forbidden`); + } + if (!Number.isInteger(raw.port) || raw.port < 1024 || raw.port > 65535) { + throw new Error('port must be an integer from 1024 through 65535'); + } + const publicOrigin = parsePublicOrigin(raw.publicOrigin, raw.developmentMode === true); + const originHostname = new URL(publicOrigin).hostname; + if (raw.rpId !== originHostname) { + throw new Error('rpId must exactly match the publicOrigin hostname'); + } + if (typeof raw.csrfSecret !== 'string' || raw.csrfSecret.length < 32) { + throw new Error('csrfSecret is missing or too short'); + } + if (typeof raw.dbPath !== 'string' || !path.isAbsolute(raw.dbPath)) { + throw new Error('dbPath must be absolute'); + } + if (!Array.isArray(raw.devices)) throw new Error('devices must be an array'); + if (typeof raw.requireTailscaleIdentity !== 'boolean') { + throw new Error('requireTailscaleIdentity must be boolean'); + } + return { + ...raw, + publicOrigin, + devices: raw.devices, + allowedTailscaleLogin: raw.allowedTailscaleLogin || null, + pairing: raw.pairing || null, + }; +} + +export async function loadConfig(configPath = DEFAULT_CONFIG_PATH) { + const raw = JSON.parse(await fs.readFile(configPath, 'utf8')); + return validateConfig(raw); +} + +export async function saveConfig(configPath, config) { + const validated = validateConfig(config); + const directory = path.dirname(configPath); + await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + await fs.chmod(directory, 0o700); + const temporaryPath = path.join( + directory, + `.config.${process.pid}.${randomToken(8)}.tmp`, + ); + await fs.writeFile(temporaryPath, `${JSON.stringify(validated, null, 2)}\n`, { + mode: 0o600, + flag: 'wx', + }); + await fs.rename(temporaryPath, configPath); + await fs.chmod(configPath, 0o600); +} + +export function createConfig({ + publicOrigin, + dbPath = DEFAULT_DB_PATH, + port = DEFAULT_PORT, + developmentMode = false, + requireTailscaleIdentity = !developmentMode, + now = Date.now(), +} = {}) { + const origin = parsePublicOrigin(publicOrigin, developmentMode); + const pairingCode = randomToken(24); + const config = validateConfig({ + version: 1, + appName: APP_NAME, + bindHost: LOOPBACK_HOST, + port, + publicOrigin: origin, + rpId: new URL(origin).hostname, + dbPath, + developmentMode, + requireTailscaleIdentity, + allowedTailscaleLogin: null, + csrfSecret: randomToken(32), + pairing: { + codeHash: sha256(pairingCode), + expiresAt: new Date(now + PAIRING_TTL_MS).toISOString(), + }, + devices: [], + }); + return { config, pairingCode }; +} + +export function rotatePairing(config, now = Date.now()) { + const pairingCode = randomToken(24); + return { + pairingCode, + config: validateConfig({ + ...config, + pairing: { + codeHash: sha256(pairingCode), + expiresAt: new Date(now + PAIRING_TTL_MS).toISOString(), + }, + }), + }; +} + +export function getVerificationCode(config) { + if (!config.pairing) return null; + return config.pairing.codeHash + .replace(/[^A-Za-z0-9]/g, '') + .slice(0, 6) + .toUpperCase(); +} + +export class ConfigStore { + #config; + #configPath; + #writeQueue = Promise.resolve(); + + constructor(configPath, config) { + this.#configPath = configPath; + this.#config = validateConfig(config); + } + + get value() { + return this.#config; + } + + async update(mutator) { + const run = async () => { + const draft = structuredClone(this.#config); + const next = (await mutator(draft)) || draft; + const validated = validateConfig(next); + await saveConfig(this.#configPath, validated); + this.#config = validated; + return this.#config; + }; + this.#writeQueue = this.#writeQueue.then(run, run); + return this.#writeQueue; + } +} diff --git a/tools/conductor-pocket/src/constants.mjs b/tools/conductor-pocket/src/constants.mjs new file mode 100644 index 00000000..9452c6d4 --- /dev/null +++ b/tools/conductor-pocket/src/constants.mjs @@ -0,0 +1,28 @@ +import path from 'node:path'; +import os from 'node:os'; + +export const APP_NAME = 'Conductor Pocket'; +export const APP_VERSION = '0.1.0'; +export const DEFAULT_PORT = 4317; +export const LOOPBACK_HOST = '127.0.0.1'; +export const MAX_MESSAGE_BYTES = 16 * 1024; +export const MAX_JSON_BODY_BYTES = 256 * 1024; +export const SESSION_COOKIE = '__Host-cp_session'; +export const PAIR_COOKIE = '__Host-cp_pair'; +export const DEFAULT_DATA_DIR = path.join(os.homedir(), '.config', 'conductor-pocket'); +export const DEFAULT_CONFIG_PATH = path.join(DEFAULT_DATA_DIR, 'config.json'); +export const DEFAULT_DB_PATH = path.join( + os.homedir(), + 'Library', + 'Application Support', + 'com.conductor.app', + 'conductor.db', +); +export const CONDUCTOR_APP_PATH = '/Applications/Conductor.app'; +export const PAIRING_TTL_MS = 15 * 60 * 1000; +export const PAIR_SESSION_TTL_MS = 5 * 60 * 1000; +export const UNLOCK_TTL_MS = 60 * 60 * 1000; +export const UNLOCK_IDLE_TTL_MS = 5 * 60 * 1000; +export const DEVICE_SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; +export const SSE_HEARTBEAT_MS = 5 * 1000; +export const DB_POLL_MS = 2 * 1000; diff --git a/tools/conductor-pocket/src/encoding.mjs b/tools/conductor-pocket/src/encoding.mjs new file mode 100644 index 00000000..0ff6d319 --- /dev/null +++ b/tools/conductor-pocket/src/encoding.mjs @@ -0,0 +1,28 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; + +export function randomToken(bytes = 32) { + return randomBytes(bytes).toString('base64url'); +} +export function sha256(value) { + return createHash('sha256').update(value).digest('base64url'); +} + +export function safeEqual(left, right) { + const leftBuffer = Buffer.from(String(left)); + const rightBuffer = Buffer.from(String(right)); + if (leftBuffer.length !== rightBuffer.length) return false; + return timingSafeEqual(leftBuffer, rightBuffer); +} + +export function toBase64Url(value) { + return Buffer.from(value).toString('base64url'); +} + +export function fromBase64Url(value) { + return new Uint8Array(Buffer.from(value, 'base64url')); +} + +export function normalizeText(value) { + if (typeof value !== 'string') return ''; + return value.replace(/\r\n?/g, '\n'); +} diff --git a/tools/conductor-pocket/src/errors.mjs b/tools/conductor-pocket/src/errors.mjs new file mode 100644 index 00000000..d569da0b --- /dev/null +++ b/tools/conductor-pocket/src/errors.mjs @@ -0,0 +1,12 @@ +export class HttpError extends Error { + constructor(status, code, message = code) { + super(message); + this.name = 'HttpError'; + this.status = status; + this.code = code; + } +} +export function asHttpError(error) { + if (error instanceof HttpError) return error; + return new HttpError(500, 'internal_error', 'An internal error occurred'); +} diff --git a/tools/conductor-pocket/src/security.mjs b/tools/conductor-pocket/src/security.mjs new file mode 100644 index 00000000..487b5b80 --- /dev/null +++ b/tools/conductor-pocket/src/security.mjs @@ -0,0 +1,476 @@ +import { createHmac, randomUUID } from 'node:crypto'; +import { + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, +} from '@simplewebauthn/server'; +import { + DEVICE_SESSION_TTL_SECONDS, + PAIR_COOKIE, + PAIR_SESSION_TTL_MS, + SESSION_COOKIE, + UNLOCK_IDLE_TTL_MS, + UNLOCK_TTL_MS, +} from './constants.mjs'; +import { fromBase64Url, randomToken, safeEqual, sha256, toBase64Url } from './encoding.mjs'; +import { HttpError } from './errors.mjs'; + +const TEXT_ENCODER = new TextEncoder(); +const PAIR_ATTEMPT_WINDOW_MS = 60 * 1000; +const PAIR_ATTEMPT_LIMIT = 5; +const CHALLENGE_TTL_MS = 5 * 60 * 1000; + +function parseCookies(header = '') { + const cookies = new Map(); + for (const pair of header.split(';')) { + const separator = pair.indexOf('='); + if (separator < 1) continue; + const key = pair.slice(0, separator).trim(); + const value = pair.slice(separator + 1).trim(); + if (key) cookies.set(key, value); + } + return cookies; +} + +function sessionCookie(name, value, { maxAge, clear = false } = {}) { + const parts = [ + `${name}=${clear ? '' : value}`, + 'Path=/', + 'HttpOnly', + 'Secure', + 'SameSite=Strict', + `Max-Age=${clear ? 0 : maxAge}`, + 'Priority=High', + ]; + return parts.join('; '); +} + +function normalizeDeviceName(value) { + if (typeof value !== 'string') return 'iPhone'; + const name = value.trim().replace(/\s+/g, ' '); + return name.slice(0, 80) || 'iPhone'; +} + +function tailscaleLogin(request) { + const value = request.headers['tailscale-user-login']; + return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null; +} + +function getRequestKey(request) { + return tailscaleLogin(request) || request.socket?.remoteAddress || 'unknown'; +} + +export function createUnlockWindow(now) { + return { + absoluteUntil: now + UNLOCK_TTL_MS, + idleUntil: now + UNLOCK_IDLE_TTL_MS, + }; +} + +export function evaluateUnlockWindow(window, now, { touch = false } = {}) { + if ( + !window || + !Number.isFinite(window.absoluteUntil) || + !Number.isFinite(window.idleUntil) || + window.absoluteUntil <= now || + window.idleUntil <= now + ) { + return { unlocked: false, unlockedUntil: 0 }; + } + if (touch) { + window.idleUntil = Math.min( + window.absoluteUntil, + now + UNLOCK_IDLE_TTL_MS, + ); + } + return { + unlocked: true, + unlockedUntil: Math.min(window.absoluteUntil, window.idleUntil), + }; +} + +export class SecurityManager { + #store; + #now; + #pendingPairs = new Map(); + #challenges = new Map(); + #unlocks = new Map(); + #pairAttempts = new Map(); + + constructor(configStore, { now = () => Date.now() } = {}) { + this.#store = configStore; + this.#now = now; + } + + get config() { + return this.#store.value; + } + + assertTailscaleIdentity(request, { capture = false } = {}) { + const config = this.config; + if (!config.requireTailscaleIdentity) return null; + const login = tailscaleLogin(request); + if (!login) { + throw new HttpError(403, 'tailscale_identity_required'); + } + if ( + config.allowedTailscaleLogin && + !safeEqual(login, config.allowedTailscaleLogin.toLowerCase()) + ) { + throw new HttpError(403, 'tailscale_identity_denied'); + } + if (!capture && !config.allowedTailscaleLogin) { + throw new HttpError(403, 'tailscale_identity_unpaired'); + } + return login; + } + + assertOrigin(request) { + const origin = request.headers.origin; + if (typeof origin !== 'string' || !safeEqual(origin, this.config.publicOrigin)) { + throw new HttpError(403, 'origin_denied'); + } + } + + async startPairing(request, { code, deviceName }) { + this.assertOrigin(request); + const login = this.assertTailscaleIdentity(request, { capture: true }); + this.#rateLimitPairing(getRequestKey(request)); + const pairing = this.config.pairing; + if (!pairing) throw new HttpError(410, 'pairing_unavailable'); + const expiresAt = Date.parse(pairing.expiresAt); + if (!Number.isFinite(expiresAt) || expiresAt <= this.#now()) { + throw new HttpError(410, 'pairing_expired'); + } + if (typeof code !== 'string' || !safeEqual(sha256(code), pairing.codeHash)) { + throw new HttpError(401, 'pairing_code_invalid'); + } + + const deviceId = randomUUID(); + const pairToken = randomToken(32); + const options = await generateRegistrationOptions({ + rpName: this.config.appName, + rpID: this.config.rpId, + userID: TEXT_ENCODER.encode(deviceId), + userName: normalizeDeviceName(deviceName), + userDisplayName: normalizeDeviceName(deviceName), + attestationType: 'none', + authenticatorSelection: { + authenticatorAttachment: 'platform', + residentKey: 'required', + userVerification: 'required', + }, + supportedAlgorithmIDs: [-7, -257], + timeout: 60_000, + }); + + this.#pendingPairs.set(sha256(pairToken), { + deviceId, + deviceName: normalizeDeviceName(deviceName), + tailscaleLogin: login, + challenge: options.challenge, + pairingHash: pairing.codeHash, + expiresAt: this.#now() + PAIR_SESSION_TTL_MS, + }); + return { + options, + setCookie: sessionCookie(PAIR_COOKIE, pairToken, { + maxAge: Math.floor(PAIR_SESSION_TTL_MS / 1000), + }), + }; + } + + async finishPairing(request, response) { + this.assertOrigin(request); + const login = this.assertTailscaleIdentity(request, { capture: true }); + const cookies = parseCookies(request.headers.cookie); + const pairToken = cookies.get(PAIR_COOKIE); + const pendingKey = pairToken ? sha256(pairToken) : null; + const pending = pendingKey ? this.#pendingPairs.get(pendingKey) : null; + if (!pending || pending.expiresAt <= this.#now()) { + if (pendingKey) this.#pendingPairs.delete(pendingKey); + throw new HttpError(401, 'pairing_session_expired'); + } + if (pending.tailscaleLogin !== login) { + throw new HttpError(403, 'pairing_identity_changed'); + } + if ( + !this.config.pairing || + !safeEqual(this.config.pairing.codeHash, pending.pairingHash) + ) { + throw new HttpError(410, 'pairing_rotated'); + } + + let verification; + try { + verification = await verifyRegistrationResponse({ + response, + expectedChallenge: pending.challenge, + expectedOrigin: this.config.publicOrigin, + expectedRPID: this.config.rpId, + requireUserVerification: true, + }); + } catch { + throw new HttpError(401, 'passkey_registration_failed'); + } + if (!verification.verified || !verification.registrationInfo) { + throw new HttpError(401, 'passkey_registration_failed'); + } + + const { credential, credentialBackedUp, credentialDeviceType } = + verification.registrationInfo; + const rawSessionToken = randomToken(32); + const sessionHash = sha256(rawSessionToken); + const now = new Date(this.#now()).toISOString(); + const device = { + id: pending.deviceId, + name: pending.deviceName, + tailscaleLogin: login, + createdAt: now, + lastSeenAt: now, + sessionHash, + passkey: { + id: credential.id, + publicKey: toBase64Url(credential.publicKey), + counter: credential.counter, + transports: response?.response?.transports || credential.transports || [], + deviceType: credentialDeviceType, + backedUp: credentialBackedUp, + }, + }; + + await this.#store.update((config) => { + if ( + !config.pairing || + !safeEqual(config.pairing.codeHash, pending.pairingHash) + ) { + throw new HttpError(410, 'pairing_rotated'); + } + if ( + config.allowedTailscaleLogin && + config.allowedTailscaleLogin.toLowerCase() !== login + ) { + throw new HttpError(403, 'tailscale_identity_denied'); + } + config.allowedTailscaleLogin = config.allowedTailscaleLogin || login; + config.pairing = null; + config.devices.push(device); + return config; + }); + + this.#pendingPairs.delete(pendingKey); + const unlock = createUnlockWindow(this.#now()); + this.#unlocks.set(sessionHash, unlock); + return { + device: this.#publicDevice(device), + csrfToken: this.#csrfToken(device), + setCookies: [ + sessionCookie(SESSION_COOKIE, rawSessionToken, { + maxAge: DEVICE_SESSION_TTL_SECONDS, + }), + sessionCookie(PAIR_COOKIE, '', { maxAge: 0, clear: true }), + ], + }; + } + + session( + request, + { requireUnlocked = false, requireCsrf = false, touch = true } = {}, + ) { + this.assertTailscaleIdentity(request); + const cookies = parseCookies(request.headers.cookie); + const token = cookies.get(SESSION_COOKIE); + if (!token) throw new HttpError(401, 'authentication_required'); + const sessionHash = sha256(token); + const device = this.config.devices.find((candidate) => + safeEqual(candidate.sessionHash, sessionHash), + ); + if (!device) throw new HttpError(401, 'device_revoked'); + const requestLogin = tailscaleLogin(request); + if ( + this.config.requireTailscaleIdentity && + (!requestLogin || device.tailscaleLogin !== requestLogin) + ) { + throw new HttpError(403, 'device_identity_mismatch'); + } + const csrfToken = this.#csrfToken(device); + if ( + requireCsrf && + (typeof request.headers['x-csrf-token'] !== 'string' || + !safeEqual(request.headers['x-csrf-token'], csrfToken)) + ) { + throw new HttpError(403, 'csrf_denied'); + } + const unlockWindow = this.#unlocks.get(device.sessionHash); + const unlock = evaluateUnlockWindow(unlockWindow, this.#now(), { touch }); + if (!unlock.unlocked && unlockWindow) { + this.#unlocks.delete(device.sessionHash); + } + if (requireUnlocked && !unlock.unlocked) { + throw new HttpError(423, 'device_locked'); + } + return { + device, + csrfToken, + unlocked: unlock.unlocked, + unlockedUntil: unlock.unlocked + ? new Date(unlock.unlockedUntil).toISOString() + : null, + }; + } + + bootstrap(request) { + const session = this.session(request); + return { + authenticated: true, + unlocked: session.unlocked, + unlockedUntil: session.unlockedUntil, + csrfToken: session.csrfToken, + device: this.#publicDevice(session.device), + }; + } + + async authenticationOptions(request) { + this.assertOrigin(request); + const { device } = this.session(request, { requireCsrf: true }); + const options = await generateAuthenticationOptions({ + rpID: this.config.rpId, + allowCredentials: [ + { + id: device.passkey.id, + transports: device.passkey.transports || [], + }, + ], + userVerification: 'required', + timeout: 60_000, + }); + this.#challenges.set(device.sessionHash, { + challenge: options.challenge, + expiresAt: this.#now() + CHALLENGE_TTL_MS, + }); + return options; + } + + async verifyAuthentication(request, response) { + this.assertOrigin(request); + const { device, csrfToken } = this.session(request, { requireCsrf: true }); + const pending = this.#challenges.get(device.sessionHash); + if (!pending || pending.expiresAt <= this.#now()) { + this.#challenges.delete(device.sessionHash); + throw new HttpError(401, 'authentication_challenge_expired'); + } + let verification; + try { + verification = await verifyAuthenticationResponse({ + response, + expectedChallenge: pending.challenge, + expectedOrigin: this.config.publicOrigin, + expectedRPID: this.config.rpId, + credential: { + id: device.passkey.id, + publicKey: fromBase64Url(device.passkey.publicKey), + counter: device.passkey.counter, + transports: device.passkey.transports || [], + }, + requireUserVerification: true, + }); + } catch { + throw new HttpError(401, 'passkey_authentication_failed'); + } + if (!verification.verified) { + throw new HttpError(401, 'passkey_authentication_failed'); + } + this.#challenges.delete(device.sessionHash); + const unlock = createUnlockWindow(this.#now()); + this.#unlocks.set(device.sessionHash, unlock); + await this.#store.update((config) => { + const stored = config.devices.find((candidate) => candidate.id === device.id); + if (!stored) throw new HttpError(401, 'device_revoked'); + stored.passkey.counter = verification.authenticationInfo.newCounter; + stored.lastSeenAt = new Date(this.#now()).toISOString(); + return config; + }); + return { + unlocked: true, + unlockedUntil: new Date(unlock.idleUntil).toISOString(), + csrfToken, + }; + } + + touch(request) { + this.assertOrigin(request); + const session = this.session(request, { + requireUnlocked: true, + requireCsrf: true, + touch: true, + }); + return { + unlocked: true, + unlockedUntil: session.unlockedUntil, + }; + } + + lock(request) { + this.assertOrigin(request); + const { device } = this.session(request, { requireCsrf: true }); + this.#unlocks.delete(device.sessionHash); + return { locked: true }; + } + + listDevices(request) { + this.session(request, { requireUnlocked: true }); + return this.config.devices.map((device) => this.#publicDevice(device)); + } + + async revokeDevice(request, deviceId) { + this.assertOrigin(request); + const current = this.session(request, { + requireUnlocked: true, + requireCsrf: true, + }); + const target = this.config.devices.find((device) => device.id === deviceId); + if (!target) throw new HttpError(404, 'device_not_found'); + await this.#store.update((config) => { + config.devices = config.devices.filter((device) => device.id !== deviceId); + return config; + }); + this.#unlocks.delete(target.sessionHash); + return { + revoked: true, + currentDevice: current.device.id === deviceId, + setCookie: + current.device.id === deviceId + ? sessionCookie(SESSION_COOKIE, '', { maxAge: 0, clear: true }) + : null, + }; + } + + #publicDevice(device) { + return { + id: device.id, + name: device.name, + createdAt: device.createdAt, + lastSeenAt: device.lastSeenAt, + tailscaleLogin: device.tailscaleLogin, + passkeyBackedUp: Boolean(device.passkey?.backedUp), + }; + } + + #csrfToken(device) { + return createHmac('sha256', this.config.csrfSecret) + .update(`csrf:${device.id}:${device.sessionHash}`) + .digest('base64url'); + } + + #rateLimitPairing(key) { + const now = this.#now(); + const prior = this.#pairAttempts.get(key) || []; + const current = prior.filter((timestamp) => timestamp > now - PAIR_ATTEMPT_WINDOW_MS); + if (current.length >= PAIR_ATTEMPT_LIMIT) { + throw new HttpError(429, 'pairing_rate_limited'); + } + current.push(now); + this.#pairAttempts.set(key, current); + } +} diff --git a/tools/conductor-pocket/src/server.mjs b/tools/conductor-pocket/src/server.mjs new file mode 100644 index 00000000..b0cddadf --- /dev/null +++ b/tools/conductor-pocket/src/server.mjs @@ -0,0 +1,548 @@ +import fs from 'node:fs/promises'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + APP_NAME, + APP_VERSION, + MAX_JSON_BODY_BYTES, + SSE_HEARTBEAT_MS, +} from './constants.mjs'; +import { getVerificationCode } from './config.mjs'; +import { HttpError, asHttpError } from './errors.mjs'; + +const publicDirectory = fileURLToPath(new URL('../public/', import.meta.url)); + +const staticFiles = new Map([ + ['/', ['index.html', 'text/html; charset=utf-8']], + ['/index.html', ['index.html', 'text/html; charset=utf-8']], + ['/app.css', ['app.css', 'text/css; charset=utf-8']], + ['/app.js', ['app.js', 'text/javascript; charset=utf-8']], + ['/icon.svg', ['icon.svg', 'image/svg+xml']], + ['/manifest.webmanifest', ['manifest.webmanifest', 'application/manifest+json']], + ['/service-worker.js', ['service-worker.js', 'text/javascript; charset=utf-8']], +]); + +const errorStatuses = new Map([ + ['draft_conflict', 409], + ['draft_recheck_required', 409], + ['message_empty', 400], + ['message_invalid', 400], + ['message_too_large', 413], + ['workspace_list_unavailable', 503], + ['workspace_not_visible', 503], + ['session_not_visible', 503], + ['composer_unavailable', 503], + ['composer_update_failed', 502], + ['conductor_not_running', 503], + ['conductor_window_unavailable', 503], + ['accessibility_disabled', 503], + ['send_unavailable', 503], + ['send_failed', 502], + ['send_not_confirmed', 502], + ['automation_timeout', 504], + ['automation_failed', 502], + ['automation_invalid_response', 502], +]); + +function securityHeaders(config, { api = false } = {}) { + const contentSecurityPolicy = [ + "default-src 'self'", + "base-uri 'none'", + "connect-src 'self'", + "font-src 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "img-src 'self' data:", + "object-src 'none'", + "script-src 'self'", + "style-src 'self'", + ]; + if (config.publicOrigin.startsWith('https://')) { + contentSecurityPolicy.push('upgrade-insecure-requests'); + } + const headers = { + 'Content-Security-Policy': contentSecurityPolicy.join('; '), + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Resource-Policy': 'same-origin', + 'Permissions-Policy': + 'camera=(), geolocation=(), microphone=(), payment=(), usb=(), serial=()', + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + }; + if (config.publicOrigin.startsWith('https://')) { + headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'; + } + if (api) headers['Cache-Control'] = 'no-store, max-age=0'; + return headers; +} + +function sendJson(response, status, value, config, extraHeaders = {}) { + const body = JSON.stringify(value); + response.writeHead(status, { + ...securityHeaders(config, { api: true }), + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + ...extraHeaders, + }); + response.end(body); +} + +async function readJson(request) { + const declaredLength = Number(request.headers['content-length'] || 0); + if (declaredLength > MAX_JSON_BODY_BYTES) { + throw new HttpError(413, 'request_too_large'); + } + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > MAX_JSON_BODY_BYTES) throw new HttpError(413, 'request_too_large'); + chunks.push(chunk); + } + if (chunks.length === 0) return {}; + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + throw new HttpError(400, 'invalid_json'); + } +} + +function idempotencyKey(request) { + const value = request.headers['idempotency-key']; + if ( + typeof value !== 'string' || + value.length < 16 || + value.length > 100 || + !/^[A-Za-z0-9_-]+$/.test(value) + ) { + throw new HttpError(400, 'idempotency_key_required'); + } + return value; +} + +function pathMatch(pathname, expression) { + const match = expression.exec(pathname); + if (!match) return null; + return match.slice(1).map((value) => decodeURIComponent(value)); +} + +class IdempotencyStore { + #entries = new Map(); + + run(key, sessionId, task) { + this.#prune(); + const existing = this.#entries.get(key); + if (existing) { + if (existing.sessionId !== sessionId) { + throw new HttpError(409, 'idempotency_key_reused'); + } + return existing.promise; + } + const promise = Promise.resolve().then(task); + this.#entries.set(key, { + sessionId, + promise, + expiresAt: Date.now() + 60 * 60 * 1000, + }); + return promise; + } + + #prune() { + const now = Date.now(); + for (const [key, entry] of this.#entries) { + if (entry.expiresAt <= now) this.#entries.delete(key); + } + } +} + +class ConnectionProbe { + #transport; + #result; + #expiresAt = 0; + + constructor(transport) { + this.#transport = transport; + } + + async run({ force = false } = {}) { + if (!force && this.#result && this.#expiresAt > Date.now()) return this.#result; + const result = await this.#transport.doctor(); + this.#result = { + checkedAt: new Date().toISOString(), + relayVersion: APP_VERSION, + conductor: result.ok, + sendPath: result.ok, + reason: result.ok ? null : result.code, + capabilities: { + read: true, + send: result.ok, + devices: true, + passkey: true, + stop: false, + newChat: false, + openConductor: false, + readMacDraft: false, + }, + }; + this.#expiresAt = Date.now() + 10_000; + return this.#result; + } +} + +export function createPocketServer({ + configStore, + security, + database, + watcher, + transport, +}) { + const idempotency = new IdempotencyStore(); + const probe = new ConnectionProbe(transport); + const clients = new Set(); + + const unsubscribe = watcher.subscribe((event) => { + const payload = `event: change\ndata: ${JSON.stringify(event)}\n\n`; + for (const client of clients) { + try { + client.write(payload); + } catch { + clients.delete(client); + } + } + }); + + const server = http.createServer(async (request, response) => { + const config = configStore.value; + try { + const requestUrl = new URL(request.url || '/', config.publicOrigin); + assertHost(request, config); + + if (request.method === 'GET' && requestUrl.pathname === '/api/health') { + return sendJson( + response, + 200, + { ok: true, app: APP_NAME, version: APP_VERSION }, + config, + ); + } + + if ( + request.method === 'POST' && + requestUrl.pathname === '/api/pair/start' + ) { + const body = await readJson(request); + const result = await security.startPairing(request, body); + return sendJson( + response, + 200, + { + options: result.options, + verificationCode: getVerificationCode(config), + macName: config.macName || 'Your Mac', + hostname: config.rpId, + }, + config, + { 'Set-Cookie': result.setCookie }, + ); + } + + if ( + request.method === 'POST' && + requestUrl.pathname === '/api/pair/finish' + ) { + const body = await readJson(request); + const result = await security.finishPairing(request, body.response); + return sendJson( + response, + 200, + { + authenticated: true, + unlocked: true, + device: result.device, + csrfToken: result.csrfToken, + }, + config, + { 'Set-Cookie': result.setCookies }, + ); + } + + if ( + request.method === 'GET' && + requestUrl.pathname === '/api/auth/bootstrap' + ) { + const result = security.bootstrap(request); + return sendJson(response, 200, result, config); + } + + if ( + request.method === 'POST' && + requestUrl.pathname === '/api/auth/options' + ) { + const options = await security.authenticationOptions(request); + return sendJson(response, 200, options, config); + } + + if ( + request.method === 'POST' && + requestUrl.pathname === '/api/auth/verify' + ) { + const body = await readJson(request); + const result = await security.verifyAuthentication(request, body.response); + return sendJson(response, 200, result, config); + } + + if (request.method === 'POST' && requestUrl.pathname === '/api/auth/lock') { + const result = security.lock(request); + return sendJson(response, 200, result, config); + } + + if (request.method === 'POST' && requestUrl.pathname === '/api/auth/touch') { + const result = security.touch(request); + return sendJson(response, 200, result, config); + } + + if (request.method === 'GET' && requestUrl.pathname === '/api/events') { + security.session(request, { requireUnlocked: true }); + response.writeHead(200, { + ...securityHeaders(config, { api: true }), + 'Content-Type': 'text/event-stream; charset=utf-8', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + response.write( + `event: ready\ndata: ${JSON.stringify({ + type: 'ready', + at: new Date().toISOString(), + })}\n\n`, + ); + clients.add(response); + const heartbeat = setInterval(() => { + try { + security.session(request, { + requireUnlocked: true, + touch: false, + }); + response.write(`event: heartbeat\ndata: ${Date.now()}\n\n`); + } catch (error) { + response.write( + `event: locked\ndata: ${JSON.stringify({ + code: asHttpError(error).code, + })}\n\n`, + ); + response.end(); + } + }, SSE_HEARTBEAT_MS); + heartbeat.unref(); + request.on('close', () => { + clearInterval(heartbeat); + clients.delete(response); + }); + return; + } + + if ( + request.method === 'GET' && + requestUrl.pathname === '/api/connection' + ) { + security.session(request, { requireUnlocked: true }); + const result = await probe.run({ force: requestUrl.searchParams.has('force') }); + return sendJson(response, 200, result, config); + } + + if ( + request.method === 'GET' && + requestUrl.pathname === '/api/workspaces' + ) { + security.session(request, { requireUnlocked: true }); + return sendJson( + response, + 200, + { workspaces: database.listWorkspaces() }, + config, + ); + } + + if ( + request.method === 'GET' && + requestUrl.pathname === '/api/sessions/recent' + ) { + security.session(request, { requireUnlocked: true }); + return sendJson( + response, + 200, + { + sessions: database.listRecentSessions( + requestUrl.searchParams.get('limit') || 50, + ), + }, + config, + ); + } + + const workspaceSessions = pathMatch( + requestUrl.pathname, + /^\/api\/workspaces\/([^/]+)\/sessions$/, + ); + if (request.method === 'GET' && workspaceSessions) { + security.session(request, { requireUnlocked: true }); + return sendJson( + response, + 200, + { sessions: database.listSessions(workspaceSessions[0]) }, + config, + ); + } + + const sessionMessages = pathMatch( + requestUrl.pathname, + /^\/api\/sessions\/([^/]+)\/messages$/, + ); + if (request.method === 'GET' && sessionMessages) { + security.session(request, { requireUnlocked: true }); + const result = database.listMessages(sessionMessages[0], { + after: requestUrl.searchParams.get('after') || 0, + limit: requestUrl.searchParams.get('limit') || 500, + }); + if (!result) throw new HttpError(404, 'session_not_found'); + return sendJson(response, 200, result, config); + } + + if (request.method === 'POST' && sessionMessages) { + security.assertOrigin(request); + const auth = security.session(request, { + requireUnlocked: true, + requireCsrf: true, + }); + const body = await readJson(request); + const route = database.getSessionRoute(sessionMessages[0]); + if (!route) throw new HttpError(404, 'session_not_found'); + const key = `${auth.device.id}:${idempotencyKey(request)}`; + const result = await idempotency.run(key, route.id, () => + transport.send({ + workspaceName: route.workspaceName, + sessionTitle: route.title, + sessionOrdinal: route.titleOrdinal, + message: body.message, + replaceDraft: body.replaceDraft === true, + expectedMacDraft: body.expectedMacDraft, + }), + ); + if (!result.ok) { + if (result.code === 'draft_conflict') { + const draft = typeof result.draftBase64 === 'string' + ? Buffer.from(result.draftBase64, 'base64').toString('utf8') + : null; + return sendJson( + response, + 409, + { error: { code: result.code, draft } }, + config, + ); + } + throw new HttpError( + errorStatuses.get(result.code) || 502, + result.code, + ); + } + return sendJson( + response, + 200, + { + delivery: 'delivered', + deliveredAt: new Date().toISOString(), + sessionId: route.id, + }, + config, + ); + } + + if (request.method === 'GET' && requestUrl.pathname === '/api/devices') { + const devices = security.listDevices(request); + return sendJson(response, 200, { devices }, config); + } + + const revokeDevice = pathMatch( + requestUrl.pathname, + /^\/api\/devices\/([^/]+)\/revoke$/, + ); + if (request.method === 'POST' && revokeDevice) { + const result = await security.revokeDevice(request, revokeDevice[0]); + return sendJson( + response, + 200, + { revoked: true, currentDevice: result.currentDevice }, + config, + result.setCookie ? { 'Set-Cookie': result.setCookie } : {}, + ); + } + + if ( + (request.method === 'GET' || request.method === 'HEAD') && + staticFiles.has(requestUrl.pathname) + ) { + return serveStatic(response, requestUrl.pathname, config, { + head: request.method === 'HEAD', + }); + } + + throw new HttpError(404, 'not_found'); + } catch (error) { + const handled = asHttpError(error); + if (response.headersSent) { + response.end(); + return; + } + sendJson( + response, + handled.status, + { error: { code: handled.code } }, + configStore.value, + ); + } + }); + + server.on('close', () => { + unsubscribe(); + watcher.stop(); + for (const client of clients) client.end(); + clients.clear(); + }); + + return server; +} + +function assertHost(request, config) { + const host = request.headers.host; + if (typeof host !== 'string') throw new HttpError(400, 'host_required'); + const allowed = new Set([ + config.rpId, + `${config.rpId}:443`, + `${config.bindHost}:${config.port}`, + `localhost:${config.port}`, + ]); + if (!allowed.has(host.toLowerCase())) { + throw new HttpError(403, 'host_denied'); + } +} + +async function serveStatic(response, pathname, config, { head = false } = {}) { + const [filename, contentType] = staticFiles.get(pathname); + try { + const body = await fs.readFile(path.join(publicDirectory, filename)); + response.writeHead(200, { + ...securityHeaders(config), + 'Content-Type': contentType, + 'Content-Length': body.length, + 'Cache-Control': + pathname === '/service-worker.js' || pathname === '/' || pathname === '/index.html' + ? 'no-cache' + : 'public, max-age=3600', + }); + response.end(head ? undefined : body); + } catch (error) { + if (error?.code === 'ENOENT') throw new HttpError(404, 'asset_not_found'); + throw error; + } +} diff --git a/tools/conductor-pocket/src/tailscale-config.mjs b/tools/conductor-pocket/src/tailscale-config.mjs new file mode 100644 index 00000000..c5ead840 --- /dev/null +++ b/tools/conductor-pocket/src/tailscale-config.mjs @@ -0,0 +1,52 @@ +function isObject(value) { + return value && typeof value === 'object' && !Array.isArray(value); +} + +function walk(value, visitor) { + if (!isObject(value) && !Array.isArray(value)) return; + visitor(value); + for (const child of Object.values(value)) walk(child, visitor); +} + +export function assertEmptyTailscaleConfig(value, label) { + if (!isObject(value) || Object.keys(value).length !== 0) { + throw new Error( + `A Tailscale ${label} configuration already exists. Refusing to overwrite it.`, + ); + } +} + +export function assertPrivateServeStatus(status, { rpId, port }) { + if (!isObject(status)) { + throw new Error('Tailscale Serve returned an invalid status document'); + } + const expectedTarget = `http://127.0.0.1:${port}`; + const proxies = []; + let hasHttps443 = false; + let hasExpectedRoot = false; + let funnelEnabled = false; + + walk(status, (value) => { + if (isObject(value)) { + if (typeof value.Proxy === 'string') proxies.push(value.Proxy); + if (value.AllowFunnel === true) funnelEnabled = true; + if (value.TCP?.['443']?.HTTPS === true) hasHttps443 = true; + const rootHandler = value.Web?.[`${rpId}:443`]?.Handlers?.['/']; + if (rootHandler?.Proxy === expectedTarget) hasExpectedRoot = true; + } + }); + + if (funnelEnabled) { + throw new Error('Tailscale Serve unexpectedly enabled public Funnel access'); + } + if ( + proxies.length !== 1 || + proxies[0] !== expectedTarget || + !hasHttps443 || + !hasExpectedRoot + ) { + throw new Error( + `Tailscale Serve is not the expected private HTTPS root proxy to ${expectedTarget}`, + ); + } +} diff --git a/tools/conductor-pocket/test/accessibility.test.mjs b/tools/conductor-pocket/test/accessibility.test.mjs new file mode 100644 index 00000000..6387b248 --- /dev/null +++ b/tools/conductor-pocket/test/accessibility.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import test from 'node:test'; +import { AccessibilityTransport } from '../src/accessibility.mjs'; + +test('accessibility transport rejects invalid messages before UI automation', async () => { + const transport = new AccessibilityTransport(); + assert.deepEqual( + await transport.send({ + workspaceName: 'Workspace', + sessionTitle: 'Chat', + sessionOrdinal: 1, + message: ' ', + }), + { ok: false, code: 'message_empty' }, + ); + assert.deepEqual( + await transport.send({ + workspaceName: 'Workspace', + sessionTitle: 'Chat', + sessionOrdinal: 1, + message: 'a'.repeat(17 * 1024), + }), + { ok: false, code: 'message_too_large' }, + ); + assert.deepEqual( + await transport.send({ + workspaceName: 'Workspace', + sessionTitle: 'Chat', + sessionOrdinal: 1, + message: 'replacement', + replaceDraft: true, + }), + { ok: false, code: 'draft_recheck_required' }, + ); +}); + +test('draft ownership and replacement checks are case-sensitive', async () => { + const source = await fs.readFile( + new URL('../src/conductor-send.applescript', import.meta.url), + 'utf8', + ); + assert.equal(source.match(/considering case/g)?.length, 3); + assert.match(source, /if currentValue is expectedMessage/); + assert.match(source, /existingDraft is not expectedDraft/); +}); diff --git a/tools/conductor-pocket/test/conductor-db.test.mjs b/tools/conductor-pocket/test/conductor-db.test.mjs new file mode 100644 index 00000000..dced28d1 --- /dev/null +++ b/tools/conductor-pocket/test/conductor-db.test.mjs @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import test from 'node:test'; +import { ConductorDatabase } from '../src/conductor-db.mjs'; + +test('database adapter exposes sanitized chat events without tool payloads', async (context) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-pocket-db-')); + context.after(() => fs.rm(directory, { recursive: true, force: true })); + const dbPath = path.join(directory, 'conductor.db'); + const writable = new DatabaseSync(dbPath); + writable.exec(` + CREATE TABLE repos ( + id TEXT PRIMARY KEY, + name TEXT, + hidden INTEGER DEFAULT 0 + ); + CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + repository_id TEXT, + workspace_name TEXT, + placeholder_branch_name TEXT, + branch TEXT, + directory_name TEXT, + state TEXT, + unread INTEGER, + pinned_at TEXT, + updated_at TEXT + ); + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + workspace_id TEXT, + title TEXT, + agent_type TEXT, + model TEXT, + status TEXT, + unread_count INTEGER, + created_at TEXT, + updated_at TEXT, + last_user_message_at TEXT, + context_used_percent REAL, + queue_paused_at TEXT, + is_hidden INTEGER DEFAULT 0 + ); + CREATE TABLE session_messages ( + id TEXT PRIMARY KEY, + session_id TEXT, + role TEXT, + content TEXT, + created_at TEXT, + sent_at TEXT, + cancelled_at TEXT, + model TEXT, + turn_id TEXT + ); + `); + writable + .prepare('INSERT INTO repos (id, name, hidden) VALUES (?, ?, 0)') + .run('repo-1', 'Quickstart'); + writable + .prepare(` + INSERT INTO workspaces + (id, repository_id, workspace_name, branch, directory_name, state, unread, updated_at) + VALUES (?, ?, ?, ?, ?, 'ready', 0, ?) + `) + .run('workspace-1', 'repo-1', 'Pocket test', 'feature/pocket', 'test', '2026-01-01'); + writable + .prepare(` + INSERT INTO sessions + (id, workspace_id, title, agent_type, model, status, unread_count, created_at, updated_at, last_user_message_at, is_hidden) + VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, 0) + `) + .run( + 'session-1', + 'workspace-1', + 'Test chat', + 'codex', + 'gpt-test', + 'working', + '2026-01-01', + '2026-01-01', + '2026-01-01', + ); + const insert = writable.prepare(` + INSERT INTO session_messages + (id, session_id, role, content, created_at, sent_at, model, turn_id) + VALUES (?, 'session-1', ?, ?, ?, ?, 'gpt-test', 'turn-1') + `); + insert.run('user-1', 'user', 'Hello from the phone', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'); + insert.run( + 'assistant-1', + 'assistant', + JSON.stringify({ + type: 'assistant', + message: { content: [{ type: 'text', text: 'Hello back.' }] }, + }), + '2026-01-01T00:00:01Z', + null, + ); + insert.run( + 'tool-1', + 'assistant', + JSON.stringify({ + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + id: 'tool-call-1', + name: 'read_secret_file', + input: { path: '/private/secret', token: 'must-not-leak' }, + }, + ], + }, + }), + '2026-01-01T00:00:02Z', + null, + ); + insert.run( + 'tool-result-1', + 'assistant', + JSON.stringify({ + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-call-1', + content: 'highly sensitive output', + }, + ], + }, + }), + '2026-01-01T00:00:03Z', + null, + ); + writable.close(); + + const database = new ConductorDatabase(dbPath); + context.after(() => database.close()); + const workspaces = database.listWorkspaces(); + assert.equal(workspaces[0].name, 'Pocket test'); + assert.equal(workspaces[0].workingCount, 1); + assert.equal(database.listSessions('workspace-1')[0].title, 'Test chat'); + + const result = database.listMessages('session-1'); + assert.equal(result.messages.some((message) => message.text === 'Hello back.'), true); + assert.equal( + result.messages.some((message) => message.name === 'Read Secret File'), + true, + ); + const serialized = JSON.stringify(result); + assert.equal(serialized.includes('must-not-leak'), false); + assert.equal(serialized.includes('highly sensitive output'), false); +}); diff --git a/tools/conductor-pocket/test/config.test.mjs b/tools/conductor-pocket/test/config.test.mjs new file mode 100644 index 00000000..0c402d77 --- /dev/null +++ b/tools/conductor-pocket/test/config.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + createConfig, + getVerificationCode, + loadConfig, + saveConfig, + validateConfig, +} from '../src/config.mjs'; + +test('configuration is loopback-only and stores only a pairing digest', async (context) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-pocket-config-')); + context.after(() => fs.rm(directory, { recursive: true, force: true })); + const configPath = path.join(directory, 'config.json'); + const { config, pairingCode } = createConfig({ + publicOrigin: 'http://127.0.0.1:4317', + developmentMode: true, + now: 1_000, + }); + + assert.equal(config.bindHost, '127.0.0.1'); + assert.equal(config.requireTailscaleIdentity, false); + assert.equal(config.rpId, '127.0.0.1'); + assert.notEqual(config.pairing.codeHash, pairingCode); + assert.equal(JSON.stringify(config).includes(pairingCode), false); + assert.match(getVerificationCode(config), /^[A-Z0-9]{6}$/); + + await saveConfig(configPath, config); + const saved = await loadConfig(configPath); + assert.equal(saved.publicOrigin, 'http://127.0.0.1:4317'); + assert.equal((await fs.stat(configPath)).mode & 0o777, 0o600); + assert.equal((await fs.stat(directory)).mode & 0o077, 0); +}); +test('configuration rejects a LAN or wildcard bind', () => { + const { config } = createConfig({ + publicOrigin: 'http://127.0.0.1:4317', + developmentMode: true, + }); + assert.throws( + () => validateConfig({ ...config, bindHost: '0.0.0.0' }), + /loopback|127\.0\.0\.1/, + ); + assert.throws( + () => + validateConfig({ + ...config, + developmentMode: false, + publicOrigin: 'http://pocket.example.com', + rpId: 'pocket.example.com', + }), + /HTTPS/, + ); +}); diff --git a/tools/conductor-pocket/test/security.test.mjs b/tools/conductor-pocket/test/security.test.mjs new file mode 100644 index 00000000..e0e7f31e --- /dev/null +++ b/tools/conductor-pocket/test/security.test.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { ConfigStore, createConfig, saveConfig } from '../src/config.mjs'; +import { + SESSION_COOKIE, + UNLOCK_IDLE_TTL_MS, + UNLOCK_TTL_MS, +} from '../src/constants.mjs'; +import { sha256 } from '../src/encoding.mjs'; +import { + SecurityManager, + createUnlockWindow, + evaluateUnlockWindow, +} from '../src/security.mjs'; + +test('unlock window enforces both idle and absolute Face ID deadlines', () => { + const startedAt = 1_000_000; + const idleWindow = createUnlockWindow(startedAt); + assert.equal( + evaluateUnlockWindow(idleWindow, startedAt + UNLOCK_IDLE_TTL_MS - 1) + .unlocked, + true, + ); + assert.equal( + evaluateUnlockWindow(idleWindow, startedAt + UNLOCK_IDLE_TTL_MS).unlocked, + false, + ); + + const activeWindow = createUnlockWindow(startedAt); + const touched = evaluateUnlockWindow( + activeWindow, + startedAt + UNLOCK_IDLE_TTL_MS - 1_000, + { touch: true }, + ); + assert.equal(touched.unlocked, true); + assert.equal( + touched.unlockedUntil, + startedAt + 2 * UNLOCK_IDLE_TTL_MS - 1_000, + ); + + const nearAbsoluteDeadline = createUnlockWindow(startedAt); + nearAbsoluteDeadline.idleUntil = nearAbsoluteDeadline.absoluteUntil; + const capped = evaluateUnlockWindow( + nearAbsoluteDeadline, + startedAt + UNLOCK_TTL_MS - 1_000, + { touch: true }, + ); + assert.equal(capped.unlockedUntil, startedAt + UNLOCK_TTL_MS); + assert.equal( + evaluateUnlockWindow( + nearAbsoluteDeadline, + startedAt + UNLOCK_TTL_MS, + ).unlocked, + false, + ); +}); + +test('device session requires its cookie and CSRF proof and starts locked', async (context) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-pocket-security-')); + context.after(() => fs.rm(directory, { recursive: true, force: true })); + const configPath = path.join(directory, 'config.json'); + const { config } = createConfig({ + publicOrigin: 'http://127.0.0.1:4317', + developmentMode: true, + }); + const rawSession = 'test-session-token-with-enough-entropy-for-fixture'; + config.devices = [ + { + id: 'device-1', + name: 'Test iPhone', + tailscaleLogin: null, + createdAt: '2026-01-01T00:00:00Z', + lastSeenAt: '2026-01-01T00:00:00Z', + sessionHash: sha256(rawSession), + passkey: { + id: 'credential-1', + publicKey: 'AA', + counter: 0, + transports: ['internal'], + backedUp: true, + }, + }, + ]; + await saveConfig(configPath, config); + const store = new ConfigStore(configPath, config); + const security = new SecurityManager(store); + const request = { + headers: { cookie: `${SESSION_COOKIE}=${rawSession}` }, + socket: { remoteAddress: '127.0.0.1' }, + }; + + const bootstrap = security.bootstrap(request); + assert.equal(bootstrap.authenticated, true); + assert.equal(bootstrap.unlocked, false); + assert.match(bootstrap.csrfToken, /^[A-Za-z0-9_-]+$/); + assert.throws( + () => security.session(request, { requireUnlocked: true }), + (error) => error.status === 423 && error.code === 'device_locked', + ); + assert.throws( + () => security.session(request, { requireCsrf: true }), + (error) => error.status === 403 && error.code === 'csrf_denied', + ); + + request.headers['x-csrf-token'] = bootstrap.csrfToken; + assert.equal(security.session(request, { requireCsrf: true }).device.id, 'device-1'); +}); + +test('production identity is pinned to the paired Tailscale login', async (context) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-pocket-tail-')); + context.after(() => fs.rm(directory, { recursive: true, force: true })); + const configPath = path.join(directory, 'config.json'); + const { config } = createConfig({ + publicOrigin: 'https://mac.example-tailnet.ts.net', + developmentMode: false, + }); + config.allowedTailscaleLogin = 'alex@example.com'; + await saveConfig(configPath, config); + const security = new SecurityManager(new ConfigStore(configPath, config)); + + assert.equal( + security.assertTailscaleIdentity({ + headers: { 'tailscale-user-login': 'Alex@Example.com' }, + }), + 'alex@example.com', + ); + assert.throws( + () => + security.assertTailscaleIdentity({ + headers: { 'tailscale-user-login': 'other@example.com' }, + }), + (error) => error.status === 403 && error.code === 'tailscale_identity_denied', + ); +}); diff --git a/tools/conductor-pocket/test/server.test.mjs b/tools/conductor-pocket/test/server.test.mjs new file mode 100644 index 00000000..2a298e70 --- /dev/null +++ b/tools/conductor-pocket/test/server.test.mjs @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import test from 'node:test'; +import { createConfig } from '../src/config.mjs'; +import { createPocketServer } from '../src/server.mjs'; + +function createWatcher() { + return { + subscribe() { + return () => {}; + }, + stop() {}, + }; +} + +function createServer(config) { + return createPocketServer({ + configStore: { value: config }, + security: { + bootstrap() { + return { authenticated: true, unlocked: false }; + }, + session() { + return { + device: { id: 'test-device' }, + csrfToken: 'test-csrf', + unlocked: true, + }; + }, + }, + database: {}, + watcher: createWatcher(), + transport: { + async doctor() { + return { ok: true, code: 'ready' }; + }, + }, + }); +} + +async function listen(server) { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + return server.address().port; +} + +async function close(server) { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +function get(port, { pathname = '/', host = '127.0.0.1:4317', method = 'GET' } = {}) { + return new Promise((resolve, reject) => { + const request = http.request( + { + host: '127.0.0.1', + port, + path: pathname, + method, + headers: { Host: host }, + }, + (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => + resolve({ + status: response.statusCode, + headers: response.headers, + body: Buffer.concat(chunks).toString('utf8'), + }), + ); + }, + ); + request.on('error', reject); + request.end(); + }); +} + +test('static shell is hardened, host-checked, and development HTTP is not upgraded', async (context) => { + const { config } = createConfig({ + publicOrigin: 'http://127.0.0.1:4317', + developmentMode: true, + }); + const server = createServer(config); + const port = await listen(server); + context.after(() => close(server)); + + const page = await get(port); + assert.equal(page.status, 200); + assert.match(page.headers['content-security-policy'], /default-src 'self'/); + assert.doesNotMatch( + page.headers['content-security-policy'], + /upgrade-insecure-requests/, + ); + assert.equal(page.headers['x-frame-options'], 'DENY'); + assert.equal(page.headers['referrer-policy'], 'no-referrer'); + assert.match(page.headers['permissions-policy'], /microphone=\(\)/); + assert.match(page.body, /id="app"/); + + const head = await get(port, { method: 'HEAD' }); + assert.equal(head.status, 200); + assert.equal(head.headers['content-type'], 'text/html; charset=utf-8'); + assert.equal(head.body, ''); + + const denied = await get(port, { host: 'attacker.invalid' }); + assert.equal(denied.status, 403); + assert.deepEqual(JSON.parse(denied.body), { + error: { code: 'host_denied' }, + }); + + const health = await get(port, { pathname: '/api/health' }); + assert.equal(health.status, 200); + assert.equal(health.headers['cache-control'], 'no-store, max-age=0'); +}); + +test('production shell emits HTTPS upgrade and HSTS', async (context) => { + const { config } = createConfig({ + publicOrigin: 'https://mac.example-tailnet.ts.net', + }); + const server = createServer(config); + const port = await listen(server); + context.after(() => close(server)); + + const page = await get(port, { host: config.rpId }); + assert.equal(page.status, 200); + assert.match( + page.headers['content-security-policy'], + /upgrade-insecure-requests/, + ); + assert.match(page.headers['strict-transport-security'], /max-age=31536000/); +}); + +test('connection probe reports the real relay version', async (context) => { + const { config } = createConfig({ + publicOrigin: 'http://127.0.0.1:4317', + developmentMode: true, + }); + const server = createServer(config); + const port = await listen(server); + context.after(() => close(server)); + + const response = await get(port, { pathname: '/api/connection' }); + assert.equal(response.status, 200); + const connection = JSON.parse(response.body); + assert.equal(connection.relayVersion, '0.1.0'); + assert.equal(connection.conductor, true); + assert.equal(connection.sendPath, true); +}); + +test('service worker explicitly bypasses every API request', async () => { + const source = await fs.readFile( + new URL('../public/service-worker.js', import.meta.url), + 'utf8', + ); + assert.match(source, /requestUrl\.pathname\.startsWith\('\/api\/'\)/); + assert.match(source, /requestUrl\.origin !== self\.location\.origin/); +}); diff --git a/tools/conductor-pocket/test/tailscale-config.test.mjs b/tools/conductor-pocket/test/tailscale-config.test.mjs new file mode 100644 index 00000000..c5103684 --- /dev/null +++ b/tools/conductor-pocket/test/tailscale-config.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + assertEmptyTailscaleConfig, + assertPrivateServeStatus, +} from '../src/tailscale-config.mjs'; + +const expected = { + TCP: { 443: { HTTPS: true } }, + Web: { + 'mac.example.ts.net:443': { + Handlers: { + '/': { Proxy: 'http://127.0.0.1:4317' }, + }, + }, + }, +}; + +test('installer accepts only the exact private HTTPS loopback proxy', () => { + assert.doesNotThrow(() => + assertPrivateServeStatus(expected, { + rpId: 'mac.example.ts.net', + port: 4317, + }), + ); + assert.throws( + () => + assertPrivateServeStatus( + { + ...expected, + Web: { + 'mac.example.ts.net:443': { + Handlers: { '/': { Proxy: 'http://127.0.0.1:9999' } }, + }, + }, + }, + { rpId: 'mac.example.ts.net', port: 4317 }, + ), + /expected private HTTPS root proxy/, + ); + assert.throws( + () => + assertPrivateServeStatus( + { ...expected, AllowFunnel: true }, + { rpId: 'mac.example.ts.net', port: 4317 }, + ), + /Funnel/, + ); +}); + +test('installer refuses any pre-existing Serve or Funnel state', () => { + assert.doesNotThrow(() => assertEmptyTailscaleConfig({}, 'Serve')); + assert.throws( + () => assertEmptyTailscaleConfig({ Web: {} }, 'Serve'), + /Refusing to overwrite/, + ); +}); From 6a9cd770fdf94e31ff9f1b50ec8ab5c45b88b5e6 Mon Sep 17 00:00:00 2001 From: Alex Weinstein Date: Sat, 25 Jul 2026 20:02:50 -0700 Subject: [PATCH 2/4] fix: normalize empty Conductor composer state --- .../src/conductor-send.applescript | 18 ++++++++++++++---- .../test/accessibility.test.mjs | 10 ++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/tools/conductor-pocket/src/conductor-send.applescript b/tools/conductor-pocket/src/conductor-send.applescript index cb355ae9..1c36c95f 100644 --- a/tools/conductor-pocket/src/conductor-send.applescript +++ b/tools/conductor-pocket/src/conductor-send.applescript @@ -9,6 +9,15 @@ on draftConflict(existingDraft) return "{\"ok\":false,\"code\":\"draft_conflict\",\"draftBase64\":\"" & encodedDraft & "\"}" end draftConflict +on normalizedDraft(rawValue) + set valueText to rawValue as text + if valueText ends with linefeed then + if (length of valueText) is 1 then return "" + return text 1 thru -2 of valueText + end if + return valueText +end normalizedDraft + on getWebArea() tell application "System Events" tell process "Conductor" @@ -124,8 +133,9 @@ on commitTextAreaValue(textArea, requestedValue) key code 51 end tell delay 0.1 + set committedValue to my normalizedDraft(value of textArea as text) considering case - return (value of textArea as text) is requestedValue + return committedValue is requestedValue end considering end commitTextAreaValue @@ -134,7 +144,7 @@ on clearOwnedDraft(expectedMessage) set textArea to getTextArea() if textArea is not missing value then tell application "System Events" - set currentValue to value of textArea as text + set currentValue to my normalizedDraft(value of textArea as text) end tell considering case if currentValue is expectedMessage then my commitTextAreaValue(textArea, "") @@ -222,7 +232,7 @@ end repeat if textArea is missing value then return "{\"ok\":false,\"code\":\"composer_unavailable\"}" tell application "System Events" - set existingDraft to value of textArea as text + set existingDraft to my normalizedDraft(value of textArea as text) considering case if existingDraft is not messageText then if replaceDraft is false and existingDraft is not "" then return my draftConflict(existingDraft) @@ -278,7 +288,7 @@ repeat with waitIndex from 1 to 40 set currentTextArea to getTextArea() if currentTextArea is not missing value then tell application "System Events" - if (value of currentTextArea as text) is "" then + if my normalizedDraft(value of currentTextArea as text) is "" then return "{\"ok\":true,\"code\":\"sent\"}" end if end tell diff --git a/tools/conductor-pocket/test/accessibility.test.mjs b/tools/conductor-pocket/test/accessibility.test.mjs index 6387b248..b9c1da7e 100644 --- a/tools/conductor-pocket/test/accessibility.test.mjs +++ b/tools/conductor-pocket/test/accessibility.test.mjs @@ -44,3 +44,13 @@ test('draft ownership and replacement checks are case-sensitive', async () => { assert.match(source, /if currentValue is expectedMessage/); assert.match(source, /existingDraft is not expectedDraft/); }); + +test('the structural accessibility linefeed is not treated as a Mac draft', async () => { + const source = await fs.readFile( + new URL('../src/conductor-send.applescript', import.meta.url), + 'utf8', + ); + assert.match(source, /on normalizedDraft\(rawValue\)/); + assert.match(source, /if \(length of valueText\) is 1 then return ""/); + assert.equal(source.match(/my normalizedDraft/g)?.length, 4); +}); From 1ee9ad0ad0fc36e31221193752978383c15743b9 Mon Sep 17 00:00:00 2001 From: Alex Weinstein Date: Sat, 25 Jul 2026 21:19:05 -0700 Subject: [PATCH 3/4] fix: retry safe Pocket delivery failures --- tools/conductor-pocket/src/accessibility.mjs | 31 +++- tools/conductor-pocket/src/server.mjs | 10 ++ .../test/accessibility.test.mjs | 27 +++- tools/conductor-pocket/test/server.test.mjs | 149 ++++++++++++++++++ 4 files changed, 207 insertions(+), 10 deletions(-) diff --git a/tools/conductor-pocket/src/accessibility.mjs b/tools/conductor-pocket/src/accessibility.mjs index b6d36c89..e93d2eb7 100644 --- a/tools/conductor-pocket/src/accessibility.mjs +++ b/tools/conductor-pocket/src/accessibility.mjs @@ -6,18 +6,37 @@ import { normalizeText } from './encoding.mjs'; const execFileAsync = promisify(execFile); const scriptPath = fileURLToPath(new URL('./conductor-send.applescript', import.meta.url)); +const safeToRetryCodes = new Set([ + 'accessibility_disabled', + 'composer_unavailable', + 'composer_update_failed', + 'conductor_not_running', + 'conductor_window_unavailable', + 'draft_conflict', + 'send_unavailable', + 'session_not_visible', + 'workspace_list_unavailable', + 'workspace_not_visible', +]); function byteLength(value) { return Buffer.byteLength(value, 'utf8'); } -function parseResult(stdout) { +function safeToRetry(code) { + return { ok: false, code, safeToRetry: true }; +} + +export function parseResult(stdout) { const trimmed = stdout.trim(); try { const result = JSON.parse(trimmed); if (!result || typeof result.ok !== 'boolean' || typeof result.code !== 'string') { throw new Error('Unexpected result shape'); } + if (!result.ok && safeToRetryCodes.has(result.code)) { + result.safeToRetry = true; + } return result; } catch { return { ok: false, code: 'automation_invalid_response' }; @@ -57,26 +76,26 @@ export class AccessibilityTransport { }) { const normalized = normalizeText(message); if (!normalized.trim()) { - return Promise.resolve({ ok: false, code: 'message_empty' }); + return Promise.resolve(safeToRetry('message_empty')); } if (normalized.includes('\0')) { - return Promise.resolve({ ok: false, code: 'message_invalid' }); + return Promise.resolve(safeToRetry('message_invalid')); } if (byteLength(normalized) > MAX_MESSAGE_BYTES) { - return Promise.resolve({ ok: false, code: 'message_too_large' }); + return Promise.resolve(safeToRetry('message_too_large')); } const normalizedExpectedDraft = typeof expectedMacDraft === 'string' ? normalizeText(expectedMacDraft) : null; if (replaceDraft && normalizedExpectedDraft === null) { - return Promise.resolve({ ok: false, code: 'draft_recheck_required' }); + return Promise.resolve(safeToRetry('draft_recheck_required')); } if ( normalizedExpectedDraft !== null && byteLength(normalizedExpectedDraft) > MAX_MESSAGE_BYTES ) { - return Promise.resolve({ ok: false, code: 'draft_recheck_required' }); + return Promise.resolve(safeToRetry('draft_recheck_required')); } const task = () => this.#run({ diff --git a/tools/conductor-pocket/src/server.mjs b/tools/conductor-pocket/src/server.mjs index b0cddadf..f5730b78 100644 --- a/tools/conductor-pocket/src/server.mjs +++ b/tools/conductor-pocket/src/server.mjs @@ -146,6 +146,16 @@ class IdempotencyStore { promise, expiresAt: Date.now() + 60 * 60 * 1000, }); + void promise.then( + (result) => { + if (result?.ok !== false || result.safeToRetry !== true) return; + const current = this.#entries.get(key); + if (current?.promise === promise) this.#entries.delete(key); + }, + () => { + // Unknown failures remain cached because the send outcome may be ambiguous. + }, + ); return promise; } diff --git a/tools/conductor-pocket/test/accessibility.test.mjs b/tools/conductor-pocket/test/accessibility.test.mjs index b9c1da7e..56c4ac30 100644 --- a/tools/conductor-pocket/test/accessibility.test.mjs +++ b/tools/conductor-pocket/test/accessibility.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import test from 'node:test'; -import { AccessibilityTransport } from '../src/accessibility.mjs'; +import { AccessibilityTransport, parseResult } from '../src/accessibility.mjs'; test('accessibility transport rejects invalid messages before UI automation', async () => { const transport = new AccessibilityTransport(); @@ -12,7 +12,7 @@ test('accessibility transport rejects invalid messages before UI automation', as sessionOrdinal: 1, message: ' ', }), - { ok: false, code: 'message_empty' }, + { ok: false, code: 'message_empty', safeToRetry: true }, ); assert.deepEqual( await transport.send({ @@ -21,7 +21,7 @@ test('accessibility transport rejects invalid messages before UI automation', as sessionOrdinal: 1, message: 'a'.repeat(17 * 1024), }), - { ok: false, code: 'message_too_large' }, + { ok: false, code: 'message_too_large', safeToRetry: true }, ); assert.deepEqual( await transport.send({ @@ -31,10 +31,29 @@ test('accessibility transport rejects invalid messages before UI automation', as message: 'replacement', replaceDraft: true, }), - { ok: false, code: 'draft_recheck_required' }, + { ok: false, code: 'draft_recheck_required', safeToRetry: true }, ); }); +test('only structured pre-send automation failures are marked safe to retry', () => { + assert.deepEqual( + parseResult('{"ok":false,"code":"accessibility_disabled"}'), + { + ok: false, + code: 'accessibility_disabled', + safeToRetry: true, + }, + ); + assert.deepEqual(parseResult('{"ok":false,"code":"send_not_confirmed"}'), { + ok: false, + code: 'send_not_confirmed', + }); + assert.deepEqual(parseResult('not-json'), { + ok: false, + code: 'automation_invalid_response', + }); +}); + test('draft ownership and replacement checks are case-sensitive', async () => { const source = await fs.readFile( new URL('../src/conductor-send.applescript', import.meta.url), diff --git a/tools/conductor-pocket/test/server.test.mjs b/tools/conductor-pocket/test/server.test.mjs index 2a298e70..0399446f 100644 --- a/tools/conductor-pocket/test/server.test.mjs +++ b/tools/conductor-pocket/test/server.test.mjs @@ -80,6 +80,40 @@ function get(port, { pathname = '/', host = '127.0.0.1:4317', method = 'GET' } = }); } +function postMessage(port, { idempotencyKey, message = 'Test message' }) { + const body = JSON.stringify({ message }); + return new Promise((resolve, reject) => { + const request = http.request( + { + host: '127.0.0.1', + port, + path: '/api/sessions/test-session/messages', + method: 'POST', + headers: { + Host: '127.0.0.1:4317', + Origin: 'http://127.0.0.1:4317', + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + 'Idempotency-Key': idempotencyKey, + 'X-CSRF-Token': 'test-csrf', + }, + }, + (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => + resolve({ + status: response.statusCode, + body: Buffer.concat(chunks).toString('utf8'), + }), + ); + }, + ); + request.on('error', reject); + request.end(body); + }); +} + test('static shell is hardened, host-checked, and development HTTP is not upgraded', async (context) => { const { config } = createConfig({ publicOrigin: 'http://127.0.0.1:4317', @@ -151,6 +185,121 @@ test('connection probe reports the real relay version', async (context) => { assert.equal(connection.sendPath, true); }); +test('a pre-send failure can retry without weakening ambiguous-send idempotency', async (context) => { + const { config } = createConfig({ + publicOrigin: 'http://127.0.0.1:4317', + developmentMode: true, + }); + let retryableSends = 0; + let ambiguousSends = 0; + let mappedPermissionFailures = 0; + let rejectedSends = 0; + const server = createPocketServer({ + configStore: { value: config }, + security: { + assertOrigin() {}, + session() { + return { + device: { id: 'test-device' }, + csrfToken: 'test-csrf', + unlocked: true, + }; + }, + }, + database: { + getSessionRoute() { + return { + id: 'test-session', + workspaceName: 'Workspace', + title: 'Chat', + titleOrdinal: 1, + }; + }, + }, + watcher: createWatcher(), + transport: { + async send({ message }) { + if (message === 'Mapped permission failure') { + mappedPermissionFailures += 1; + return mappedPermissionFailures === 1 + ? { ok: false, code: 'accessibility_disabled' } + : { ok: true, code: 'sent' }; + } + if (message === 'Rejected send') { + rejectedSends += 1; + throw new Error('transport failed'); + } + if (message === 'Ambiguous send') { + ambiguousSends += 1; + return ambiguousSends === 1 + ? { ok: false, code: 'send_not_confirmed' } + : { ok: true, code: 'sent' }; + } + retryableSends += 1; + return retryableSends === 1 + ? { + ok: false, + code: 'accessibility_disabled', + safeToRetry: true, + } + : { ok: true, code: 'sent' }; + }, + }, + }); + const port = await listen(server); + context.after(() => close(server)); + + const first = await postMessage(port, { + idempotencyKey: 'retryable_failure_key', + }); + const retry = await postMessage(port, { + idempotencyKey: 'retryable_failure_key', + }); + + assert.equal(first.status, 503); + assert.equal(retry.status, 200); + assert.equal(retryableSends, 2); + + const mappedPermissionFailure = await postMessage(port, { + idempotencyKey: 'mapped_permission_failure_key', + message: 'Mapped permission failure', + }); + const mappedPermissionRetry = await postMessage(port, { + idempotencyKey: 'mapped_permission_failure_key', + message: 'Mapped permission failure', + }); + + assert.equal(mappedPermissionFailure.status, 503); + assert.equal(mappedPermissionRetry.status, 503); + assert.equal(mappedPermissionFailures, 1); + + const ambiguous = await postMessage(port, { + idempotencyKey: 'ambiguous_failure_key', + message: 'Ambiguous send', + }); + const ambiguousRetry = await postMessage(port, { + idempotencyKey: 'ambiguous_failure_key', + message: 'Ambiguous send', + }); + + assert.equal(ambiguous.status, 502); + assert.equal(ambiguousRetry.status, 502); + assert.equal(ambiguousSends, 1); + + const rejected = await postMessage(port, { + idempotencyKey: 'rejected_send_key', + message: 'Rejected send', + }); + const rejectedRetry = await postMessage(port, { + idempotencyKey: 'rejected_send_key', + message: 'Rejected send', + }); + + assert.equal(rejected.status, 500); + assert.equal(rejectedRetry.status, 500); + assert.equal(rejectedSends, 1); +}); + test('service worker explicitly bypasses every API request', async () => { const source = await fs.readFile( new URL('../public/service-worker.js', import.meta.url), From 14ced7422648ea81a375dd36d58950146c686a79 Mon Sep 17 00:00:00 2001 From: Alex Weinstein Date: Sun, 26 Jul 2026 12:32:24 -0700 Subject: [PATCH 4/4] feat: harden Conductor Pocket relay --- tools/conductor-pocket/README.md | 69 +- tools/conductor-pocket/SECURITY.md | 52 +- tools/conductor-pocket/package-lock.json | 4 +- tools/conductor-pocket/package.json | 7 +- tools/conductor-pocket/public/app.js | 617 ++++++++++++++++-- .../public/delivery-receipts.js | 35 + tools/conductor-pocket/public/index.html | 2 +- .../conductor-pocket/public/service-worker.js | 53 +- .../scripts/cutover-sidecar.mjs | 291 +++++++++ .../scripts/install-relay.mjs | 174 +++-- .../scripts/install-sidecar.mjs | 137 ++++ .../conductor-pocket/scripts/lib/cutover.mjs | 162 +++++ tools/conductor-pocket/scripts/lib/login.mjs | 270 ++++++++ .../conductor-pocket/scripts/lib/sidecar.mjs | 501 ++++++++++++++ .../scripts/login-sidecar.mjs | 61 ++ tools/conductor-pocket/src/accessibility.mjs | 9 + tools/conductor-pocket/src/cli.mjs | 382 ++++++++++- tools/conductor-pocket/src/conductor-db.mjs | 63 +- .../src/conductor-send.applescript | 17 +- tools/conductor-pocket/src/config.mjs | 165 ++++- tools/conductor-pocket/src/constants.mjs | 2 +- tools/conductor-pocket/src/operation-lock.mjs | 281 ++++++++ tools/conductor-pocket/src/security.mjs | 36 +- tools/conductor-pocket/src/server.mjs | 247 ++++++- .../conductor-pocket/src/tailscale-config.mjs | 213 +++++- .../test/accessibility.test.mjs | 26 +- .../test/conductor-db.test.mjs | 190 ++++++ tools/conductor-pocket/test/config.test.mjs | 128 ++++ tools/conductor-pocket/test/cutover.test.mjs | 191 ++++++ .../test/delivery-receipts.test.mjs | 73 +++ tools/conductor-pocket/test/login.test.mjs | 175 +++++ .../test/operation-lock.test.mjs | 132 ++++ tools/conductor-pocket/test/security.test.mjs | 41 ++ tools/conductor-pocket/test/server.test.mjs | 408 +++++++++++- tools/conductor-pocket/test/sidecar.test.mjs | 252 +++++++ .../test/tailscale-config.test.mjs | 274 +++++++- 36 files changed, 5488 insertions(+), 252 deletions(-) create mode 100644 tools/conductor-pocket/public/delivery-receipts.js create mode 100644 tools/conductor-pocket/scripts/cutover-sidecar.mjs create mode 100644 tools/conductor-pocket/scripts/install-sidecar.mjs create mode 100644 tools/conductor-pocket/scripts/lib/cutover.mjs create mode 100644 tools/conductor-pocket/scripts/lib/login.mjs create mode 100644 tools/conductor-pocket/scripts/lib/sidecar.mjs create mode 100644 tools/conductor-pocket/scripts/login-sidecar.mjs create mode 100644 tools/conductor-pocket/src/operation-lock.mjs create mode 100644 tools/conductor-pocket/test/cutover.test.mjs create mode 100644 tools/conductor-pocket/test/delivery-receipts.test.mjs create mode 100644 tools/conductor-pocket/test/login.test.mjs create mode 100644 tools/conductor-pocket/test/operation-lock.test.mjs create mode 100644 tools/conductor-pocket/test/sidecar.test.mjs diff --git a/tools/conductor-pocket/README.md b/tools/conductor-pocket/README.md index 7b657f4f..01ac9bf5 100644 --- a/tools/conductor-pocket/README.md +++ b/tools/conductor-pocket/README.md @@ -30,7 +30,8 @@ Pocket never overwrites or merges it silently. The production setup is defense in depth: 1. The Node relay binds only to `127.0.0.1`. -2. Tailscale **Serve** provides private tailnet HTTPS. Funnel is refused. +2. A dedicated user-mode Tailscale node gives Pocket its own hostname, IP, + certificate, browser origin, and **Serve** configuration. Funnel is refused. 3. Tailscale's authenticated identity header is captured at pairing and must match on every request. 4. A 192-bit, single-use pairing link expires after 15 minutes and stays in @@ -56,6 +57,9 @@ See [SECURITY.md](./SECURITY.md) for trust boundaries and failure behavior. - macOS with Conductor installed and running. - Node 22.5 or newer. - Tailscale signed into the same tailnet on the Mac and iPhone. +- Homebrew's open-source `tailscale` formula for the dedicated Pocket node. + Its background service must remain disabled; Pocket owns a private + user-level daemon, state directory, and Unix socket. - The Mac awake and online. Conductor can continue working with its window in the background, but it must have a window available for phone sends. - One-time macOS Accessibility permission for the relay's Node executable. @@ -66,21 +70,38 @@ From this directory: ```sh npm install +brew install tailscale +npm run sidecar:install +npm run sidecar:login npm run setup npm run install:relay +npm run sidecar:cutover npm run doctor ``` +`sidecar:login` opens a one-time Tailscale authorization URL in the Mac browser +and exits as soon as the private daemon owns the request; it does not need an +open terminal while you approve it. If the command or chat is interrupted, +rerun it to recover the still-pending URL from the private socket. If browser +opening is unavailable, explicitly print the secret with +`npm run sidecar:login -- --print-url`. After approving the URL, rerun the +command once to verify the node is authenticated. It creates a separate node +named `conductor-pocket`; it does not log out, rename, or replace the Mac's +normal Tailscale connection. Never run `brew services start tailscale` for +this setup. + `npm run setup` prints two values: - a single-use pairing URL to open on the iPhone; - a six-character verification code that must match the phone. -The installer refuses to continue if any Tailscale Funnel exists or if a -Serve configuration would be overwritten. It creates a user LaunchAgent and -a private Serve proxy only after those checks. It first copies the audited -runtime into `~/.config/conductor-pocket/runtimes/`, so archiving or deleting -this source workspace cannot break the installed relay. +The relay installer first copies the audited runtime into +`~/.config/conductor-pocket/runtimes/`, so archiving or deleting this source +workspace cannot break the installed relay. The sidecar installer creates a +separate user LaunchAgent with a `0700` state directory and an explicit private +socket. Cutover refuses an unexpected daemon process or launch argument, +unsafe Tailscale preferences, extra Serve handlers, enabled Funnel state, a +different tailnet, a reused Mac node identity, or an origin mismatch. Open the pairing URL on the iPhone while Tailscale is connected, compare the code, enroll Face ID, then use Safari's Share → Add to Home Screen. @@ -91,6 +112,42 @@ To pair another phone later: npm run pair ``` +## Migrating from the old shared Mac hostname + +The old path-based setup must be retired, not reused. Paths on one hostname +share cookies, WebAuthn authority, IndexedDB, Cache Storage, localStorage, and +service workers. + +1. Install and authenticate the dedicated sidecar. +2. Run `npm run install:relay` to install the versioned retirement client. +3. Run `npm run sidecar:cutover` once. This arms server-enforced retirement + and disables new pairing on the old origin. +4. On every old phone, fully close and reopen Pocket while online. In Security + & Devices, confirm the App row says `client 0.2.0`, then sign out **that + same phone**. Close every other Pocket window first. The client sets an + origin tombstone, stops sibling contexts, verifies it is the only remaining + browser window, and erases IndexedDB, Pocket-owned Cache Storage, and the + Pocket root service worker before the Mac records retirement. +5. Remove the old Pocket Home Screen icon. +6. Run `npm run sidecar:cutover` again. +7. Open the new one-time link, enroll a new Face ID passkey, and add the new + dedicated address to the Home Screen. + +Cutover rotates the CSRF secret, pairing secret, RP ID, and public origin, +requires a versioned self-purge receipt from every original device, verifies +the exact live config revision over both loopback and HTTPS, and removes only +Pocket's old `/` handler. Remote revocation cannot satisfy retirement. Other +handlers and listeners on the Mac's normal Tailscale hostname are checked +against the complete pre-cutover Serve document and preserved exactly. + +If a legacy config already has no paired devices and no retirement record, +cutover refuses to guess. Only when the origin was never paired or every old +copy was independently erased may you explicitly run: + +```sh +npm run sidecar:cutover -- --attest-no-old-devices +``` + ## Development An insecure loopback-only mode exists for automated browser tests: diff --git a/tools/conductor-pocket/SECURITY.md b/tools/conductor-pocket/SECURITY.md index 00da8cc7..8ba7d3fb 100644 --- a/tools/conductor-pocket/SECURITY.md +++ b/tools/conductor-pocket/SECURITY.md @@ -17,8 +17,8 @@ Pocket. They remain inside the processes and profiles Conductor already owns. - Alex's logged-in macOS account and its local files. - The installed Conductor application. - The loopback interface. -- The authenticated Tailscale Serve proxy after its identity header is - matched to the paired identity. +- The dedicated Pocket Tailscale node and Serve proxy after its identity + header is matched to the paired identity. - The iPhone platform authenticator after WebAuthn user verification. ### Not trusted @@ -38,8 +38,28 @@ Pocket. They remain inside the processes and profiles Conductor already owns. The HTTP server refuses non-loopback binds in config validation. It validates the Host header and accepts only the configured tailnet host or explicit loopback development hosts. Tailscale Serve strips spoofed identity headers -before adding authenticated values. The installer refuses all nonempty -Funnel configurations and will not replace an existing Serve configuration. +before adding authenticated values. + +Pocket runs a second, user-mode Tailscale node with a separate node key, IP, +MagicDNS name, certificate, state directory, Unix socket, and Serve +configuration. The normal Mac node is never addressed by the formula CLI, and +every sidecar CLI command carries the explicit private socket. The sidecar +uses userspace networking, accepts neither routes nor DNS, advertises no exit +node or subnet, and exposes exactly one tailnet-only HTTPS root proxy to the +loopback relay. The loaded LaunchAgent arguments and Unix-socket owner must +match the audited daemon profile; DNS, route, SSH, web-client, connector, and +advertising preferences fail closed. Funnel and extra handlers fail +validation. + +The one-time sidecar authorization URL is read back from that audited private +socket, accepted only when it is the canonical +`https://login.tailscale.com/a/…` shape, and never persisted by Pocket. It is +opened directly with macOS rather than written to command output unless the +operator explicitly requests `--print-url`. The short-lived CLI helper has +both an internal Tailscale timeout and bounded process cleanup, and Pocket +proves the daemon retained the same request after terminating it. Browser +approval therefore does not depend on a terminal or agent session remaining +alive. ### Authentication @@ -93,11 +113,19 @@ Framing, object embedding, referrers, camera, microphone, location, payment, USB, and serial access are disabled. The UI builds transcript nodes with `textContent`; transcript Markdown is never injected as HTML. -The service worker caches only the app shell and explicitly excludes `/api/`. +The service worker handles only an allowlist of Pocket shell paths, deletes +only `conductor-pocket-shell-*` caches, and never intercepts sibling routes or +`/api/`. Device-local transcript snapshots are bounded and are not rendered until -after Face ID unlock. Revocation instructs the connected client to purge -them; a device that never reconnects remains protected by iOS Data -Protection and the app's Face ID gate. +after Face ID unlock. During an origin migration, the server records the +original device set, disables remote revocation, and accepts only a +version-matched self-sign-out. The phone first writes a persistent origin +tombstone, prevents every Pocket context from reopening its cache, verifies +through the service worker that no other browser window remains, and deletes +Pocket's localStorage keys, IndexedDB, Cache Storage, and root service worker. +Blocked deletion fails visibly and keeps the device enrolled. Only then does +the client send its retirement receipt. A device that never reconnects remains +protected by iOS Data Protection and the app's Face ID gate. ## Residual risks @@ -106,6 +134,11 @@ Protection and the app's Face ID gate. the Mac user account. - macOS Accessibility permission is broad. The relay's Node executable must be trusted and should not be replaced by an untrusted binary. +- The dedicated Tailscale state contains a node private key. Its directory is + `0700`, files are `0600`, and FileVault remains the at-rest protection. +- Tailscale HTTPS certificate names can appear in public certificate + transparency logs. The hostname reveals the service label, not its content + or access. - UI automation depends on Conductor's accessible structure. Version changes fail closed: a missing workspace, session, composer, or enabled Send control produces an error rather than a guessed click. @@ -116,4 +149,5 @@ Protection and the app's Face ID gate. unauthenticated app shell and pairing endpoint, but cannot pair without the high-entropy one-time link and matching identity. - Device-local browser storage cannot be remotely erased while the iPhone is - permanently offline. Revoke plus iOS device security is the recovery path. + permanently offline. iOS device security is the recovery boundary until + that phone reconnects and completes its own retirement purge. diff --git a/tools/conductor-pocket/package-lock.json b/tools/conductor-pocket/package-lock.json index 032da1f2..7766e32d 100644 --- a/tools/conductor-pocket/package-lock.json +++ b/tools/conductor-pocket/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ovo/conductor-pocket", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ovo/conductor-pocket", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@simplewebauthn/server": "13.3.2" }, diff --git a/tools/conductor-pocket/package.json b/tools/conductor-pocket/package.json index ad57aec3..01a5ab0e 100644 --- a/tools/conductor-pocket/package.json +++ b/tools/conductor-pocket/package.json @@ -1,17 +1,20 @@ { "name": "@ovo/conductor-pocket", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "engines": { "node": ">=22.5.0" }, "scripts": { - "check": "node --check src/server.mjs && node --check src/security.mjs && node --check src/conductor-db.mjs && node --check src/accessibility.mjs && node --check src/tailscale-config.mjs && node --check scripts/install-relay.mjs && node --check public/app.js && node --check public/service-worker.js && npm test", + "check": "node --check src/cli.mjs && node --check src/server.mjs && node --check src/security.mjs && node --check src/conductor-db.mjs && node --check src/accessibility.mjs && node --check src/tailscale-config.mjs && node --check src/operation-lock.mjs && node --check scripts/lib/sidecar.mjs && node --check scripts/lib/login.mjs && node --check scripts/lib/cutover.mjs && node --check scripts/install-relay.mjs && node --check scripts/install-sidecar.mjs && node --check scripts/login-sidecar.mjs && node --check scripts/cutover-sidecar.mjs && node --check public/delivery-receipts.js && node --check public/app.js && node --check public/service-worker.js && npm test", "doctor": "node --no-warnings=ExperimentalWarning src/cli.mjs doctor", "install:relay": "node --no-warnings=ExperimentalWarning scripts/install-relay.mjs", "pair": "node --no-warnings=ExperimentalWarning src/cli.mjs pair", "setup": "node --no-warnings=ExperimentalWarning src/cli.mjs setup", + "sidecar:cutover": "node --no-warnings=ExperimentalWarning scripts/cutover-sidecar.mjs", + "sidecar:install": "node --no-warnings=ExperimentalWarning scripts/install-sidecar.mjs", + "sidecar:login": "node --no-warnings=ExperimentalWarning scripts/login-sidecar.mjs", "start": "node --no-warnings=ExperimentalWarning src/cli.mjs serve", "start:ui-fixture": "node --no-warnings=ExperimentalWarning scripts/ui-fixture.mjs", "test": "node --no-warnings=ExperimentalWarning --test" diff --git a/tools/conductor-pocket/public/app.js b/tools/conductor-pocket/public/app.js index c54cc9d2..539fd096 100644 --- a/tools/conductor-pocket/public/app.js +++ b/tools/conductor-pocket/public/app.js @@ -1,9 +1,18 @@ +import { reconcileDeliveryReceipts } from './delivery-receipts.js?v=0.2.0-delivery-fix-2'; + const app = document.querySelector('#app'); const overlayRoot = document.querySelector('#overlay-root'); const announcer = document.querySelector('#announcer'); const AWAY_LOCK_MS = 5 * 60 * 1000; const ACTIVITY_HEARTBEAT_MS = 60 * 1000; const HIDDEN_AT_KEY = 'cp:hidden-at:v1'; +const CLIENT_VERSION = '0.2.0'; +const SHELL_CACHE_PREFIX = 'conductor-pocket-shell-'; +const CACHE_PURGE_CHANNEL = 'conductor-pocket-cache-purge-v1'; +const ORIGIN_RETIRED_KEY = 'cp:origin-retired:v1'; +const PENDING_DELIVERIES_KEY = 'pending-deliveries:v1'; +const DELIVERY_RECOVERY_MS = 27_000; +const DELIVERY_STATUS_REQUEST_MS = 2_500; const state = { auth: null, @@ -386,8 +395,7 @@ async function bootstrap() { else renderLock(); } catch (error) { if (error.status === 401 || error.code === 'device_revoked') { - await purgeLocalData(); - renderSignedOut(); + await purgeThenRenderSignedOut(); } else { renderConnectionGate(error.code); } @@ -452,6 +460,15 @@ function renderLock({ errorMessage = '' } = {}) { function renderSignedOut() { stopEvents(); + state.auth = null; + state.csrfToken = null; + state.workspaces = []; + state.recentSessions = []; + state.sessionsByWorkspace.clear(); + state.messagesBySession.clear(); + state.cursorsBySession.clear(); + state.optimistic = []; + state.seenMessageIds.clear(); gateView({ mark: 'lock', title: 'This device was signed out', @@ -464,19 +481,58 @@ function renderSignedOut() { }); } +function isLocalPurgeFailure(error) { + return /transcript_cache_delete|service_worker_retirement|other_pocket_window|origin_retired|cache/i.test( + error?.message || '', + ); +} + +function renderLocalPurgeFailure(retry) { + stopEvents(); + gateView({ + mark: 'warn', + title: 'Local data was not erased', + body: + 'Close every other Pocket window on this phone, then retry. This device stays enrolled until the cleanup finishes.', + action: node('button', { + className: 'primary-button', + type: 'button', + text: 'Retry cleanup', + on: { click: retry }, + }), + }); +} + +async function purgeThenRenderSignedOut() { + try { + await purgeLocalData(); + renderSignedOut(); + } catch { + renderLocalPurgeFailure(() => purgeThenRenderSignedOut()); + } +} + function renderConnectionGate(code) { + const upgradeRequired = code === 'retirement_client_upgrade_required'; gateView({ - mark: 'wifiOff', - title: 'Mac unreachable', + mark: upgradeRequired ? 'refresh' : 'wifiOff', + title: upgradeRequired ? 'Pocket must refresh' : 'Mac unreachable', body: - code === 'tailscale_identity_required' + upgradeRequired + ? 'Fully close Pocket, reopen it while online, then sign out again. The old app cannot retire this phone.' + : code === 'tailscale_identity_required' ? 'Connect this phone to your private Tailscale network, then try again.' : 'Conductor Pocket could not reach the relay on your Mac.', action: node('button', { className: 'primary-button', type: 'button', - text: 'Try again', - on: { click: () => bootstrap() }, + text: upgradeRequired ? 'Reload Pocket' : 'Try again', + on: { + click: () => { + if (upgradeRequired) location.reload(); + else bootstrap(); + }, + }, }), }); } @@ -554,8 +610,21 @@ function saveDraft(sessionId, value) { } let cacheDatabasePromise; +let originRetired = localStorage.getItem(ORIGIN_RETIRED_KEY) === '1'; +const cachePurgeChannel = + 'BroadcastChannel' in window + ? new BroadcastChannel(CACHE_PURGE_CHANNEL) + : null; +let serviceWorkerRegistrationPromise = null; function cacheDatabase() { + if ( + originRetired || + localStorage.getItem(ORIGIN_RETIRED_KEY) === '1' + ) { + originRetired = true; + return Promise.reject(new Error('origin_retired')); + } if (!cacheDatabasePromise) { cacheDatabasePromise = new Promise((resolve, reject) => { const open = indexedDB.open('conductor-pocket-v1', 1); @@ -571,6 +640,26 @@ function cacheDatabase() { return cacheDatabasePromise; } +async function closeCacheDatabase() { + try { + const database = await cacheDatabasePromise; + database?.close(); + } catch { + // A failed cache open has nothing to close. + } + cacheDatabasePromise = null; +} + +cachePurgeChannel?.addEventListener('message', (event) => { + if (event.data?.type === 'retire-origin') { + originRetired = true; + stopEvents(); + void closeCacheDatabase(); + } else if (event.data?.type === 'close-transcript-database') { + void closeCacheDatabase(); + } +}); + async function cacheGet(key) { try { const database = await cacheDatabase(); @@ -599,23 +688,243 @@ async function cacheSet(key, value) { } } +async function cacheSetRequired(key, value) { + const database = await cacheDatabase(); + await new Promise((resolve, reject) => { + const transaction = database.transaction('snapshots', 'readwrite'); + transaction.objectStore('snapshots').put(value, key); + transaction.oncomplete = resolve; + transaction.onerror = () => + reject(transaction.error || new Error('cache_write_failed')); + transaction.onabort = () => + reject(transaction.error || new Error('cache_write_aborted')); + }); +} + +function validPersistedKey(value) { + return ( + typeof value === 'string' && + value.length >= 16 && + value.length <= 100 && + /^[A-Za-z0-9_-]+$/.test(value) + ); +} + +function sanitizePendingDelivery(value) { + if ( + !value || + typeof value !== 'object' || + value.kind !== 'optimistic' || + typeof value.id !== 'string' || + typeof value.sessionId !== 'string' || + value.sessionId.length === 0 || + value.sessionId.length > 200 || + typeof value.text !== 'string' || + value.text.length > 16 * 1024 || + !validPersistedKey(value.idempotencyKey) + ) { + return null; + } + const delivery = new Set([ + 'delivering', + 'confirming', + 'delivered', + 'failed', + ]).has(value.delivery) + ? value.delivery + : 'confirming'; + return { + id: value.id, + idempotencyKey: value.idempotencyKey, + activeDeliveryKey: validPersistedKey(value.activeDeliveryKey) + ? value.activeDeliveryKey + : value.idempotencyKey, + replaceIdempotencyKey: validPersistedKey(value.replaceIdempotencyKey) + ? value.replaceIdempotencyKey + : null, + kind: 'optimistic', + sessionId: value.sessionId, + text: value.text, + delivery, + createdAt: + typeof value.createdAt === 'string' + ? value.createdAt + : new Date().toISOString(), + deliveredAt: + typeof value.deliveredAt === 'string' ? value.deliveredAt : null, + receiptBaselineCursor: Number.isSafeInteger( + value.receiptBaselineCursor, + ) + ? value.receiptBaselineCursor + : null, + receiptRowId: Number.isSafeInteger(value.receiptRowId) + ? value.receiptRowId + : null, + retrySafe: value.retrySafe === true, + errorCode: + typeof value.errorCode === 'string' ? value.errorCode : null, + replaceDraft: value.replaceDraft === true, + macDraft: + typeof value.macDraft === 'string' + ? value.macDraft.slice(0, 16 * 1024) + : null, + }; +} + +function pendingDeliverySnapshot() { + return state.optimistic + .map(sanitizePendingDelivery) + .filter(Boolean); +} + +async function persistPendingDeliveries({ required = false } = {}) { + const snapshot = pendingDeliverySnapshot(); + if (required) { + await cacheSetRequired(PENDING_DELIVERIES_KEY, snapshot); + } else { + await cacheSet(PENDING_DELIVERIES_KEY, snapshot); + } +} + +async function restorePendingDeliveries() { + const cached = await cacheGet(PENDING_DELIVERIES_KEY); + state.optimistic = Array.isArray(cached) + ? cached.map(sanitizePendingDelivery).filter(Boolean) + : []; +} + +async function clearTranscriptCache() { + cachePurgeChannel?.postMessage({ type: 'close-transcript-database' }); + await closeCacheDatabase(); + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('transcript_cache_delete_blocked')), + 5_000, + ); + const requestValue = indexedDB.deleteDatabase('conductor-pocket-v1'); + requestValue.onsuccess = () => { + clearTimeout(timeout); + resolve(); + }; + requestValue.onerror = () => { + clearTimeout(timeout); + reject(requestValue.error || new Error('transcript_cache_delete_failed')); + }; + requestValue.onblocked = () => { + // Another Pocket window receives the BroadcastChannel close request. + // If it does not release the database, the timeout fails closed. + }; + }); +} + +async function assertOnlyRetiringWindow() { + if (!('serviceWorker' in navigator)) return; + const registration = serviceWorkerRegistrationPromise + ? await serviceWorkerRegistrationPromise + : await navigator.serviceWorker.ready; + await registration.update(); + const candidate = registration.installing || registration.waiting; + if (candidate && candidate.state !== 'activated') { + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('service_worker_retirement_timeout')), + 4_000, + ); + const onStateChange = () => { + if (candidate.state === 'activated') { + clearTimeout(timeout); + candidate.removeEventListener('statechange', onStateChange); + resolve(); + } else if (candidate.state === 'redundant') { + clearTimeout(timeout); + candidate.removeEventListener('statechange', onStateChange); + reject(new Error('service_worker_retirement_unavailable')); + } + }; + candidate.addEventListener('statechange', onStateChange); + onStateChange(); + }); + } + const worker = + registration.active || registration.waiting || registration.installing; + if (!worker) throw new Error('service_worker_retirement_unavailable'); + const requestId = crypto.randomUUID + ? crypto.randomUUID() + : randomIdempotencyKey(); + const response = await new Promise((resolve, reject) => { + const channel = new MessageChannel(); + const timeout = setTimeout( + () => reject(new Error('service_worker_retirement_timeout')), + 2_000, + ); + channel.port1.onmessage = (event) => { + if ( + event.data?.type === 'retirement-window-count' && + event.data?.requestId === requestId + ) { + clearTimeout(timeout); + resolve(event.data); + } + }; + worker.postMessage( + { type: 'retirement-window-count', requestId }, + [channel.port2], + ); + }); + if (response.count !== 1) { + throw new Error('other_pocket_window_open'); + } +} + async function purgeLocalData() { + originRetired = true; + localStorage.setItem(ORIGIN_RETIRED_KEY, '1'); + cachePurgeChannel?.postMessage({ type: 'retire-origin' }); + await assertOnlyRetiringWindow(); localStorage.removeItem('cp:last-route:v1'); localStorage.removeItem('cp:drafts:v1'); localStorage.removeItem(HIDDEN_AT_KEY); - try { - const database = await cacheDatabasePromise; - database?.close(); - } catch { - // A failed cache open has nothing to close. + await clearTranscriptCache(); + if ('caches' in window) { + const cacheNames = await caches.keys(); + await Promise.all( + cacheNames + .filter((name) => name.startsWith(SHELL_CACHE_PREFIX)) + .map((name) => caches.delete(name)), + ); + } + if ('serviceWorker' in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all( + registrations.map(async (registration) => { + const worker = + registration.active || registration.waiting || registration.installing; + if (!worker) return; + const scriptUrl = new URL(worker.scriptURL); + if ( + scriptUrl.origin === location.origin && + scriptUrl.pathname === '/service-worker.js' + ) { + await registration.unregister(); + } + }), + ); + const remaining = await navigator.serviceWorker.getRegistrations(); + if ( + remaining.some((registration) => { + const worker = + registration.active || registration.waiting || registration.installing; + if (!worker) return false; + const scriptUrl = new URL(worker.scriptURL); + return ( + scriptUrl.origin === location.origin && + scriptUrl.pathname === '/service-worker.js' + ); + }) + ) { + throw new Error('service_worker_retirement_failed'); + } } - cacheDatabasePromise = null; - await new Promise((resolve) => { - const requestValue = indexedDB.deleteDatabase('conductor-pocket-v1'); - requestValue.onsuccess = resolve; - requestValue.onerror = resolve; - requestValue.onblocked = resolve; - }); } async function startApplication() { @@ -623,6 +932,7 @@ async function startApplication() { state.hiddenAt = null; state.route = loadRoute(); state.workspacesLoaded = false; + await restorePendingDeliveries(); ensureShell(); updateRoutePanels(); const cachedWorkspaces = await cacheGet('workspaces'); @@ -634,6 +944,7 @@ async function startApplication() { await loadRecentSessions(); await restoreRoute(); startEvents(); + void recoverPendingDeliveries(); } async function restoreRoute() { @@ -1168,7 +1479,7 @@ async function openSession(sessionId, { workspaceId = state.route.workspaceId, p } async function refreshMessages(sessionId, { full = false } = {}) { - if (!sessionId) return; + if (!sessionId) return []; const cursor = full ? 0 : state.cursorsBySession.get(sessionId) || 0; try { const data = await request( @@ -1178,14 +1489,16 @@ async function refreshMessages(sessionId, { full = false } = {}) { const messages = full ? data.messages : [...existing, ...data.messages]; state.messagesBySession.set(sessionId, dedupeMessages(messages)); state.cursorsBySession.set(sessionId, data.cursor); - reconcileOptimistic(sessionId); + const reconciled = reconcileOptimistic(sessionId); await cacheSet(`messages:${sessionId}`, { cursor: data.cursor, messages: state.messagesBySession.get(sessionId).slice(-50), }); if (state.route.sessionId === sessionId) renderTranscript(); + return reconciled; } catch (error) { handleRuntimeError(error); + return []; } } @@ -1199,15 +1512,16 @@ function dedupeMessages(messages) { } function reconcileOptimistic(sessionId) { - const serverMessages = state.messagesBySession.get(sessionId) || []; - for (const optimistic of state.optimistic.filter( - (item) => item.sessionId === sessionId && item.delivery === 'delivered', - )) { - const found = serverMessages.some( - (message) => message.kind === 'user' && message.text === optimistic.text, - ); - if (found) state.optimistic = state.optimistic.filter((item) => item !== optimistic); + const result = reconcileDeliveryReceipts( + state.optimistic, + sessionId, + state.cursorsBySession.get(sessionId), + ); + state.optimistic = result.remaining; + if (result.reconciled.length > 0) { + void persistPendingDeliveries(); } + return result.reconciled; } function currentSession() { @@ -1339,18 +1653,34 @@ function renderMessage(message, toolResults) { const meta = node('div', { className: 'message-meta' }); if (message.delivery === 'delivering') { meta.textContent = 'Delivering…'; + } else if (message.delivery === 'confirming') { + meta.textContent = 'Checking delivery…'; } else if (message.delivery === 'failed') { meta.classList.add('failed'); + const retrySafe = message.retrySafe === true; meta.append( icon('warn'), - document.createTextNode('Failed to deliver · '), + document.createTextNode( + retrySafe ? 'Failed to deliver · ' : 'Delivery unconfirmed · ', + ), node('button', { className: 'message-retry', type: 'button', - text: 'Retry', - on: { click: () => retryMessage(message) }, + text: retrySafe ? 'Retry' : 'Check', + on: { + click: () => + retrySafe ? retryMessage(message) : checkDelivery(message), + }, }), ); + } else if ( + message.kind === 'optimistic' && + message.delivery === 'delivered' + ) { + meta.append( + icon('checkDouble'), + document.createTextNode('Delivered · Syncing…'), + ); } else if (message.queued) { meta.textContent = 'Queued'; } else { @@ -1468,6 +1798,15 @@ async function sendCurrentMessage() { createdAt: new Date().toISOString(), }; state.optimistic.push(optimistic); + try { + await persistPendingDeliveries({ required: true }); + } catch { + state.optimistic = state.optimistic.filter( + (item) => item !== optimistic, + ); + announce('Message stayed in your draft because secure delivery storage was unavailable'); + return; + } field.value = ''; saveDraft(sessionId, ''); state.shell.composer.resize(); @@ -1486,6 +1825,16 @@ async function deliverOptimistic( const deliveryKey = replaceDraft ? optimistic.replaceIdempotencyKey : optimistic.idempotencyKey; + optimistic.activeDeliveryKey = deliveryKey; + try { + await persistPendingDeliveries({ required: true }); + } catch { + optimistic.delivery = 'failed'; + optimistic.errorCode = 'secure_delivery_storage_unavailable'; + optimistic.retrySafe = true; + renderTranscript(); + return; + } try { const result = await fetch( `/api/sessions/${encodeURIComponent(optimistic.sessionId)}/messages`, @@ -1512,10 +1861,11 @@ async function deliverOptimistic( error.code = payload.error?.code || `http_${result.status}`; error.status = result.status; error.draft = payload.error?.draft; + error.retrySafe = payload.error?.retrySafe === true; throw error; } - optimistic.delivery = 'delivered'; - optimistic.deliveredAt = payload.deliveredAt; + applyDeliveryReceipt(optimistic, payload); + await persistPendingDeliveries(); state.connectionProbe = { sendPath: true, capabilities: { send: true }, @@ -1524,21 +1874,158 @@ async function deliverOptimistic( announce('Message delivered'); setTimeout(() => refreshMessages(optimistic.sessionId, { full: true }), 120); } catch (error) { - optimistic.delivery = 'failed'; optimistic.errorCode = error.code; - renderTranscript(); if (error.code === 'draft_conflict') { + optimistic.delivery = 'failed'; + optimistic.retrySafe = true; + await persistPendingDeliveries(); + renderTranscript(); optimistic.macDraft = error.draft; if (replaceDraft) optimistic.replaceIdempotencyKey = null; optimistic.replaceDraft = false; openDraftConflict(optimistic); + } else if (error.status === 401 || error.status === 423) { + optimistic.delivery = 'failed'; + optimistic.retrySafe = false; + await persistPendingDeliveries(); + renderTranscript(); + handleRuntimeError(error); + } else if (!error.status) { + await checkDelivery(optimistic); + } else { + optimistic.delivery = 'failed'; + optimistic.retrySafe = error.retrySafe === true; + await persistPendingDeliveries(); + renderTranscript(); + } + } +} + +function applyDeliveryReceipt(message, receipt) { + message.delivery = 'delivered'; + message.deliveredAt = receipt.deliveredAt; + message.receiptBaselineCursor = Number.isSafeInteger(receipt.baselineCursor) + ? receipt.baselineCursor + : null; + message.receiptRowId = Number.isSafeInteger(receipt.rowId) + ? receipt.rowId + : null; + message.retrySafe = false; +} + +async function requestDeliveryStatus(message) { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + DELIVERY_STATUS_REQUEST_MS, + ); + try { + const response = await fetch( + `/api/sessions/${encodeURIComponent(message.sessionId)}/delivery-status`, + { + method: 'POST', + credentials: 'same-origin', + cache: 'no-store', + signal: controller.signal, + headers: { + Accept: 'application/json', + 'X-CSRF-Token': state.csrfToken, + 'Idempotency-Key': + message.activeDeliveryKey || message.idempotencyKey, + }, + }, + ); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const error = new Error( + payload.error?.code || `http_${response.status}`, + ); + error.code = payload.error?.code || `http_${response.status}`; + error.status = response.status; + throw error; } - else if (error.status === 401 || error.status === 423) handleRuntimeError(error); + return payload.delivery || { state: 'unknown' }; + } finally { + clearTimeout(timeout); } } +async function checkDelivery(message) { + if (!state.optimistic.includes(message)) return true; + message.delivery = 'confirming'; + message.retrySafe = false; + await persistPendingDeliveries(); + renderTranscript(); + const deadline = Date.now() + DELIVERY_RECOVERY_MS; + let lastError = null; + while (Date.now() < deadline) { + try { + const delivery = await requestDeliveryStatus(message); + if (delivery.state === 'delivered') { + applyDeliveryReceipt(message, delivery); + await persistPendingDeliveries(); + renderTranscript(); + announce('Message delivered'); + await refreshMessages(message.sessionId, { full: true }); + return true; + } + if (delivery.state !== 'pending') { + message.delivery = 'failed'; + message.errorCode = + delivery.state === 'failed' + ? delivery.code + : 'delivery_unknown'; + message.retrySafe = + delivery.state === 'failed' && + delivery.retrySafe === true; + await persistPendingDeliveries(); + renderTranscript(); + return false; + } + } catch (error) { + lastError = error; + if (error.status === 401 || error.status === 423) { + message.delivery = 'failed'; + message.errorCode = error.code; + message.retrySafe = false; + await persistPendingDeliveries(); + renderTranscript(); + handleRuntimeError(error); + return false; + } + } + const remaining = deadline - Date.now(); + if (remaining > 0) { + await new Promise((resolve) => + setTimeout(resolve, Math.min(500, remaining)), + ); + } + } + message.delivery = 'failed'; + message.errorCode = lastError?.code || 'delivery_confirmation_timeout'; + message.retrySafe = false; + await persistPendingDeliveries(); + renderTranscript(); + return false; +} + +async function recoverPendingDeliveries() { + await Promise.all( + state.optimistic.map(async (message) => { + if (message.delivery === 'delivered') { + await refreshMessages(message.sessionId, { full: true }); + } else if (!(message.delivery === 'failed' && message.retrySafe)) { + await checkDelivery(message); + } + }), + ); +} + function retryMessage(message) { + if (message.retrySafe !== true) return; message.delivery = 'delivering'; + message.retrySafe = false; + void persistPendingDeliveries(); renderTranscript(); deliverOptimistic(message, { replaceDraft: message.replaceDraft === true }); } @@ -1567,6 +2054,7 @@ function openDraftConflict(message) { on: { click: () => { message.delivery = 'delivering'; + void persistPendingDeliveries(); closeOverlay(); renderTranscript(); deliverOptimistic(message, { @@ -1585,6 +2073,7 @@ function openDraftConflict(message) { state.shell.composer.field.value = message.text; saveDraft(message.sessionId, message.text); state.optimistic = state.optimistic.filter((item) => item !== message); + void persistPendingDeliveries(); closeOverlay(); renderTranscript(); }, @@ -1623,8 +2112,7 @@ function startEvents() { // Keep the locked fallback. } if (code === 'device_revoked' || code === 'authentication_required') { - await purgeLocalData(); - renderSignedOut(); + await purgeThenRenderSignedOut(); } else { renderLock(); } @@ -1674,9 +2162,11 @@ function renderConnectionState() { function handleRuntimeError(error) { if (error.status === 401 || error.code === 'device_revoked') { - purgeLocalData().finally(renderSignedOut); + void purgeThenRenderSignedOut(); } else if (error.status === 423 || error.code === 'device_locked') { renderLock(); + } else if (error.code === 'retirement_client_upgrade_required') { + renderConnectionGate(error.code); } } @@ -1883,7 +2373,7 @@ async function openSecurity() { settingsRow({ iconName: 'bolt', title: 'Conductor Pocket', - subtitle: `pocket ${connection.relayVersion || 'unknown'} · private relay`, + subtitle: `relay ${connection.relayVersion || 'unknown'} · client ${CLIENT_VERSION}`, }), ); content.append(appSection.root); @@ -1944,10 +2434,19 @@ function confirmClearCache() { text: 'Clear', on: { click: async () => { - await purgeLocalData(); - state.messagesBySession.clear(); - closeOverlay(); - await startApplication(); + try { + await clearTranscriptCache(); + state.messagesBySession.clear(); + closeOverlay(); + await startApplication(); + } catch (error) { + if (isLocalPurgeFailure(error)) { + closeOverlay(); + renderLocalPurgeFailure(() => confirmClearCache()); + } else { + handleRuntimeError(error); + } + } }, }, }), @@ -1979,18 +2478,36 @@ function confirmRevoke(device) { on: { click: async () => { try { + const currentDevice = device.id === state.auth?.device?.id; + if (currentDevice) await purgeLocalData(); const result = await request( `/api/devices/${encodeURIComponent(device.id)}/revoke`, - { method: 'POST', body: {}, csrf: true }, + { + method: 'POST', + body: currentDevice + ? { + clientVersion: CLIENT_VERSION, + localPurgeCompleted: true, + } + : {}, + csrf: true, + }, ); if (result.currentDevice) { - await purgeLocalData(); renderSignedOut(); } else { openSecurity(); } } catch (error) { - handleRuntimeError(error); + if ( + device.id === state.auth?.device?.id && + isLocalPurgeFailure(error) + ) { + closeOverlay(); + renderLocalPurgeFailure(() => confirmRevoke(device)); + } else { + handleRuntimeError(error); + } } }, }, @@ -2090,7 +2607,9 @@ window.addEventListener('pageshow', () => { }); if ('serviceWorker' in navigator) { - navigator.serviceWorker.register('/service-worker.js').catch(() => {}); + serviceWorkerRegistrationPromise = + navigator.serviceWorker.register('/service-worker.js'); + serviceWorkerRegistrationPromise.catch(() => {}); } const pairingCode = new URLSearchParams(location.hash.slice(1)).get('pair'); diff --git a/tools/conductor-pocket/public/delivery-receipts.js b/tools/conductor-pocket/public/delivery-receipts.js new file mode 100644 index 00000000..93a33117 --- /dev/null +++ b/tools/conductor-pocket/public/delivery-receipts.js @@ -0,0 +1,35 @@ +function validCursor(value) { + return Number.isSafeInteger(value) && value >= 0; +} + +export function receiptReachedTranscript(message, transcriptCursor) { + if (message.delivery !== 'delivered' || !validCursor(transcriptCursor)) { + return false; + } + if (validCursor(message.receiptRowId)) { + return transcriptCursor >= message.receiptRowId; + } + if (validCursor(message.receiptBaselineCursor)) { + return transcriptCursor > message.receiptBaselineCursor; + } + return false; +} + +export function reconcileDeliveryReceipts( + optimisticMessages, + sessionId, + transcriptCursor, +) { + const reconciled = []; + const remaining = optimisticMessages.filter((message) => { + if ( + message.sessionId !== sessionId || + !receiptReachedTranscript(message, transcriptCursor) + ) { + return true; + } + reconciled.push(message); + return false; + }); + return { remaining, reconciled }; +} diff --git a/tools/conductor-pocket/public/index.html b/tools/conductor-pocket/public/index.html index 5135b83b..00359e0f 100644 --- a/tools/conductor-pocket/public/index.html +++ b/tools/conductor-pocket/public/index.html @@ -13,7 +13,7 @@ - + "\''), 'a&<b>"''); +}); + +test('the sidecar refuses Tailscale builds older than the security floor', () => { + assert.equal(versionAtLeast('1.98.9', '1.98.9'), true); + assert.equal(versionAtLeast('1.100.0', '1.98.9'), true); + assert.equal(versionAtLeast('1.98.8', '1.98.9'), false); + assert.equal(versionAtLeast('unknown', '1.98.9'), false); + assert.equal( + statusVersion({ Version: '1.98.9-t4fb758c39-g200941d74' }), + '1.98.9', + ); + assert.equal( + assertSupportedStatusVersion({ + Version: '1.98.9-t4fb758c39-g200941d74', + }), + '1.98.9', + ); + assert.throws( + () => assertSupportedStatusVersion({ Version: '1.98.8-old' }), + /running Tailscale daemon/, + ); +}); + +test('installer waits for a real socket-scoped daemon response', async () => { + let reads = 0; + const status = await waitForSidecarResponse({ + readStatus: async () => { + reads += 1; + if (reads < 3) throw new Error('connection refused'); + return { BackendState: 'NeedsLogin' }; + }, + delayMs: 0, + sleep: async () => {}, + }); + assert.equal(reads, 3); + assert.equal(status.BackendState, 'NeedsLogin'); +}); + +test('sidecar login accepts only an exact Tailscale HTTPS authorization URL', () => { + assert.equal( + validatedSidecarAuthUrl('https://login.tailscale.com/a/Abc_123-xyz'), + 'https://login.tailscale.com/a/Abc_123-xyz', + ); + assert.equal(validatedSidecarAuthUrl(''), null); + for (const value of [ + 'http://login.tailscale.com/a/abcdef', + 'https://login.tailscale.com.evil.example/a/abcdef', + 'https://user@login.tailscale.com/a/abcdef', + 'https://login.tailscale.com:443/a/abcdef', + 'https://login.tailscale.com:444/a/abcdef', + ' https://login.tailscale.com/a/abcdef', + 'https://login.tailscale.com/a/abcdef?continue=evil', + 'https://login.tailscale.com/a/abcdef#fragment', + 'https://login.tailscale.com/a/abcdef%2Fextra', + 'https://login.tailscale.com/a/abcdef/extra', + 'https://login.tailscale.com/admin/abcdef', + ]) { + assert.throws( + () => validatedSidecarAuthUrl(value), + /untrusted login URL/, + ); + } +}); + +test('sidecar login returns once the daemon owns a pending authorization', async () => { + const statuses = [ + { BackendState: 'NeedsLogin', AuthURL: '' }, + { + BackendState: 'NeedsLogin', + AuthURL: 'https://login.tailscale.com/a/abcdef123456', + }, + ]; + const outcome = await waitForSidecarLoginOutcome({ + readStatus: async () => statuses.shift(), + pollDelayMs: 0, + sleep: async () => {}, + }); + assert.equal( + outcome.authUrl, + 'https://login.tailscale.com/a/abcdef123456', + ); + assert.equal(outcome.status.BackendState, 'NeedsLogin'); +}); + +test('sidecar login recognizes approval without requiring the helper process', async () => { + const outcome = await waitForSidecarLoginOutcome({ + readStatus: async () => ({ BackendState: 'Running', AuthURL: '' }), + pollDelayMs: 0, + sleep: async () => {}, + }); + assert.equal(outcome.authUrl, null); + assert.equal(outcome.status.BackendState, 'Running'); +}); + +test('sidecar login deadline bounds a stalled status reader', async () => { + const startedAt = Date.now(); + await assert.rejects( + waitForSidecarLoginOutcome({ + readStatus: async () => new Promise(() => {}), + deadlineMs: 20, + statusTimeoutMs: 5, + pollDelayMs: 1, + }), + /stopped answering during login/, + ); + assert.ok(Date.now() - startedAt < 200); +}); + +test('installer removes only a socket proven stale by a failed probe', async (context) => { + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), 'conductor-pocket-socket-'), + ); + context.after(() => fs.rm(directory, { recursive: true, force: true })); + const socketPath = path.join(directory, 'tailscaled.sock'); + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, resolve); + }); + context.after( + () => + new Promise((resolve) => { + server.close(resolve); + }), + ); + + assert.equal( + await removeVerifiedStaleSocket(socketPath, { + probe: async () => { + const error = new Error('connection refused'); + throw error; + }, + }), + true, + ); + await assert.rejects(fs.lstat(socketPath), (error) => error.code === 'ENOENT'); +}); + +test('installer refuses to unlink a socket that still answers', async () => { + await assert.rejects( + removeVerifiedStaleSocket('/verified/socket', { + lstat: async () => ({ isSocket: () => true }), + probe: async () => ({ BackendState: 'Running' }), + unlink: async () => { + throw new Error('must not unlink'); + }, + }), + /still answers/, + ); +}); diff --git a/tools/conductor-pocket/test/tailscale-config.test.mjs b/tools/conductor-pocket/test/tailscale-config.test.mjs index c5103684..d39bd11b 100644 --- a/tools/conductor-pocket/test/tailscale-config.test.mjs +++ b/tools/conductor-pocket/test/tailscale-config.test.mjs @@ -1,8 +1,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { - assertEmptyTailscaleConfig, + assertLockedSidecarPrefs, + assertNoFunnel, assertPrivateServeStatus, + assertRootRemovedWithHandlersPreserved, + assertSameTailnet, + expectedAfterPocketRootRemoval, + pocketRootState, + runningTailscaleIdentity, } from '../src/tailscale-config.mjs'; const expected = { @@ -41,17 +47,273 @@ test('installer accepts only the exact private HTTPS loopback proxy', () => { assert.throws( () => assertPrivateServeStatus( - { ...expected, AllowFunnel: true }, + { + ...expected, + AllowFunnel: { 'mac.example.ts.net:443': true }, + }, { rpId: 'mac.example.ts.net', port: 4317 }, ), /Funnel/, ); + assert.throws( + () => + assertPrivateServeStatus( + { + ...expected, + Web: { + ...expected.Web, + 'mac.example.ts.net:443': { + Handlers: { + ...expected.Web['mac.example.ts.net:443'].Handlers, + '/text': { Text: 'unexpected' }, + }, + }, + }, + }, + { rpId: 'mac.example.ts.net', port: 4317 }, + ), + /expected private HTTPS root proxy/, + ); + assert.throws( + () => + assertPrivateServeStatus( + { + ...expected, + TCP: { + ...expected.TCP, + 8443: { TCPForward: '127.0.0.1:8443' }, + }, + }, + { rpId: 'mac.example.ts.net', port: 4317 }, + ), + /expected private HTTPS root proxy/, + ); +}); + +test('Funnel detection accepts Serve state but rejects any enabled Funnel map', () => { + assert.doesNotThrow(() => assertNoFunnel(expected)); + assert.doesNotThrow(() => assertNoFunnel({ ...expected, AllowFunnel: {} })); + assert.throws( + () => + assertNoFunnel({ + ...expected, + AllowFunnel: { 'mac.example.ts.net:443': true }, + }), + /Funnel/, + ); +}); + +test('sidecar status requires a running node with a real DNS name and address', () => { + assert.deepEqual( + runningTailscaleIdentity({ + BackendState: 'Running', + Self: { + DNSName: 'Conductor-Pocket.example.ts.net.', + TailscaleIPs: ['100.64.0.2'], + }, + }), + { + dnsName: 'conductor-pocket.example.ts.net', + addresses: ['100.64.0.2'], + }, + ); + assert.throws( + () => + runningTailscaleIdentity({ + BackendState: 'NeedsLogin', + Self: {}, + }), + /not connected/, + ); +}); + +test('sidecar and Mac identities must belong to the same tailnet', () => { + const sidecar = { + CurrentTailnet: { MagicDNSSuffix: 'tail.example.ts.net' }, + }; + const main = { + MagicDNSSuffix: 'tail.example.ts.net', + }; + assert.doesNotThrow(() => assertSameTailnet(sidecar, main)); + assert.throws( + () => + assertSameTailnet(sidecar, { + MagicDNSSuffix: 'other.example.ts.net', + }), + /not authenticated to the Mac tailnet/, + ); +}); + +test('sidecar preferences refuse route, DNS, SSH, web, and advertising features', () => { + const locked = { + WantRunning: true, + LoggedOut: false, + RouteAll: false, + CorpDNS: false, + RunSSH: false, + RunWebClient: false, + ShieldsUp: false, + PostureChecking: false, + AdvertiseRoutes: [], + AdvertiseServices: [], + AdvertiseTags: [], + DriveShares: [], + ExitNodeID: '', + ExitNodeIP: '', + ExitNodeAllowLANAccess: false, + AppConnector: { Advertise: false }, + }; + assert.doesNotThrow(() => assertLockedSidecarPrefs(locked)); + assert.doesNotThrow(() => + assertLockedSidecarPrefs({ ...locked, AdvertiseRoutes: null }), + ); + assert.throws( + () => + assertLockedSidecarPrefs({ + WantRunning: true, + LoggedOut: false, + RouteAll: false, + CorpDNS: false, + }), + /locked-down Pocket profile/, + ); + for (const unsafe of [ + { RouteAll: true }, + { CorpDNS: true }, + { RunSSH: true }, + { RunWebClient: true }, + { AdvertiseRoutes: ['10.0.0.0/8'] }, + { ExitNodeIP: '100.64.0.1' }, + { AppConnector: { Advertise: true } }, + ]) { + assert.throws( + () => assertLockedSidecarPrefs({ ...locked, ...unsafe }), + new RegExp( + `locked-down Pocket profile: ${Object.keys(unsafe)[0].replace( + 'AppConnector', + 'AppConnector.Advertise', + )}`, + ), + ); + } }); -test('installer refuses any pre-existing Serve or Funnel state', () => { - assert.doesNotThrow(() => assertEmptyTailscaleConfig({}, 'Serve')); +test('scoped root removal must preserve every unrelated Serve handler', () => { + const before = { + TCP: { + 443: { HTTPS: true }, + 2222: { TCPForward: '127.0.0.1:22' }, + }, + Web: { + 'mac.example.ts.net:443': { + Handlers: { + '/': { Proxy: 'http://127.0.0.1:4317' }, + '/other': { Proxy: 'http://127.0.0.1:4173' }, + }, + }, + 'other.example.ts.net:443': { + Handlers: { + '/status': { Text: 'ok' }, + }, + }, + }, + Foreground: { SessionID: 'preserve-me' }, + }; + const after = { + TCP: { + 443: { HTTPS: true }, + 2222: { TCPForward: '127.0.0.1:22' }, + }, + Web: { + 'mac.example.ts.net:443': { + Handlers: { + '/other': { Proxy: 'http://127.0.0.1:4173' }, + }, + }, + 'other.example.ts.net:443': { + Handlers: { + '/status': { Text: 'ok' }, + }, + }, + }, + Foreground: { SessionID: 'preserve-me' }, + }; + assert.doesNotThrow(() => + assertRootRemovedWithHandlersPreserved(before, after, { + rpId: 'mac.example.ts.net', + port: 4317, + }), + ); assert.throws( - () => assertEmptyTailscaleConfig({ Web: {} }, 'Serve'), - /Refusing to overwrite/, + () => + assertRootRemovedWithHandlersPreserved( + before, + { + ...after, + TCP: { + 443: { HTTPS: true }, + }, + }, + { rpId: 'mac.example.ts.net', port: 4317 }, + ), + /unrelated Serve state/, + ); +}); + +test('root classification never treats a foreign main-node root as Pocket', () => { + assert.equal( + pocketRootState(expected, { + rpId: 'mac.example.ts.net', + port: 4317, + }), + 'pocket', + ); + assert.equal( + pocketRootState( + { + ...expected, + Web: { + 'mac.example.ts.net:443': { + Handlers: { + '/': { Proxy: 'http://127.0.0.1:9999' }, + }, + }, + }, + }, + { rpId: 'mac.example.ts.net', port: 4317 }, + ), + 'foreign', + ); + assert.equal( + pocketRootState( + { + TCP: { 443: { HTTPS: true } }, + Web: { + 'mac.example.ts.net:443': { + Handlers: { + '/other': { Proxy: 'http://127.0.0.1:4173' }, + }, + }, + }, + }, + { rpId: 'mac.example.ts.net', port: 4317 }, + ), + 'absent', + ); +}); + +test('removing a lone Pocket root permits only expected empty-listener cleanup', () => { + assert.deepEqual( + expectedAfterPocketRootRemoval(expected, { + rpId: 'mac.example.ts.net', + port: 4317, + }), + {}, + ); + assert.doesNotThrow(() => + assertRootRemovedWithHandlersPreserved(expected, {}, { + rpId: 'mac.example.ts.net', + port: 4317, + }), ); });