conductor-remote 1.22.0 → 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,7 +13,7 @@
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-mi7qGnEU.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-BpxkVzXq.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-BL-O33Ca.css">
18
18
  <link rel="manifest" href="/manifest.webmanifest"></head>
19
19
  <body>
package/dist/sw.js CHANGED
@@ -1 +1 @@
1
- if(!self.define){let e,i={};const n=(n,s)=>(n=new URL(n+".js",s).href,i[n]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=i,document.head.appendChild(e)}else e=n,importScripts(n),i()}).then(()=>{let e=i[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(s,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(i[o])return;let l={};const c=e=>n(e,o),t={module:{uri:o},exports:l,require:c};i[o]=Promise.all(s.map(e=>t[e]||c(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:"5ccd9f749918c8f9973db364bba619b4"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-mi7qGnEU.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\//]}))});
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
  }
@@ -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.22.0",
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.",