conductor-remote 1.35.2 → 1.36.1

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.
@@ -0,0 +1,249 @@
1
+ // The root half of `nosleep`, in one place.
2
+ //
3
+ // The same POSIX-sh body runs two ways: piped to `sudo sh -c` (asks for your
4
+ // password) or as the root-owned helper `nosleep setup` installs (doesn't). Keeping
5
+ // one copy is what stops those two paths from drifting into different restore
6
+ // behaviour, which is the half that matters — an un-restored `disablesleep` leaves
7
+ // a Mac that can never sleep again.
8
+ //
9
+ // It reads its arguments rather than having them interpolated in, because the
10
+ // installed helper is a fixed file that a sudoers rule names: the duration has to
11
+ // arrive at run time, and the rule grants the path with no argument pattern. So the
12
+ // script validates its own input (digits only) instead of trusting the caller.
13
+ //
14
+ // Strip-clean (plain-node type-stripping), stdlib-only — see CLAUDE.md.
15
+ // biome-ignore-all lint/suspicious/noTemplateCurlyInString: the shell body is full of POSIX parameter expansions (${1:-0}, ${sb:-1}) in ordinary strings — that is the point, not a mis-typed template literal.
16
+ import { execFile } from 'node:child_process';
17
+ import fs from 'node:fs';
18
+ import { promisify } from 'node:util';
19
+ const execFileP = promisify(execFile);
20
+ /** Absolute path of the root-owned helper. Must sit in a directory only root can write. */
21
+ export const HELPER_PATH = '/usr/local/libexec/conductor-remote-nosleep';
22
+ /**
23
+ * Where the armed window records itself: `<pid> <expiry-epoch> <start-token>` (expiry
24
+ * 0 = until killed). Written by root, mode 0644, because the relay has to *read* it on
25
+ * every status poll and a sudo round-trip per poll would be absurd. It is the lock as
26
+ * well as the record — see the one-owner note on NOSLEEP_BODY.
27
+ *
28
+ * The third field is the process's own start time with the spaces squeezed out, and it
29
+ * is what makes the pid trustworthy. A pid alone is not an identity: a window killed
30
+ * with SIGKILL leaves this file behind, macOS recycles pids freely, and the helper runs
31
+ * as root — so `kill -0` succeeds against whatever inherited the number, and `--stop`
32
+ * would then SIGTERM an unrelated root process. Readers that only want the pid and the
33
+ * expiry (`src/nosleep.ts` ▸ readPidfile) can ignore it; a two-field file from an older
34
+ * version still parses, and skips the check.
35
+ */
36
+ export const PIDFILE_PATH = '/var/run/conductor-remote-nosleep.pid';
37
+ /**
38
+ * sudo's `@includedir` skips any drop-in whose name contains a dot or ends in `~`,
39
+ * silently — so this filename has neither, and adding an extension would install a
40
+ * file that reads fine and never takes effect.
41
+ */
42
+ export const SUDOERS_PATH = '/etc/sudoers.d/conductor-remote';
43
+ /**
44
+ * `$1` = seconds to stay awake, 0 = until killed, or one of `--check` / `--stop`.
45
+ * `$2` = optional display label ("90m") echoed back in the confirmation, validated to
46
+ * the same charset the CLI accepts so nothing arbitrary reaches the terminal.
47
+ *
48
+ * Restore is on the EXIT trap only; INT/TERM/HUP just `exit`, so a signal can't run
49
+ * the restore twice. The captured values are read *before* anything changes and put
50
+ * back verbatim — never defaults, or a `standby 0` somebody set on purpose gets
51
+ * clobbered. `sleep` runs in the background with an explicit `wait` because a
52
+ * foreground `sleep` makes the shell defer its trap until the sleep ends, which for
53
+ * a 2h window means SIGTERM does nothing for 2h.
54
+ *
55
+ * **Exactly one window may be armed**, and arming takes over rather than refusing.
56
+ * Capture-and-restore is only correct for a single owner: a second window would
57
+ * capture the *first* one's already-flipped values (standby 0, disablesleep 1) and
58
+ * "restore" those on its way out, leaving a Mac that can never sleep again. So a new
59
+ * arm signals the incumbent and waits for its restore to land before capturing. That
60
+ * is also why the phone arming a window kills one you left running in a terminal —
61
+ * intended, and the reason `--stop` exists rather than a plain `kill`, since the
62
+ * armed process runs as root and you can't signal it yourself.
63
+ *
64
+ * **That wait is bounded, so it must fail closed.** Waiting forever would hang the
65
+ * relay's arm request; capturing anyway is the one move that produces the permanent
66
+ * failure above, and it is reachable — an incumbent stuck in `pmset`, or a pid that
67
+ * was recycled while the record went stale, never answers the signal. So the wait
68
+ * expiring is an error (exit 75, EX_TEMPFAIL), not a green light. Refusing to arm
69
+ * costs a phone tap. Capturing the flipped values costs a Mac that cannot sleep,
70
+ * silently, with nothing armed to point at.
71
+ */
72
+ export const NOSLEEP_BODY = [
73
+ 'set -u',
74
+ '',
75
+ `pidfile=${PIDFILE_PATH}`,
76
+ '',
77
+ // A pid is not an identity — see the PIDFILE_PATH note. Squeezing the spaces out of
78
+ // `lstart` makes it one whitespace-delimited token, so the record stays a single
79
+ // awk-readable line and a stale pid that has been recycled fails to match.
80
+ 'procStart() { ps -p "$1" -o lstart= 2>/dev/null | tr -d \' \\n\'; }',
81
+ '',
82
+ '# The pid of the armed window, or empty when nothing valid is recorded.',
83
+ 'armedPid() {',
84
+ '\t[ -f "$pidfile" ] || return 0',
85
+ '\tset -- $(awk \'NR==1{print $1, $2, $3}\' "$pidfile" 2>/dev/null)',
86
+ '\tp=${1:-}',
87
+ '\ttok=${3:-}',
88
+ '\tcase "$p" in \'\' | *[!0-9]*) return 0 ;; esac',
89
+ '\tkill -0 "$p" 2>/dev/null || return 0',
90
+ // No token means a record from an older version: fall back to the pid alone rather
91
+ // than treating a live window as absent, which would let a second one arm alongside it.
92
+ '\tif [ -n "$tok" ] && [ "$(procStart "$p")" != "$tok" ]; then return 0; fi',
93
+ '\techo "$p"',
94
+ '}',
95
+ '',
96
+ 'arg=${1:-0}',
97
+ // The probe `helperReady()` runs. It has to be the real path under the real sudo
98
+ // rule — the files being present proves nothing, since a drop-in with the wrong
99
+ // mode or a name includedir skips sits there looking installed and does nothing.
100
+ 'if [ "$arg" = --check ]; then echo ok; exit 0; fi',
101
+ // Disarm. Root-only by necessity: the armed process runs as root, so nothing the
102
+ // relay does as itself can signal it. Reached through the same sudoers rule.
103
+ 'if [ "$arg" = --stop ]; then',
104
+ '\tp=$(armedPid)',
105
+ '\tif [ -n "$p" ]; then kill -TERM "$p" 2>/dev/null; echo stopped; else rm -f "$pidfile"; echo idle; fi',
106
+ '\texit 0',
107
+ 'fi',
108
+ 'secs=$arg',
109
+ 'label=${2:-}',
110
+ "case \"$secs\" in '' | *[!0-9]*) echo 'nosleep: seconds must be digits (0 = until killed)' >&2; exit 64 ;; esac",
111
+ 'case "$label" in *[!0-9smh]*) label=\'\' ;; esac',
112
+ '',
113
+ '# Take over from any incumbent BEFORE capturing, or we would capture its flipped',
114
+ '# values and restore those — the one way this leaves a Mac unable to sleep.',
115
+ 'old=$(armedPid)',
116
+ 'if [ -n "$old" ] && [ "$old" != "$$" ]; then',
117
+ '\tkill -TERM "$old" 2>/dev/null',
118
+ '\tn=0',
119
+ '\twhile kill -0 "$old" 2>/dev/null && [ "$n" -lt 50 ]; do sleep 0.1; n=$((n + 1)); done',
120
+ // Fail closed. Capturing now would read the incumbent's flipped values and "restore"
121
+ // those on the way out, which is the permanent no-sleep this whole block exists to stop.
122
+ '\tif kill -0 "$old" 2>/dev/null; then',
123
+ '\t\techo "nosleep: the armed window ($old) did not stop within 5s; refusing to arm" >&2',
124
+ '\t\texit 75',
125
+ '\tfi',
126
+ 'fi',
127
+ '',
128
+ '# Per-source values live in the Battery Power block; disablesleep is global.',
129
+ 'battval() { pmset -g custom | awk -v k="$1" \'/^Battery Power:/{b=1;next} /^AC Power:/{b=0} b&&$1==k{print $2;exit}\'; }',
130
+ 'sb=$(battval standby)',
131
+ 'pn=$(battval powernap)',
132
+ "ds=$(pmset -g | awk '/SleepDisabled/{print $2;exit}')",
133
+ '',
134
+ "sleeper=''",
135
+ 'cleanup() {',
136
+ '\tif [ -n "$sleeper" ]; then kill "$sleeper" 2>/dev/null || true; fi',
137
+ '\tpmset -b standby "${sb:-1}" powernap "${pn:-1}"',
138
+ '\tpmset -a disablesleep "${ds:-0}"',
139
+ // Only clear the record if it is still ours: a successor that already took over
140
+ // has written its own, and wiping that would strand it as invisible.
141
+ '\tif [ "$(awk \'NR==1{print $1}\' "$pidfile" 2>/dev/null)" = "$$" ]; then rm -f "$pidfile"; fi',
142
+ '}',
143
+ '# Armed before anything changes, so even a pmset that fails half-way reverts.',
144
+ 'trap cleanup EXIT',
145
+ "trap 'exit 130' INT",
146
+ "trap 'exit 143' TERM HUP",
147
+ '',
148
+ 'pmset -b standby 0 powernap 0',
149
+ 'pmset -a disablesleep 1',
150
+ '',
151
+ 'until=0',
152
+ 'if [ "$secs" -gt 0 ]; then until=$(( $(date +%s) + secs )); fi',
153
+ // 0644 so the relay can poll the state without a sudo round-trip per tick.
154
+ 'printf \'%s %s %s\\n\' "$$" "$until" "$(procStart $$)" > "$pidfile" && chmod 644 "$pidfile"',
155
+ '',
156
+ '# A long window is ambiguous without the weekday: "until 15:45" reads as today.',
157
+ 'clock() {',
158
+ '\tif [ "$(date -r "$1" \'+%j\')" = "$(date \'+%j\')" ]; then date -r "$1" \'+%H:%M\'; else date -r "$1" \'+%a %H:%M\'; fi',
159
+ '}',
160
+ '',
161
+ // Printed from in here, after pmset applies: it is the only signal that the
162
+ // password landed and the setting actually took. The clock starts here too, not
163
+ // when the command was typed.
164
+ "echo ''",
165
+ 'if [ "$secs" -gt 0 ]; then',
166
+ '\tsuffix=""',
167
+ '\tif [ -n "$label" ]; then suffix="$label, "; fi',
168
+ '\techo "✓ Sleep disabled until $(clock "$until") (${suffix}incl. lid closed). Ctrl-C to restore."',
169
+ '\tsleep "$secs" &',
170
+ 'else',
171
+ '\techo "✓ Sleep disabled at $(date \'+%H:%M\') (incl. lid closed) — until you press Ctrl-C."',
172
+ '\tsleep 2147483647 &',
173
+ 'fi',
174
+ 'sleeper=$!',
175
+ 'wait "$sleeper"'
176
+ ].join('\n');
177
+ /** The helper as installed: the shared body with a shebang and a note on where it came from. */
178
+ export function helperFile() {
179
+ return [
180
+ '#!/bin/sh',
181
+ '# conductor-remote nosleep helper — installed by `conductor-remote nosleep setup`.',
182
+ '#',
183
+ `# Runs as root via a scoped NOPASSWD rule in ${SUDOERS_PATH}. That rule is only`,
184
+ '# as safe as this file: it MUST stay root-owned and non-writable by anyone else, or',
185
+ '# granting it passwordless root grants passwordless root to everything.',
186
+ '# Remove both with `conductor-remote nosleep setup --uninstall`.',
187
+ '',
188
+ NOSLEEP_BODY,
189
+ ''
190
+ ].join('\n');
191
+ }
192
+ /**
193
+ * The helper as it exists on disk, or null when it isn't installed. Compared against
194
+ * `helperFile()` to catch the copy going stale: the package self-updates and this
195
+ * file deliberately does not follow, so drift is a thing to report, never to fix
196
+ * behind the user's back (see the supply-chain note in nosleep-setup.ts).
197
+ */
198
+ export function installedHelper() {
199
+ try {
200
+ return fs.readFileSync(HELPER_PATH, 'utf8');
201
+ }
202
+ catch {
203
+ return null;
204
+ }
205
+ }
206
+ /**
207
+ * True when the helper will actually run as root without a password.
208
+ *
209
+ * It runs the real command under the real rule, because every cheaper check lies.
210
+ * `sudo -l <cmd>` answers "may this user run it", which is yes for anyone holding
211
+ * blanket `(ALL) ALL` whether or not NOPASSWD applies (measured on this Mac: an
212
+ * unlisted `/usr/bin/pmset` still listed clean). `-k` is what makes the answer
213
+ * about the rule instead of a warm timestamp: it ignores cached credentials for
214
+ * this call only, without clearing them, so probing doesn't cost the user a
215
+ * re-prompt. `--check` exits before touching pmset, so the probe has no effect.
216
+ *
217
+ * Async because it sits behind two token-gated routes and the relay is one thread. A
218
+ * synchronous `sudo` here stops every poll on the phone for as long as it takes, and the
219
+ * 5s ceiling is the whole budget when sudo is slow to answer.
220
+ */
221
+ export async function helperReady() {
222
+ try {
223
+ const { stdout } = await execFileP('sudo', ['-n', '-k', HELPER_PATH, '--check'], {
224
+ encoding: 'utf8',
225
+ timeout: 5000
226
+ });
227
+ return stdout.trim() === 'ok';
228
+ }
229
+ catch {
230
+ return false;
231
+ }
232
+ }
233
+ /**
234
+ * The sudoers drop-in. One command, named absolutely, no argument wildcard — the
235
+ * helper validates its own arguments, and a wildcard here would be the usual way
236
+ * this kind of rule turns into a root shell.
237
+ */
238
+ export function sudoersFile(user) {
239
+ return [
240
+ '# conductor-remote — installed by `conductor-remote nosleep setup`.',
241
+ '#',
242
+ `# Lets ${user} arm and restore lid-closed sleep with no password, which is what`,
243
+ '# lets the relay do it: the login LaunchAgent has no TTY to prompt on.',
244
+ '# Scoped to exactly one root-owned command. Undo: `conductor-remote nosleep setup --uninstall`.',
245
+ `Cmnd_Alias CONDUCTOR_REMOTE_NOSLEEP = ${HELPER_PATH}`,
246
+ `${user} ALL=(root) NOPASSWD: CONDUCTOR_REMOTE_NOSLEEP`,
247
+ ''
248
+ ].join('\n');
249
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Arming lid-closed wakefulness from the phone.
3
+ *
4
+ * This is the one relay capability that needs root, and it only exists at all because
5
+ * `nosleep setup` installed a scoped NOPASSWD rule: the LaunchAgent has no TTY, so
6
+ * without that rule there is no prompt to answer and nothing here can work. Every
7
+ * function below degrades to "not available" rather than failing loudly when the rule
8
+ * isn't installed, because that is the normal state for anyone who hasn't opted in.
9
+ *
10
+ * Two properties matter and neither is obvious:
11
+ *
12
+ * - **The armed window must outlive this process.** `autoupdate` deliberately
13
+ * `exit()`s to reload and launchd restarts us; if the helper were an ordinary child
14
+ * it would die with us and silently restore sleep, which is exactly the moment the
15
+ * phone is relying on it. So it is spawned detached (its own session), and the
16
+ * relay finds it again after a restart by reading the pidfile rather than by
17
+ * holding a handle.
18
+ * - **Liveness can't be checked the usual way.** The armed process runs as root, so
19
+ * `kill(pid, 0)` from this process raises EPERM rather than succeeding. EPERM means
20
+ * it is alive and not ours; ESRCH means it is gone. Treating EPERM as dead would
21
+ * report every armed window as idle.
22
+ *
23
+ * Stdlib only, strip-clean — see CLAUDE.md.
24
+ */
25
+ import { execFile, spawn } from 'node:child_process';
26
+ import fs from 'node:fs';
27
+ import { promisify } from 'node:util';
28
+ import { HELPER_PATH, helperReady, PIDFILE_PATH } from "./nosleep-helper.js";
29
+ const execFileP = promisify(execFile);
30
+ /** Longest window the API will arm. A phone tap should never be able to disable sleep forever. */
31
+ export const MAX_SECONDS = 12 * 3600;
32
+ /**
33
+ * Whether `pid` exists. EPERM is the interesting case: the armed helper runs as root, so
34
+ * signalling it from here is refused, and that refusal is itself proof it is alive.
35
+ */
36
+ function alive(pid) {
37
+ try {
38
+ process.kill(pid, 0);
39
+ return true;
40
+ }
41
+ catch (err) {
42
+ return err.code === 'EPERM';
43
+ }
44
+ }
45
+ /** Parse `<pid> <expiry-epoch-seconds>`; expiry 0 means "until stopped". */
46
+ function readPidfile() {
47
+ let raw;
48
+ try {
49
+ raw = fs.readFileSync(PIDFILE_PATH, 'utf8');
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ const [pidRaw, untilRaw] = raw.trim().split(/\s+/);
55
+ const pid = Number(pidRaw);
56
+ if (!Number.isInteger(pid) || pid <= 0)
57
+ return null;
58
+ const untilSec = Number(untilRaw);
59
+ return { pid, until: Number.isFinite(untilSec) && untilSec > 0 ? untilSec * 1000 : null };
60
+ }
61
+ /**
62
+ * Armed-ness on its own: a local file read plus a signal probe, no subprocess at all.
63
+ *
64
+ * Split out from `nosleepState()` because the wait loops below poll it several times a
65
+ * second. `nosleepState()` falls through to `helperReady()` whenever nothing is armed,
66
+ * which is exactly the state a loop *waiting* for an arm sits in, so polling it would fire
67
+ * a synchronous sudo on every pass — fifty per arm, each one blocking the relay's single
68
+ * thread. The loops already know the grant works; they checked before spawning.
69
+ */
70
+ function armedRecord() {
71
+ const rec = readPidfile();
72
+ return rec && alive(rec.pid) ? rec : null;
73
+ }
74
+ function armedState(rec) {
75
+ return { available: true, armed: true, until: rec.until, pid: rec.pid };
76
+ }
77
+ /**
78
+ * Current state. `helperReady()` shells out to sudo, so it is only consulted when nothing
79
+ * is armed — an armed window is itself proof the grant works.
80
+ */
81
+ export async function nosleepState() {
82
+ const rec = armedRecord();
83
+ if (rec)
84
+ return armedState(rec);
85
+ return { available: await helperReady(), armed: false, until: null, pid: null };
86
+ }
87
+ function unavailable() {
88
+ return {
89
+ ok: false,
90
+ error: 'Passwordless nosleep isn’t installed. Run `conductor-remote nosleep setup` on the Mac.',
91
+ state: { available: false, armed: false, until: null, pid: null }
92
+ };
93
+ }
94
+ /**
95
+ * Arm for `seconds` (0 = until stopped). Arming while already armed replaces the window
96
+ * rather than stacking — the helper enforces that, and it has to, since two owners would
97
+ * restore each other's flipped values and leave sleep disabled for good.
98
+ */
99
+ export async function armNoSleep(seconds) {
100
+ if (!(await helperReady()))
101
+ return unavailable();
102
+ // Floor of 1, not 0. The helper reads 0 as "until killed", so anything under a second
103
+ // truncates straight past MAX_SECONDS into a window nothing ever closes — which is the
104
+ // one thing the cap exists to prevent.
105
+ const secs = Math.min(MAX_SECONDS, Math.max(1, Math.trunc(seconds)));
106
+ // Detached, own session, no stdio: it has to survive this relay's own restarts,
107
+ // which autoupdate performs routinely and without warning.
108
+ const child = spawn('sudo', ['-n', HELPER_PATH, String(secs), ''], {
109
+ detached: true,
110
+ stdio: 'ignore'
111
+ });
112
+ child.unref();
113
+ // The helper writes its pidfile only after pmset actually applied, so waiting for the
114
+ // file is what turns "we launched something" into "sleep is genuinely blocked". A
115
+ // takeover adds its own wait for the incumbent to restore, hence the generous ceiling.
116
+ const deadline = Date.now() + 10_000;
117
+ while (Date.now() < deadline) {
118
+ const rec = armedRecord();
119
+ if (rec)
120
+ return { ok: true, state: armedState(rec) };
121
+ await new Promise(r => setTimeout(r, 200));
122
+ }
123
+ return { ok: false, error: 'nosleep did not report itself armed within 10s', state: await nosleepState() };
124
+ }
125
+ /** Disarm. Goes through the helper because the armed process is root and we can't signal it. */
126
+ export async function disarmNoSleep() {
127
+ if (!(await helperReady()))
128
+ return unavailable();
129
+ try {
130
+ await execFileP('sudo', ['-n', HELPER_PATH, '--stop'], { timeout: 10_000 });
131
+ }
132
+ catch (err) {
133
+ return { ok: false, error: err instanceof Error ? err.message : String(err), state: await nosleepState() };
134
+ }
135
+ const deadline = Date.now() + 8000;
136
+ while (Date.now() < deadline) {
137
+ if (!armedRecord())
138
+ return { ok: true, state: { available: true, armed: false, until: null, pid: null } };
139
+ await new Promise(r => setTimeout(r, 200));
140
+ }
141
+ return { ok: false, error: 'nosleep is still armed after --stop', state: await nosleepState() };
142
+ }
@@ -11,11 +11,14 @@ import { startFunnelWatchdog } from "./funnel-watchdog.js";
11
11
  import { workspaceDiff } from "./git.js";
12
12
  import { installLogCapture, isManaged, LOG_FILE_NAMES, logFiles, processStartedAt, recentLogs, redactSecrets, tailLogFile } from "./logbuf.js";
13
13
  import { mergePr } from "./merge.js";
14
+ import { armNoSleep, disarmNoSleep, MAX_SECONDS as NOSLEEP_MAX_SECONDS, nosleepState } from "./nosleep.js";
14
15
  import { notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
15
16
  import { ParkedPromptQueue } from "./parked.js";
16
17
  import { attachPrStatus } from "./pr.js";
17
18
  import { Reads } from "./reads.js";
19
+ import { readSettings, writeSettings } from "./settings.js";
18
20
  import { driftWarningLines, tailscaleBin } from "./tailscale.js";
21
+ import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "./wifi.js";
19
22
  import { createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, retryWontHelp, screenLocked, setAgentOptions, setRestartGuard, setWorkspaceStatus, WORKSPACE_STATUS_LABELS } from "./writes.js";
20
23
  // Before anything that logs: from here on every console line is also kept in memory for
21
24
  // `GET /api/logs`, so the phone can read why a send failed without ssh-ing into the Mac.
@@ -419,6 +422,68 @@ const server = http.createServer(async (req, res) => {
419
422
  if (req.method === 'GET' && pathname === '/api/repos') {
420
423
  return json(req, res, 200, { repos: reads.listRepos() });
421
424
  }
425
+ // GET /api/settings — relay preferences plus what the phone needs to edit them:
426
+ // the SSIDs this Mac already holds credentials for, so the picker offers a choice
427
+ // instead of asking someone to type a network name from memory on a phone keyboard.
428
+ // `ssid` is best-effort and often null (macOS gates it behind Location Services).
429
+ if (req.method === 'GET' && pathname === '/api/settings') {
430
+ // Four subprocesses, all concurrent: this is the one route that shells out more
431
+ // than once, and serialising them would put the phone's polls behind the sum.
432
+ const [known, current, autoJoinHotspot, nosleep] = await Promise.all([
433
+ preferredNetworks(),
434
+ currentSsid(),
435
+ // macOS's own Auto-join Hotspot setting. On "Never" the Mac won't reach for
436
+ // your phone unprompted, which no amount of relay code can substitute for.
437
+ autoJoinHotspotMode(),
438
+ nosleepState()
439
+ ]);
440
+ return json(req, res, 200, {
441
+ settings: readSettings(),
442
+ wifi: {
443
+ current,
444
+ known,
445
+ // A guess from the name, never a fact — see wifi.ts. It only sorts the picker.
446
+ likelyHotspots: known.filter(looksLikeHotspot),
447
+ autoJoinHotspot
448
+ },
449
+ nosleep: { ...nosleep, maxSeconds: NOSLEEP_MAX_SECONDS }
450
+ });
451
+ }
452
+ // PATCH /api/settings { fallbackSsids?, autoRejoin? } — merge and persist.
453
+ if (req.method === 'PATCH' && pathname === '/api/settings') {
454
+ const body = JSON.parse((await readBody(req)) || '{}');
455
+ const patch = {};
456
+ if (Array.isArray(body.fallbackSsids))
457
+ patch.fallbackSsids = body.fallbackSsids;
458
+ if (typeof body.autoRejoin === 'boolean')
459
+ patch.autoRejoin = body.autoRejoin;
460
+ if (Object.keys(patch).length === 0)
461
+ return json(req, res, 400, { error: 'nothing to change' });
462
+ return json(req, res, 200, { settings: writeSettings(patch) });
463
+ }
464
+ // GET /api/nosleep — is the Mac being held awake, and can this relay do it at all
465
+ if (req.method === 'GET' && pathname === '/api/nosleep') {
466
+ return json(req, res, 200, { ...(await nosleepState()), maxSeconds: NOSLEEP_MAX_SECONDS });
467
+ }
468
+ // POST /api/nosleep { seconds } — hold this Mac awake, lid closed, for a bounded window.
469
+ // Only works once `conductor-remote nosleep setup` has installed the scoped sudoers
470
+ // rule; without it there is no way for a TTY-less daemon to reach root, and the
471
+ // response says so rather than failing vaguely.
472
+ if (req.method === 'POST' && pathname === '/api/nosleep') {
473
+ const body = JSON.parse((await readBody(req)) || '{}');
474
+ const seconds = Number(body.seconds);
475
+ // Whole seconds, not just "> 0": the helper reads 0 as "until killed", and 0.4
476
+ // truncates to 0 — an unbounded window from a request that looked bounded.
477
+ if (!Number.isInteger(seconds) || seconds < 1)
478
+ return json(req, res, 400, { error: 'need a whole number of seconds >= 1' });
479
+ const result = await armNoSleep(seconds);
480
+ return json(req, res, result.ok ? 200 : result.state.available ? 502 : 409, result);
481
+ }
482
+ // DELETE /api/nosleep — let it sleep again now, rather than at the window's end
483
+ if (req.method === 'DELETE' && pathname === '/api/nosleep') {
484
+ const result = await disarmNoSleep();
485
+ return json(req, res, result.ok ? 200 : result.state.available ? 502 : 409, result);
486
+ }
422
487
  // GET /api/logs?file=&limit= — the relay's own log, so a phone can diagnose a failed send
423
488
  // without reaching the Mac. Default is this process's captured console (ordered, timestamped);
424
489
  // `file` tails the daemon's stdout/stderr on disk, which is the only place the *previous*
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The relay's own preferences, set from the phone.
3
+ *
4
+ * Persisted beside the token and the parked queues (`stateDir()/settings.json`) for the
5
+ * same reason those are: the daemon restarts itself on every self-update, and anything
6
+ * held in memory is a preference that quietly reverts.
7
+ *
8
+ * **Nothing secret goes in here.** The file is plain JSON at rest and its contents flow
9
+ * back out through a token-gated API, so the Wi-Fi entries are SSIDs only — macOS already
10
+ * holds the credentials for any network it has joined, and `src/wifi.ts` never passes a
11
+ * password. An SSID the Mac doesn't know is reported as unjoinable rather than stored
12
+ * with one.
13
+ *
14
+ * Stdlib only, strip-clean — see CLAUDE.md.
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { stateDir } from "./config.js";
19
+ const DEFAULTS = { fallbackSsids: [], autoRejoin: false };
20
+ function settingsPath() {
21
+ return path.join(stateDir(), 'settings.json');
22
+ }
23
+ /** Coerce anything (a hand-edited file, an older shape, a bad PATCH) into a valid Settings. */
24
+ function sanitize(raw) {
25
+ const obj = (raw ?? {});
26
+ const ssids = Array.isArray(obj.fallbackSsids) ? obj.fallbackSsids : [];
27
+ return {
28
+ // An SSID is at most 32 bytes; anything longer is not one. Deduped and capped so a
29
+ // runaway list can't turn the rejoin branch into a minutes-long walk.
30
+ fallbackSsids: [
31
+ ...new Set(ssids.filter((s) => typeof s === 'string' && !!s.trim()).map(s => s.trim()))
32
+ ]
33
+ .filter(s => s.length <= 32)
34
+ .slice(0, 8),
35
+ autoRejoin: obj.autoRejoin === true
36
+ };
37
+ }
38
+ let cache = null;
39
+ export function readSettings() {
40
+ if (cache)
41
+ return cache;
42
+ try {
43
+ cache = sanitize(JSON.parse(fs.readFileSync(settingsPath(), 'utf8')));
44
+ }
45
+ catch {
46
+ cache = { ...DEFAULTS };
47
+ }
48
+ return cache;
49
+ }
50
+ /** Merge a partial patch over what's stored and persist. Returns the settings as they now are. */
51
+ export function writeSettings(patch) {
52
+ const next = sanitize({ ...readSettings(), ...patch });
53
+ const file = settingsPath();
54
+ try {
55
+ fs.mkdirSync(path.dirname(file), { recursive: true });
56
+ fs.writeFileSync(file, `${JSON.stringify(next, null, '\t')}\n`, { mode: 0o600 });
57
+ }
58
+ catch (err) {
59
+ console.warn(`⚠ could not persist settings (${err instanceof Error ? err.message : err})`);
60
+ }
61
+ cache = next;
62
+ return next;
63
+ }