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, + }), ); });