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.
- package/README.md +53 -7
- package/bin/cli.js +6 -2
- package/dist/assets/{index--7Dp2aq8.css → index-BnXNOJmN.css} +1 -1
- package/dist/assets/{index-vRxdHNVK.js → index-CoLnU4CO.js} +31 -31
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/scripts/nosleep-setup.js +186 -0
- package/dist-node/scripts/nosleep.js +61 -56
- package/dist-node/src/funnel-watchdog.js +94 -1
- package/dist-node/src/nosleep-helper.js +249 -0
- package/dist-node/src/nosleep.js +142 -0
- package/dist-node/src/server.js +65 -0
- package/dist-node/src/settings.js +63 -0
- package/dist-node/src/wifi.js +173 -0
- package/package.json +4 -3
|
@@ -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
|
+
}
|
package/dist-node/src/server.js
CHANGED
|
@@ -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
|
+
}
|