conductor-remote 1.21.3 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -13,8 +13,8 @@
13
13
  <title>Conductor Remote</title>
14
14
  <!-- Runs before the module bundle so it can catch a stale shell that fails to boot. -->
15
15
  <script src="/self-heal.js"></script>
16
- <script type="module" crossorigin src="/assets/index-lrYsUtTg.js"></script>
17
- <link rel="stylesheet" crossorigin href="/assets/index-W8Wv8MfE.css">
16
+ <script type="module" crossorigin src="/assets/index-BpxkVzXq.js"></script>
17
+ <link rel="stylesheet" crossorigin href="/assets/index-BL-O33Ca.css">
18
18
  <link rel="manifest" href="/manifest.webmanifest"></head>
19
19
  <body>
20
20
  <div id="root"></div>
package/dist/sw.js CHANGED
@@ -1 +1 @@
1
- if(!self.define){let e,i={};const s=(s,n)=>(s=new URL(s+".js",n).href,i[s]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=s,e.onload=i,document.head.appendChild(e)}else e=s,importScripts(s),i()}).then(()=>{let e=i[s];if(!e)throw new Error(`Module ${s} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(i[o])return;let l={};const t=e=>s(e,o),c={module:{uri:o},exports:l,require:t};i[o]=Promise.all(n.map(e=>c[e]||t(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"index.html",revision:"f6a6b88cfb756b0a9ff8c81573287244"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-lrYsUtTg.js",revision:null},{url:"assets/index-W8Wv8MfE.css",revision:null},{url:"apple-touch-icon.png",revision:"2b9301416b880d45d4bb655f2600d1f2"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
1
+ if(!self.define){let e,i={};const s=(s,n)=>(s=new URL(s+".js",n).href,i[s]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=s,e.onload=i,document.head.appendChild(e)}else e=s,importScripts(s),i()}).then(()=>{let e=i[s];if(!e)throw new Error(`Module ${s} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(i[o])return;let l={};const t=e=>s(e,o),c={module:{uri:o},exports:l,require:t};i[o]=Promise.all(n.map(e=>c[e]||t(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"index.html",revision:"f8e519f4a8c664e338315a2129d6f59e"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-BpxkVzXq.js",revision:null},{url:"assets/index-BL-O33Ca.css",revision:null},{url:"apple-touch-icon.png",revision:"2b9301416b880d45d4bb655f2600d1f2"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
@@ -13,6 +13,7 @@ import fs from 'node:fs';
13
13
  import os from 'node:os';
14
14
  import path from 'node:path';
15
15
  import { packageRoot } from "../src/pkg-root.js";
16
+ import { exposeStorePath, magicDnsName, normalizeExposeMode, relayPort, tailscaleBin } from "../src/tailscale.js";
16
17
  import { qrLines } from "./qr.js";
17
18
  const LABEL = 'no.adluna.conductor-remote';
18
19
  const projectDir = packageRoot(import.meta.dirname);
@@ -33,6 +34,8 @@ const FLAG_ENV = {
33
34
  '--write-strategy': 'WRITE_STRATEGY',
34
35
  '--auto-update': 'AUTO_UPDATE',
35
36
  '--auto-update-interval': 'AUTO_UPDATE_INTERVAL_MINUTES',
37
+ '--funnel-watchdog': 'FUNNEL_WATCHDOG',
38
+ '--funnel-watchdog-interval': 'FUNNEL_WATCHDOG_INTERVAL_SECONDS',
36
39
  '--db': 'CONDUCTOR_DB',
37
40
  '--workspaces': 'CONDUCTOR_WORKSPACES'
38
41
  };
@@ -137,6 +140,10 @@ function buildPlist() {
137
140
  envEntries.push(['AUTO_UPDATE', process.env.AUTO_UPDATE]);
138
141
  if (process.env.AUTO_UPDATE_INTERVAL_MINUTES)
139
142
  envEntries.push(['AUTO_UPDATE_INTERVAL_MINUTES', process.env.AUTO_UPDATE_INTERVAL_MINUTES]);
143
+ if (process.env.FUNNEL_WATCHDOG)
144
+ envEntries.push(['FUNNEL_WATCHDOG', process.env.FUNNEL_WATCHDOG]);
145
+ if (process.env.FUNNEL_WATCHDOG_INTERVAL_SECONDS)
146
+ envEntries.push(['FUNNEL_WATCHDOG_INTERVAL_SECONDS', process.env.FUNNEL_WATCHDOG_INTERVAL_SECONDS]);
140
147
  if (process.env.CONDUCTOR_DB)
141
148
  envEntries.push(['CONDUCTOR_DB', process.env.CONDUCTOR_DB]);
142
149
  if (process.env.CONDUCTOR_WORKSPACES)
@@ -207,53 +214,14 @@ function persistPinnedToken() {
207
214
  console.info(` ⚠ could not persist --token (${err instanceof Error ? err.message : err})`);
208
215
  }
209
216
  }
210
- const RELAY_PORT = process.env.RELAY_PORT ?? '8787';
211
- /** Locate the tailscale CLI: PATH first, then the common macOS install locations. Null if absent. */
212
- function tailscaleBin() {
213
- for (const bin of [
214
- 'tailscale',
215
- '/opt/homebrew/bin/tailscale',
216
- '/usr/local/bin/tailscale',
217
- '/Applications/Tailscale.app/Contents/MacOS/Tailscale'
218
- ]) {
219
- try {
220
- execFileSync(bin, ['version'], { stdio: 'pipe' });
221
- return bin;
222
- }
223
- catch {
224
- // try the next candidate
225
- }
226
- }
227
- return null;
228
- }
229
- /** This node's MagicDNS name without the trailing dot, e.g. `mac.taila6dcd6.ts.net`. */
230
- function magicDnsName(bin) {
231
- try {
232
- const out = execFileSync(bin, ['status', '--json'], { encoding: 'utf8', stdio: 'pipe' });
233
- return (JSON.parse(out)?.Self?.DNSName ?? '').replace(/\.$/, '') || null;
234
- }
235
- catch {
236
- return null;
237
- }
238
- }
239
- /** Where the chosen expose mode is persisted so a later bare `yarn deploy` keeps the same posture. */
240
- function exposeStorePath() {
241
- return path.join(os.homedir(), 'Library', 'Application Support', 'conductor-remote', 'expose');
242
- }
243
- function normalizeMode(raw) {
244
- const v = raw?.trim().toLowerCase();
245
- if (v === 'public' || v === 'funnel')
246
- return 'public';
247
- if (v === 'tailnet' || v === 'serve' || v === 'private')
248
- return 'tailnet';
249
- return null;
250
- }
217
+ const RELAY_PORT = relayPort();
251
218
  /**
252
219
  * Resolve the expose mode. Precedence: `EXPOSE` env (public|funnel / tailnet|serve|private) > persisted
253
220
  * choice > 'public' default. An explicit env value is persisted so re-deploys don't silently flip posture.
221
+ * (The read-only counterpart used by the runtime watchdog is readExposeMode() in src/tailscale.ts.)
254
222
  */
255
223
  function resolveExposeMode() {
256
- const fromEnv = normalizeMode(process.env.EXPOSE);
224
+ const fromEnv = normalizeExposeMode(process.env.EXPOSE);
257
225
  if (fromEnv) {
258
226
  try {
259
227
  const file = exposeStorePath();
@@ -268,7 +236,7 @@ function resolveExposeMode() {
268
236
  if (process.env.EXPOSE)
269
237
  console.info(` ⚠ unrecognized EXPOSE=${process.env.EXPOSE} — expected public|tailnet.`);
270
238
  try {
271
- const saved = normalizeMode(fs.readFileSync(exposeStorePath(), 'utf8'));
239
+ const saved = normalizeExposeMode(fs.readFileSync(exposeStorePath(), 'utf8'));
272
240
  if (saved)
273
241
  return saved;
274
242
  }
@@ -105,6 +105,18 @@ function isNewer(candidate, base) {
105
105
  }
106
106
  return false;
107
107
  }
108
+ /**
109
+ * The effective installed version: the newer of the version this process booted as (`CURRENT`) and
110
+ * what's on disk right now. A restart that raced npm's global write can boot us reading the pre-install
111
+ * `package.json`, so comparing the registry's `latest` against `CURRENT` alone re-declares an update we
112
+ * already applied to disk — and reinstall→restart→boot-stale loops, dropping every connected client on
113
+ * each restart (one release once became three restarts this way). Comparing against disk too closes that
114
+ * loop; the disk-skew heal then reloads us into the newer on-disk code.
115
+ */
116
+ function installedVersion() {
117
+ const onDisk = diskVersion();
118
+ return onDisk && isNewer(onDisk, CURRENT) ? onDisk : CURRENT;
119
+ }
108
120
  async function fetchLatest() {
109
121
  // The `/latest` sub-endpoint serves the full manifest as application/json; the abbreviated
110
122
  // `vnd.npm.install-v1+json` media type is only valid on the packument root and 406s here.
@@ -128,6 +140,23 @@ async function installLatest() {
128
140
  env: { ...process.env, npm_config_yes: 'true' }
129
141
  });
130
142
  }
143
+ /**
144
+ * Block until the installed package.json on disk reports `want`, or `timeoutMs` elapses. npm's global
145
+ * swap can lag `execFile`'s resolve by a beat (nvm symlink/fs caching); restarting before disk reflects
146
+ * the install boots the successor into the *old* version, which then re-triggers the same update — the
147
+ * restart loop. Waiting makes the successor deterministically boot into `want`. Bounded so a mis-resolved
148
+ * install can't hang the updater; the 60s disk-skew heal is the backstop if this returns false.
149
+ */
150
+ async function waitForDiskVersion(want, timeoutMs = 5000) {
151
+ const deadline = Date.now() + timeoutMs;
152
+ for (;;) {
153
+ if (diskVersion() === want)
154
+ return true;
155
+ if (Date.now() >= deadline)
156
+ return false;
157
+ await new Promise(resolve => setTimeout(resolve, 200));
158
+ }
159
+ }
131
160
  function log(msg) {
132
161
  console.info(`[auto-update] ${msg}`);
133
162
  }
@@ -175,24 +204,31 @@ function restartIfDiskSkewed() {
175
204
  }
176
205
  let inFlight = false;
177
206
  async function runCheck(mode) {
178
- if (inFlight)
207
+ if (inFlight || restarting)
179
208
  return;
180
209
  inFlight = true;
181
210
  try {
182
211
  const latest = await fetchLatest();
183
212
  status.latest = latest;
184
213
  status.checkedAt = Date.now();
185
- status.available = latest != null && isNewer(latest, CURRENT);
214
+ // Compare against the effective installed version (the boot version, or a newer one already on
215
+ // disk), never `CURRENT` alone — otherwise a restart that booted us stale reinstalls a version disk
216
+ // already has, looping. See installedVersion().
217
+ const base = installedVersion();
218
+ status.available = latest != null && isNewer(latest, base);
186
219
  status.lastError = null;
187
220
  if (!status.available || latest == null)
188
221
  return;
189
222
  if (mode === 'check') {
190
- log(`update available: ${CURRENT} → ${latest} (AUTO_UPDATE=check — not installing)`);
223
+ log(`update available: ${base} → ${latest} (AUTO_UPDATE=check — not installing)`);
191
224
  return;
192
225
  }
193
- log(`updating ${CURRENT} → ${latest} via \`npm i -g ${NAME}@latest\`…`);
226
+ log(`updating ${base} → ${latest} via \`npm i -g ${NAME}@latest\`…`);
194
227
  await installLatest();
195
- scheduleRestart(`installed ${latest}`);
228
+ // Restart only once the install is visible on disk, so the successor boots into `latest` instead of
229
+ // re-reading the pre-install version and re-triggering this update (the restart loop).
230
+ const settled = await waitForDiskVersion(latest);
231
+ scheduleRestart(settled ? `installed ${latest}` : `installed ${latest} (disk still settling)`);
196
232
  }
197
233
  catch (err) {
198
234
  status.lastError = err instanceof Error ? err.message : String(err);
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Keeps the phone's public URL actually reachable.
3
+ *
4
+ * Tailscale Funnel exposes the loopback relay to the internet by having tailscaled hold a registration
5
+ * with Tailscale's Funnel *ingress* relays; public clients hit those relays, which forward the TLS stream
6
+ * to this node. That registration goes STALE after a network transition (Wi-Fi ⇄ iPhone hotspot, a new
7
+ * DHCP lease, sleep/wake) — especially behind the symmetric NAT common at offices, where the node's
8
+ * public endpoint keeps moving. When it does, `tailscale funnel status` still cheerfully reports "Funnel
9
+ * on" and tailscaled keeps its local :443 listener up, but the ingress can no longer reach the node: the
10
+ * phone (and any desktop tab) sits on "Offline — retrying" until someone manually re-runs `funnel reset`.
11
+ * Nothing self-heals it. (See FINDINGS / the funnel-ingress-stale note.)
12
+ *
13
+ * So this watchdog does NOT trust any local funnel status. It probes the REAL public path end-to-end —
14
+ * resolving the node's MagicDNS name against a *public* resolver to get the ingress IPs (never the tailnet
15
+ * 100.x address, which would mask the break exactly like a `--resolve` to the node does), then an HTTPS
16
+ * GET /health pinned to an ingress IP with the node's SNI. A healthy funnel answers; a stale one fails the
17
+ * TLS/HTTP exchange entirely. After a few consecutive failures — and only when the ingress is TCP-reachable,
18
+ * so a plain internet outage doesn't trigger a pointless reset — it re-registers with
19
+ * `tailscale funnel reset && tailscale funnel --bg --yes <port>`, then waits for propagation. The periodic
20
+ * probe doubles as keepalive traffic through the ingress, which also helps hold the mapping open.
21
+ *
22
+ * Gated like the self-updater: only the launchd-managed daemon (`CONDUCTOR_REMOTE_MANAGED=1`) in public
23
+ * (Funnel) posture runs it. `FUNNEL_WATCHDOG=off` disables; `on` forces it where a tailscale CLI + Funnel
24
+ * posture exist. Stdlib + global fetch/dns/https only — no runtime deps, strip-clean.
25
+ */
26
+ import { execFile } from 'node:child_process';
27
+ import { Resolver } from 'node:dns/promises';
28
+ import https from 'node:https';
29
+ import net from 'node:net';
30
+ import { promisify } from 'node:util';
31
+ import { magicDnsName, readExposeMode, relayPort, tailscaleBin } from "./tailscale.js";
32
+ const execFileP = promisify(execFile);
33
+ const PROBE_PATH = '/health'; // unauthenticated 200 on the relay — no token needed to prove reachability
34
+ const PROBE_TIMEOUT_MS = 8000;
35
+ const TCP_TIMEOUT_MS = 4000;
36
+ const FIRST_DELAY_MS = 20 * 1000; // let the relay + funnel settle after a (re)start before the first probe
37
+ // Consecutive failed probes before we re-register. A single miss is often a transient blip; a stale ingress
38
+ // stays broken until reset, so waiting for N in a row costs a little detection latency to avoid a needless
39
+ // funnel reset (which itself briefly drops clients). ~N×interval of confirmed-down before acting.
40
+ const FAIL_THRESHOLD = 3;
41
+ const HEAL_COOLDOWN_MS = 120 * 1000; // min gap between re-registrations, so a persistent fault can't hammer funnel
42
+ const POST_HEAL_MS = 60 * 1000; // after a reset, give the control plane time to propagate before re-probing
43
+ /** Probe cadence. Frequent enough to detect a stale ingress within a couple minutes and to keep the mapping warm. */
44
+ function resolveIntervalMs() {
45
+ const raw = Number(process.env.FUNNEL_WATCHDOG_INTERVAL_SECONDS);
46
+ const seconds = Number.isFinite(raw) && raw > 0 ? raw : 60;
47
+ return Math.max(seconds, 15) * 1000;
48
+ }
49
+ function log(msg) {
50
+ console.info(`[funnel-watchdog] ${msg}`);
51
+ }
52
+ function wantEnabled() {
53
+ const raw = process.env.FUNNEL_WATCHDOG?.trim().toLowerCase();
54
+ if (raw === 'off' || raw === 'false' || raw === '0')
55
+ return false;
56
+ if (raw === 'on' || raw === 'true' || raw === '1')
57
+ return true;
58
+ return process.env.CONDUCTOR_REMOTE_MANAGED === '1'; // default: only the managed daemon
59
+ }
60
+ /**
61
+ * Public A records for `host` — the Funnel ingress IPs. Queried against public resolvers so MagicDNS on
62
+ * this node can't answer with the tailnet 100.x address (which would make every probe hit the node directly
63
+ * and never see an ingress break). Falls back to the system resolver only if the public query fails.
64
+ */
65
+ async function ingressIps(host) {
66
+ try {
67
+ const r = new Resolver({ timeout: 4000, tries: 2 });
68
+ r.setServers(['1.1.1.1', '8.8.8.8']);
69
+ const ips = await r.resolve4(host);
70
+ if (ips.length)
71
+ return ips;
72
+ }
73
+ catch {
74
+ // public resolver blocked/unreachable — fall through to the system resolver
75
+ }
76
+ try {
77
+ const { lookup } = await import('node:dns/promises');
78
+ const all = await lookup(host, { all: true, family: 4 });
79
+ return all.map(a => a.address);
80
+ }
81
+ catch {
82
+ return [];
83
+ }
84
+ }
85
+ /** GET https://host<PROBE_PATH> but pinned to `ip` (an ingress relay) with SNI=host. Resolves the HTTP status
86
+ * code; rejects if the TLS/HTTP exchange never completes — the signature of a stale ingress. */
87
+ function probeVia(ip, host, timeoutMs) {
88
+ return new Promise((resolve, reject) => {
89
+ const req = https.request({
90
+ host: ip,
91
+ port: 443,
92
+ servername: host, // SNI so the ingress routes to this node and the node's cert validates
93
+ path: PROBE_PATH,
94
+ method: 'GET',
95
+ headers: { host, 'user-agent': 'conductor-remote-funnel-watchdog' },
96
+ timeout: timeoutMs
97
+ }, res => {
98
+ res.resume(); // drain so the socket can close
99
+ resolve(res.statusCode ?? 0);
100
+ });
101
+ req.on('timeout', () => req.destroy(new Error('probe timeout')));
102
+ req.on('error', reject);
103
+ req.end();
104
+ });
105
+ }
106
+ /** True if a bare TCP connection to `ip:port` opens — i.e. the internet + ingress are reachable, isolating
107
+ * "stale funnel stream" (heal-worthy) from "no connectivity" (nothing to fix, don't churn). */
108
+ function tcpOpen(ip, port, timeoutMs) {
109
+ return new Promise(resolve => {
110
+ const socket = net.connect({ host: ip, port });
111
+ const finish = (ok) => {
112
+ socket.destroy();
113
+ resolve(ok);
114
+ };
115
+ socket.setTimeout(timeoutMs);
116
+ socket.once('connect', () => finish(true));
117
+ socket.once('timeout', () => finish(false));
118
+ socket.once('error', () => finish(false));
119
+ });
120
+ }
121
+ async function reRegisterFunnel(bin, port) {
122
+ await execFileP(bin, ['funnel', 'reset'], { timeout: 15_000 });
123
+ await execFileP(bin, ['funnel', '--bg', '--yes', port], { timeout: 20_000 });
124
+ }
125
+ let fails = 0;
126
+ let lastHealAt = 0;
127
+ function schedule(fn, delayMs) {
128
+ setTimeout(fn, delayMs).unref();
129
+ }
130
+ async function tick(host, bin, port, intervalMs) {
131
+ const again = (delay) => schedule(() => void tick(host, bin, port, intervalMs), delay);
132
+ const ips = await ingressIps(host);
133
+ if (ips.length === 0) {
134
+ // Can't resolve the ingress at all (DNS down / offline) — can't confirm a fault, so never heal.
135
+ return again(intervalMs);
136
+ }
137
+ const ip = ips[0];
138
+ let healthy = false;
139
+ try {
140
+ const status = await probeVia(ip, host, PROBE_TIMEOUT_MS);
141
+ healthy = status > 0 && status < 500; // any real HTTP answer means the ingress reached the node
142
+ }
143
+ catch {
144
+ healthy = false;
145
+ }
146
+ if (healthy) {
147
+ if (fails > 0)
148
+ log(`ingress healthy again after ${fails} failed probe(s)`);
149
+ fails = 0;
150
+ return again(intervalMs);
151
+ }
152
+ fails++;
153
+ if (fails < FAIL_THRESHOLD)
154
+ return again(intervalMs);
155
+ // Confirmed down. Only re-register if the ingress is actually reachable (internet up) — otherwise it's a
156
+ // connectivity outage that will clear on its own, and a funnel reset would just churn.
157
+ const reachable = await tcpOpen(ip, 443, TCP_TIMEOUT_MS);
158
+ if (!reachable) {
159
+ log(`ingress ${ip} unreachable after ${fails} probes — looks offline, not re-registering`);
160
+ return again(intervalMs);
161
+ }
162
+ if (Date.now() - lastHealAt < HEAL_COOLDOWN_MS)
163
+ return again(intervalMs);
164
+ log(`funnel ingress stale (${fails} failed probes, ingress TCP-reachable) — re-registering: funnel reset && funnel --bg ${port}`);
165
+ try {
166
+ await reRegisterFunnel(bin, port);
167
+ lastHealAt = Date.now();
168
+ fails = 0;
169
+ log(`re-registered funnel; waiting ${POST_HEAL_MS / 1000}s for the ingress to propagate`);
170
+ return again(POST_HEAL_MS);
171
+ }
172
+ catch (err) {
173
+ log(`funnel re-register failed: ${err instanceof Error ? err.message.trim() : String(err)} — will retry`);
174
+ return again(intervalMs);
175
+ }
176
+ }
177
+ /** Start the funnel watchdog. Safe to call unconditionally — no-ops unless the gates pass. */
178
+ export function startFunnelWatchdog() {
179
+ if (!wantEnabled())
180
+ return;
181
+ if (readExposeMode() !== 'public') {
182
+ // Funnel-specific: in tailnet (serve) posture there's no public ingress to keep alive.
183
+ if (process.env.FUNNEL_WATCHDOG)
184
+ log('skipped: expose posture is tailnet (serve), not public (funnel)');
185
+ return;
186
+ }
187
+ const bin = tailscaleBin();
188
+ if (!bin) {
189
+ log('skipped: tailscale CLI not found');
190
+ return;
191
+ }
192
+ const host = magicDnsName(bin);
193
+ if (!host) {
194
+ log('skipped: no MagicDNS name for this node');
195
+ return;
196
+ }
197
+ const port = relayPort();
198
+ const intervalMs = resolveIntervalMs();
199
+ log(`enabled; probing https://${host}${PROBE_PATH} via the public ingress every ${intervalMs / 1000}s (heal after ${FAIL_THRESHOLD} fails)`);
200
+ schedule(() => void tick(host, bin, port, intervalMs), FIRST_DELAY_MS);
201
+ }
@@ -6,6 +6,7 @@ import zlib from 'node:zlib';
6
6
  import { startAutoUpdate, updateStatus } from "./autoupdate.js";
7
7
  import { loadConfig } from "./config.js";
8
8
  import { ConductorDb } from "./db.js";
9
+ import { startFunnelWatchdog } from "./funnel-watchdog.js";
9
10
  import { workspaceDiff } from "./git.js";
10
11
  import { mergePr } from "./merge.js";
11
12
  import { attachPrStatus } from "./pr.js";
@@ -257,4 +258,7 @@ server.listen(cfg.port, cfg.host, () => {
257
258
  ].join('\n'));
258
259
  // Keep the managed global daemon current — no-ops for dev checkouts / unmanaged runs (see autoupdate.ts).
259
260
  startAutoUpdate();
261
+ // Keep the phone's public URL reachable — re-registers Funnel when its ingress goes stale after a
262
+ // network change. No-ops unless managed + public (Funnel) posture (see funnel-watchdog.ts).
263
+ startFunnelWatchdog();
260
264
  });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Shared Tailscale helpers used by BOTH the deploy script (scripts/service.ts) and the runtime funnel
3
+ * watchdog (src/funnel-watchdog.ts). They must agree: the watchdog re-establishes Funnel with the same
4
+ * port and posture the deploy configured, so "how do we find tailscale / this node's public name / the
5
+ * expose mode" lives here once rather than drifting between deploy-time and runtime copies.
6
+ *
7
+ * Stdlib only, strip-clean (no transform-requiring syntax — see CLAUDE.md ▸ dev path).
8
+ */
9
+ import { execFileSync } from 'node:child_process';
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ /** The relay's loopback port — what Funnel/Serve proxy to. Mirrors config.ts (`RELAY_PORT ?? 8787`). */
14
+ export function relayPort() {
15
+ return String(process.env.RELAY_PORT ?? 8787);
16
+ }
17
+ /** Locate the tailscale CLI: PATH first, then the common macOS install locations. Null if absent. */
18
+ export function tailscaleBin() {
19
+ for (const bin of [
20
+ 'tailscale',
21
+ '/opt/homebrew/bin/tailscale',
22
+ '/usr/local/bin/tailscale',
23
+ '/Applications/Tailscale.app/Contents/MacOS/Tailscale'
24
+ ]) {
25
+ try {
26
+ execFileSync(bin, ['version'], { stdio: 'pipe' });
27
+ return bin;
28
+ }
29
+ catch {
30
+ // try the next candidate
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+ /** This node's MagicDNS name without the trailing dot, e.g. `mac.taila6dcd6.ts.net`. Null if unknown. */
36
+ export function magicDnsName(bin) {
37
+ try {
38
+ const out = execFileSync(bin, ['status', '--json'], { encoding: 'utf8', stdio: 'pipe' });
39
+ return (JSON.parse(out)?.Self?.DNSName ?? '').replace(/\.$/, '') || null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ /** Where the chosen expose mode is persisted (written by the deploy script; read-only here). */
46
+ export function exposeStorePath() {
47
+ return path.join(os.homedir(), 'Library', 'Application Support', 'conductor-remote', 'expose');
48
+ }
49
+ export function normalizeExposeMode(raw) {
50
+ const v = raw?.trim().toLowerCase();
51
+ if (v === 'public' || v === 'funnel')
52
+ return 'public';
53
+ if (v === 'tailnet' || v === 'serve' || v === 'private')
54
+ return 'tailnet';
55
+ return null;
56
+ }
57
+ /**
58
+ * Read-only resolve of the expose posture: `EXPOSE` env > persisted choice > 'public' default. Unlike the
59
+ * deploy script's resolveExposeMode(), this never writes the persisted file — the runtime only observes.
60
+ */
61
+ export function readExposeMode() {
62
+ const fromEnv = normalizeExposeMode(process.env.EXPOSE);
63
+ if (fromEnv)
64
+ return fromEnv;
65
+ try {
66
+ const saved = normalizeExposeMode(fs.readFileSync(exposeStorePath(), 'utf8'));
67
+ if (saved)
68
+ return saved;
69
+ }
70
+ catch {
71
+ // no saved choice yet
72
+ }
73
+ return 'public';
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.21.3",
3
+ "version": "1.23.0",
4
4
  "type": "module",
5
5
  "packageManager": "yarn@4.15.0",
6
6
  "description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",