@monster-orange/den 0.4.20 → 0.4.22
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/CHANGELOG.md +164 -0
- package/README.md +7 -7
- package/docs/distributions.md +167 -19
- package/package.json +1 -1
- package/service/install.sh +213 -63
- package/src/shared/agents/den-claude.ts +13 -1
- package/src/shared/agents/records.ts +6 -0
- package/src/shared/agents/runtime.ts +6 -0
- package/src/shared/agents/session-name.ts +21 -1
- package/src/shared/attest-code.ts +125 -0
- package/src/shared/claim-code.ts +6 -2
- package/src/{ui/server/edge-exposure.ts → shared/edge/exposure.ts} +7 -1
- package/src/shared/github-app.ts +4 -0
- package/src/shared/github-device.ts +38 -1
- package/src/shared/launchd-session.ts +92 -0
- package/src/shared/owner-github.ts +100 -0
- package/src/shared/product.ts +45 -5
- package/src/shared/profile.ts +26 -2
- package/src/shared/sessions/client.ts +24 -0
- package/src/shared/sessions/host.ts +114 -5
- package/src/shared/sessions/types.ts +5 -0
- package/src/shared/web-session.ts +112 -6
- package/src/ui/cli/agent-ops.ts +20 -2
- package/src/ui/cli/agents-command.ts +32 -1
- package/src/ui/cli/create.ts +92 -6
- package/src/ui/cli/doctor-command.ts +126 -15
- package/src/ui/cli/doctor-owner.ts +122 -0
- package/src/ui/cli/doctor-where.ts +203 -0
- package/src/ui/cli/index.ts +1 -1
- package/src/ui/cli/service-command.ts +233 -11
- package/src/ui/cli/sessions-command.ts +46 -12
- package/src/ui/cli/web-command.ts +78 -12
- package/src/ui/server/device-flows.ts +45 -10
- package/src/ui/server/github-routes.ts +169 -16
- package/src/ui/server/index.ts +147 -12
- package/src/ui/server/public/agents-page.js +103 -6
- package/src/ui/server/public/agents.html +30 -0
- package/src/ui/server/public/error-page.js +115 -33
- package/src/ui/server/public/error.html +16 -0
- package/src/ui/server/public/header.js +5 -7
- package/src/ui/server/public/i18n/es.js +24 -1
- package/src/ui/server/public/i18n/ja.js +24 -1
- package/src/ui/server/public/settings-registry.js +1 -1
- package/src/ui/server/public/shell.js +24 -1
- package/src/ui/server/route-classes.ts +7 -0
- package/src/ui/server/session-routes.ts +4 -0
package/service/install.sh
CHANGED
|
@@ -1,24 +1,42 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Run the web UI as
|
|
3
|
-
# started at login, restarted if
|
|
2
|
+
# Run the web UI and the session host as long-lived macOS launchd jobs:
|
|
3
|
+
# started at boot or login, restarted if they exit, not tied to a terminal.
|
|
4
4
|
#
|
|
5
|
-
# service/install.sh install (or refresh) and start
|
|
6
|
-
# service/install.sh uninstall stop
|
|
7
|
-
# service/install.sh status launchd's view of
|
|
8
|
-
# service/install.sh print write the
|
|
5
|
+
# service/install.sh install (or refresh) and start them
|
|
6
|
+
# service/install.sh uninstall stop them and remove their plists
|
|
7
|
+
# service/install.sh status launchd's view of them
|
|
8
|
+
# service/install.sh print write the web plist to stdout only
|
|
9
|
+
# service/install.sh print-host write the session host plist to stdout only
|
|
10
|
+
#
|
|
11
|
+
# Two modes (D18), DEN_SERVICE_MODE:
|
|
12
|
+
# login (default) LaunchAgents in the desktop session (gui/<uid>): they
|
|
13
|
+
# start at login and stop at logout, with every agent.
|
|
14
|
+
# always LaunchDaemons that run as you (UserName): they start at boot and
|
|
15
|
+
# keep running across logout, so agents do too. One sudo writes
|
|
16
|
+
# and loads them. No login keychain, no GUI there: agents use the
|
|
17
|
+
# 1Password service-account token, headless browsers, and Full
|
|
18
|
+
# Disk Access for protected folders.
|
|
19
|
+
#
|
|
20
|
+
# The session host job runs DEN_SERVICE_HOST_EXE, a copy of node Den owns
|
|
21
|
+
# (<stateDir>/bin/<cli>-sessions-host): Full Disk Access is granted to that
|
|
22
|
+
# path, so it never goes to the shared node. A node change replaces the
|
|
23
|
+
# copy; the grant holds when the code signature's requirement is the same
|
|
24
|
+
# (an official node build), and install says to grant it again when not.
|
|
9
25
|
#
|
|
10
26
|
# Works from Den's checkout and from a distribution, where it lives in
|
|
11
27
|
# node_modules/@monster-orange/den/service/. The distribution root is
|
|
12
|
-
# DEN_ROOT, else this checkout when it holds den.yml.
|
|
13
|
-
# engine's server with the node found on PATH now, from the distribution
|
|
14
|
-
# root; re-run install after changing node versions.
|
|
28
|
+
# DEN_ROOT, else this checkout when it holds den.yml.
|
|
15
29
|
#
|
|
16
|
-
# DEN_SERVICE_LABEL
|
|
17
|
-
#
|
|
18
|
-
#
|
|
30
|
+
# DEN_SERVICE_LABEL web job label (default local.<dist dir name>.web)
|
|
31
|
+
# DEN_SERVICE_HOST_LABEL host job label (default: the web label's .web → .sessions)
|
|
32
|
+
# DEN_SERVICE_LOGS log directory (default ~/Library/Logs/<dist dir name>)
|
|
33
|
+
# DEN_SERVICE_NODE the node the jobs run (default: see pick_node)
|
|
34
|
+
# DEN_SERVICE_HOST_EXE the host's node copy (default: none, the host runs that node)
|
|
19
35
|
# DEN_SERVICE_READY_CHECK=external install does not print "running": the
|
|
20
36
|
# caller (`<cli> service install`) checks the server
|
|
21
37
|
# answers first.
|
|
38
|
+
# DEN_SERVICE_DAEMON_DIR, DEN_SERVICE_SUDO (tests) where LaunchDaemons go
|
|
39
|
+
# and what runs as root (default /Library/LaunchDaemons, sudo)
|
|
22
40
|
set -euo pipefail
|
|
23
41
|
|
|
24
42
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
@@ -34,9 +52,31 @@ fi
|
|
|
34
52
|
[ -f "$DIST/den.yml" ] || { echo "✗ $DIST has no den.yml" >&2; exit 1; }
|
|
35
53
|
NAME="$(basename "$DIST")"
|
|
36
54
|
LABEL="${DEN_SERVICE_LABEL:-local.$NAME.web}"
|
|
55
|
+
HOST_LABEL="${DEN_SERVICE_HOST_LABEL:-${LABEL%.web}.sessions}"
|
|
37
56
|
LOGS="${DEN_SERVICE_LOGS:-$HOME/Library/Logs/$NAME}"
|
|
38
|
-
|
|
39
|
-
|
|
57
|
+
SVC_MODE="${DEN_SERVICE_MODE:-login}"
|
|
58
|
+
case "$SVC_MODE" in
|
|
59
|
+
login|always) ;;
|
|
60
|
+
*) echo "✗ DEN_SERVICE_MODE must be login or always, not \"$SVC_MODE\"" >&2; exit 2 ;;
|
|
61
|
+
esac
|
|
62
|
+
UID_N="$(id -u)"
|
|
63
|
+
USER_N="$(id -un)"
|
|
64
|
+
AGENT_DIR="$HOME/Library/LaunchAgents"
|
|
65
|
+
DAEMON_DIR="${DEN_SERVICE_DAEMON_DIR:-/Library/LaunchDaemons}"
|
|
66
|
+
SUDO="${DEN_SERVICE_SUDO-sudo}"
|
|
67
|
+
GUI="gui/$UID_N"
|
|
68
|
+
|
|
69
|
+
# The plist and domain for a label in a mode.
|
|
70
|
+
plist_for() { # $1 label $2 mode
|
|
71
|
+
if [ "$2" = always ]; then echo "$DAEMON_DIR/$1.plist"; else echo "$AGENT_DIR/$1.plist"; fi
|
|
72
|
+
}
|
|
73
|
+
domain_for() { if [ "$1" = always ]; then echo system; else echo "$GUI"; fi; }
|
|
74
|
+
other_mode() { if [ "$1" = always ]; then echo login; else echo always; fi; }
|
|
75
|
+
# Root runs it in the system domain (a LaunchDaemon); else as you.
|
|
76
|
+
as_mode() { # $1 mode, then the command
|
|
77
|
+
local m="$1"; shift
|
|
78
|
+
if [ "$m" = always ] && [ -n "$SUDO" ]; then "$SUDO" "$@"; else "$@"; fi
|
|
79
|
+
}
|
|
40
80
|
|
|
41
81
|
# tsx: the nearest node_modules/tsx at or above the engine (hoisted installs).
|
|
42
82
|
find_tsx() {
|
|
@@ -76,20 +116,75 @@ pick_node() {
|
|
|
76
116
|
echo "$node"
|
|
77
117
|
}
|
|
78
118
|
|
|
79
|
-
|
|
119
|
+
# The code signature's designated requirement, or "" (unsigned, no codesign).
|
|
120
|
+
requirement() { codesign -d -r- "$1" 2>&1 | sed -n 's/^designated => //p' || true; }
|
|
121
|
+
|
|
122
|
+
# The host's node copy (D18): refreshed when node changed. Prints the path
|
|
123
|
+
# the host job runs: the copy, or node itself when it cannot be copied.
|
|
124
|
+
# A node linked to libraries beside it (Homebrew's) does not run copied.
|
|
125
|
+
self_contained() {
|
|
126
|
+
! { command -v otool >/dev/null 2>&1 && otool -L "$1" 2>/dev/null | grep -q '@rpath/\|/opt/homebrew/\|/usr/local/opt/'; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
host_exe() { # $1 node
|
|
130
|
+
local node="$1" exe="${DEN_SERVICE_HOST_EXE:-}" c src=""
|
|
131
|
+
if [ -z "$exe" ]; then echo "$node"; return; fi
|
|
132
|
+
# The node the jobs run, else (unless DEN_SERVICE_NODE names one) the
|
|
133
|
+
# PATH's: a version manager's is fine here, since the copy outlives it.
|
|
134
|
+
for c in "$node" $([ -z "${DEN_SERVICE_NODE:-}" ] && command -v node 2>/dev/null); do
|
|
135
|
+
if self_contained "$c"; then src="$c"; break; fi
|
|
136
|
+
done
|
|
137
|
+
if [ -z "$src" ]; then
|
|
138
|
+
echo "⚠ $node is not self-contained (it loads libraries beside it), so the session host" >&2
|
|
139
|
+
echo " runs it directly: grant Full Disk Access to $node, and again after each node" >&2
|
|
140
|
+
echo " upgrade. An official node build (nodejs.org, nvm) avoids that: set DEN_SERVICE_NODE." >&2
|
|
141
|
+
echo "$node"; return
|
|
142
|
+
fi
|
|
143
|
+
node="$src"
|
|
144
|
+
if [ -f "$exe" ] && cmp -s "$node" "$exe"; then echo "$exe"; return; fi
|
|
145
|
+
local before="" after
|
|
146
|
+
[ -f "$exe" ] && before="$(requirement "$exe")"
|
|
147
|
+
mkdir -p "$(dirname "$exe")"
|
|
148
|
+
chmod 700 "$(dirname "$exe")"
|
|
149
|
+
cp "$node" "$exe.new"
|
|
150
|
+
chmod 700 "$exe.new"
|
|
151
|
+
mv -f "$exe.new" "$exe"
|
|
152
|
+
after="$(requirement "$exe")"
|
|
153
|
+
case "$after" in
|
|
154
|
+
"" | cdhash*)
|
|
155
|
+
echo "⚠ $exe is a copy of $node, which has no stable code signature: Full Disk" >&2
|
|
156
|
+
echo " Access must be granted to it again after every node change. An official" >&2
|
|
157
|
+
echo " node build (nodejs.org, nvm) keeps the grant: set DEN_SERVICE_NODE." >&2 ;;
|
|
158
|
+
*)
|
|
159
|
+
if [ -n "$before" ] && [ "$before" != "$after" ]; then
|
|
160
|
+
echo "⚠ $exe changed signer: grant it Full Disk Access again" >&2
|
|
161
|
+
echo " (System Settings → Privacy & Security → Full Disk Access)." >&2
|
|
162
|
+
fi ;;
|
|
163
|
+
esac
|
|
164
|
+
echo "$exe"
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
write_plist() { # $1 = output file, $2 = web|host, $3 = mode
|
|
80
168
|
NODE="$(pick_node "$MODE")"
|
|
81
169
|
TSX="$(find_tsx)" || { echo "✗ tsx not installed: run npm ci in $DIST" >&2; exit 1; }
|
|
170
|
+
local exe="$NODE" label="$LABEL" log="web.log"
|
|
171
|
+
if [ "$2" = host ]; then
|
|
172
|
+
if [ "$MODE" = install ]; then exe="$(host_exe "$NODE")"; else exe="${DEN_SERVICE_HOST_EXE:-$NODE}"; fi
|
|
173
|
+
label="$HOST_LABEL"; log="sessions-host.log"
|
|
174
|
+
fi
|
|
82
175
|
DOCKER_DIR="$(dirname "$(command -v docker || echo /usr/local/bin/docker)")"
|
|
83
176
|
SVC_PATH="$(dirname "$NODE"):$DOCKER_DIR:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
|
84
|
-
python3 -I - "$1" "$
|
|
177
|
+
python3 -I - "$1" "$2" "$3" "$label" "$exe" "$TSX" "$ENGINE" "$DIST" "$SVC_PATH" "$HOME" "$LOGS/$log" "$USER_N" <<'PY'
|
|
85
178
|
import plistlib, sys
|
|
86
|
-
out, label,
|
|
179
|
+
out, kind, mode, label, exe, tsx, engine, dist, path, home, log, user = sys.argv[1:]
|
|
180
|
+
entry = (f"{engine}/src/ui/server/index.ts", "--silent") if kind == "web" \
|
|
181
|
+
else (f"{engine}/src/ui/cli/index.ts", "sessions", "host", "--stand-by")
|
|
87
182
|
data = {
|
|
88
183
|
"Label": label,
|
|
89
|
-
"ProgramArguments": [
|
|
90
|
-
f"{engine}/src/ui/server/index.ts", "--silent"],
|
|
184
|
+
"ProgramArguments": [exe, "--import", f"{tsx}/dist/loader.mjs", *entry],
|
|
91
185
|
"WorkingDirectory": dist,
|
|
92
|
-
"EnvironmentVariables": {"PATH": path, "HOME": home,
|
|
186
|
+
"EnvironmentVariables": {"PATH": path, "HOME": home, "USER": user,
|
|
187
|
+
"LOGNAME": user,
|
|
93
188
|
"DEN_ROOT": dist, "DEN_ENGINE_ROOT": engine},
|
|
94
189
|
"RunAtLoad": True,
|
|
95
190
|
"KeepAlive": True,
|
|
@@ -97,9 +192,12 @@ data = {
|
|
|
97
192
|
# Interactive, not Background: macOS gives Background agents efficiency
|
|
98
193
|
# cores and throttled I/O, and the UI stalled for seconds under load.
|
|
99
194
|
"ProcessType": "Interactive",
|
|
100
|
-
"StandardOutPath":
|
|
101
|
-
"StandardErrorPath":
|
|
195
|
+
"StandardOutPath": log,
|
|
196
|
+
"StandardErrorPath": log,
|
|
102
197
|
}
|
|
198
|
+
if mode == "always":
|
|
199
|
+
# A LaunchDaemon runs as root unless told otherwise: run as the person.
|
|
200
|
+
data["UserName"] = user
|
|
103
201
|
if out == "-":
|
|
104
202
|
sys.stdout.buffer.write(plistlib.dumps(data))
|
|
105
203
|
else:
|
|
@@ -108,58 +206,110 @@ else:
|
|
|
108
206
|
PY
|
|
109
207
|
}
|
|
110
208
|
|
|
209
|
+
# Stop and remove a job (any mode), quietly when it is not there.
|
|
210
|
+
remove_job() { # $1 label $2 mode
|
|
211
|
+
local plist; plist="$(plist_for "$1" "$2")"
|
|
212
|
+
if [ "$2" = always ]; then
|
|
213
|
+
[ -f "$plist" ] || return 0
|
|
214
|
+
fi
|
|
215
|
+
as_mode "$2" launchctl bootout "$(domain_for "$2")/$1" 2>/dev/null || true
|
|
216
|
+
[ -f "$plist" ] && as_mode "$2" rm -f "$plist"
|
|
217
|
+
return 0
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
# Install and start one job in SVC_MODE.
|
|
221
|
+
load_job() { # $1 label $2 web|host
|
|
222
|
+
local label="$1" plist domain tmp
|
|
223
|
+
plist="$(plist_for "$label" "$SVC_MODE")"
|
|
224
|
+
domain="$(domain_for "$SVC_MODE")"
|
|
225
|
+
tmp="$(mktemp "${TMPDIR:-/tmp}/den-plist.XXXXXX")"
|
|
226
|
+
write_plist "$tmp" "$2" "$SVC_MODE"
|
|
227
|
+
plutil -lint "$tmp" >/dev/null
|
|
228
|
+
# The other mode's job is the same service: never both.
|
|
229
|
+
remove_job "$label" "$(other_mode "$SVC_MODE")"
|
|
230
|
+
as_mode "$SVC_MODE" launchctl bootout "$domain/$label" 2>/dev/null || true
|
|
231
|
+
# bootout returns before launchd has let go of the label; a bootstrap
|
|
232
|
+
# straight after fails ("Bootstrap failed: 5") and leaves NO service.
|
|
233
|
+
# Wait for it to go, then retry bootstrap a few times.
|
|
234
|
+
for _ in $(seq 1 50); do
|
|
235
|
+
as_mode "$SVC_MODE" launchctl print "$domain/$label" >/dev/null 2>&1 || break
|
|
236
|
+
sleep 0.2
|
|
237
|
+
done
|
|
238
|
+
if [ "$SVC_MODE" = always ]; then
|
|
239
|
+
as_mode always install -o root -g wheel -m 644 "$tmp" "$plist"
|
|
240
|
+
rm -f "$tmp"
|
|
241
|
+
else
|
|
242
|
+
mkdir -p "$(dirname "$plist")"
|
|
243
|
+
mv "$tmp" "$plist"
|
|
244
|
+
fi
|
|
245
|
+
local loaded=0
|
|
246
|
+
for _ in 1 2 3 4 5; do
|
|
247
|
+
if as_mode "$SVC_MODE" launchctl bootstrap "$domain" "$plist" 2>/dev/null; then loaded=1; break; fi
|
|
248
|
+
sleep 1
|
|
249
|
+
done
|
|
250
|
+
[ "$loaded" = 1 ] || { echo "✗ launchctl bootstrap failed for $label (plist: $plist)" >&2; exit 1; }
|
|
251
|
+
as_mode "$SVC_MODE" launchctl kickstart -k "$domain/$label"
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# The job's state, as status shows it.
|
|
255
|
+
job_status() { # $1 label
|
|
256
|
+
local m plist domain svc_node out
|
|
257
|
+
for m in always login; do
|
|
258
|
+
plist="$(plist_for "$1" "$m")"
|
|
259
|
+
[ -f "$plist" ] || continue
|
|
260
|
+
domain="$(domain_for "$m")"
|
|
261
|
+
# The node it was installed with may be gone (a removed nvm version):
|
|
262
|
+
# launchd then restarts it forever with no useful message.
|
|
263
|
+
if command -v plutil >/dev/null 2>&1; then
|
|
264
|
+
svc_node="$(plutil -extract ProgramArguments.0 raw -o - "$plist" 2>/dev/null || true)"
|
|
265
|
+
if [ -n "$svc_node" ] && [ ! -x "$svc_node" ]; then
|
|
266
|
+
echo "✗ $1 runs $svc_node, which no longer exists: re-run service install" >&2
|
|
267
|
+
return 1
|
|
268
|
+
fi
|
|
269
|
+
fi
|
|
270
|
+
echo "$1 ($m: $domain)"
|
|
271
|
+
if out="$(launchctl print "$domain/$1" 2>/dev/null)"; then
|
|
272
|
+
printf '%s\n' "$out" | grep -E '^\s*(state|pid|last exit code|runs|spawn type)'
|
|
273
|
+
else
|
|
274
|
+
echo " plist present, job not loaded: $([ "$m" = always ] && echo "sudo ")launchctl bootstrap $domain $plist"
|
|
275
|
+
fi
|
|
276
|
+
return 0
|
|
277
|
+
done
|
|
278
|
+
# launchctl's own "Could not find service" is noise when it isn't loaded.
|
|
279
|
+
echo "$1 is not loaded"
|
|
280
|
+
}
|
|
281
|
+
|
|
111
282
|
MODE="${1:-install}"
|
|
112
283
|
case "$MODE" in
|
|
113
284
|
install)
|
|
114
|
-
mkdir -p "$LOGS"
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
# Wait for it to go, then retry bootstrap a few times.
|
|
121
|
-
for _ in $(seq 1 50); do
|
|
122
|
-
launchctl print "$DOMAIN/$LABEL" >/dev/null 2>&1 || break
|
|
123
|
-
sleep 0.2
|
|
124
|
-
done
|
|
125
|
-
mv "$PLIST.new" "$PLIST"
|
|
126
|
-
loaded=0
|
|
127
|
-
for _ in 1 2 3 4 5; do
|
|
128
|
-
if launchctl bootstrap "$DOMAIN" "$PLIST" 2>/dev/null; then loaded=1; break; fi
|
|
129
|
-
sleep 1
|
|
130
|
-
done
|
|
131
|
-
[ "$loaded" = 1 ] || { echo "✗ launchctl bootstrap failed for $LABEL (plist: $PLIST)" >&2; exit 1; }
|
|
132
|
-
launchctl kickstart -k "$DOMAIN/$LABEL"
|
|
285
|
+
mkdir -p "$LOGS"
|
|
286
|
+
if [ "$SVC_MODE" = always ] && [ -n "$SUDO" ]; then
|
|
287
|
+
echo "… $SVC_MODE: LaunchDaemons running as $USER_N need your admin password once (sudo)"
|
|
288
|
+
fi
|
|
289
|
+
load_job "$HOST_LABEL" host
|
|
290
|
+
load_job "$LABEL" web
|
|
133
291
|
if [ "${DEN_SERVICE_READY_CHECK:-}" = external ]; then
|
|
134
|
-
echo "… $LABEL started; waiting for it to answer"
|
|
292
|
+
echo "… $LABEL started ($SVC_MODE); waiting for it to answer"
|
|
135
293
|
else
|
|
136
|
-
echo "✓ $LABEL running (logs: $LOGS/web.log)"
|
|
294
|
+
echo "✓ $LABEL running ($SVC_MODE; logs: $LOGS/web.log)"
|
|
137
295
|
fi
|
|
138
296
|
;;
|
|
139
297
|
uninstall)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
298
|
+
for m in login always; do
|
|
299
|
+
remove_job "$LABEL" "$m"
|
|
300
|
+
remove_job "$HOST_LABEL" "$m"
|
|
301
|
+
done
|
|
302
|
+
echo "✓ $LABEL and $HOST_LABEL removed"
|
|
143
303
|
;;
|
|
144
304
|
print)
|
|
145
|
-
write_plist -
|
|
305
|
+
write_plist - web "$SVC_MODE"
|
|
306
|
+
;;
|
|
307
|
+
print-host)
|
|
308
|
+
write_plist - host "$SVC_MODE"
|
|
146
309
|
;;
|
|
147
310
|
status)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if [ -f "$PLIST" ] && command -v plutil >/dev/null 2>&1; then
|
|
151
|
-
svc_node="$(plutil -extract ProgramArguments.0 raw -o - "$PLIST" 2>/dev/null || true)"
|
|
152
|
-
if [ -n "$svc_node" ] && [ ! -x "$svc_node" ]; then
|
|
153
|
-
echo "✗ $LABEL runs $svc_node, which no longer exists: re-run service install" >&2
|
|
154
|
-
exit 1
|
|
155
|
-
fi
|
|
156
|
-
fi
|
|
157
|
-
# launchctl's own "Could not find service" is noise when it isn't loaded.
|
|
158
|
-
if out="$(launchctl print "$DOMAIN/$LABEL" 2>/dev/null)"; then
|
|
159
|
-
printf '%s\n' "$out" | grep -E '^\s*(state|pid|last exit code|runs|spawn type)'
|
|
160
|
-
else
|
|
161
|
-
echo "$LABEL is not loaded"
|
|
162
|
-
fi
|
|
311
|
+
job_status "$LABEL"
|
|
312
|
+
job_status "$HOST_LABEL"
|
|
163
313
|
;;
|
|
164
|
-
*) echo "usage: $0 [install|uninstall|status|print]" >&2; exit 2 ;;
|
|
314
|
+
*) echo "usage: $0 [install|uninstall|status|print|print-host]" >&2; exit 2 ;;
|
|
165
315
|
esac
|
|
@@ -455,6 +455,11 @@ export function firstPromptArg(text: string | undefined): string[] {
|
|
|
455
455
|
return [t.startsWith("-") ? `Prompt: ${t}` : t];
|
|
456
456
|
}
|
|
457
457
|
|
|
458
|
+
/** D24: `--name <label>`, so peers tell Den's agents apart; or nothing. */
|
|
459
|
+
function nameArg(label: string | undefined): string[] {
|
|
460
|
+
return label ? ["--name", label] : [];
|
|
461
|
+
}
|
|
462
|
+
|
|
458
463
|
const TOKEN_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
459
464
|
|
|
460
465
|
/**
|
|
@@ -503,6 +508,7 @@ export const denClaude: Runtime = {
|
|
|
503
508
|
bin,
|
|
504
509
|
"--session-id",
|
|
505
510
|
id,
|
|
511
|
+
...nameArg(input.sessionLabel),
|
|
506
512
|
...c.argv,
|
|
507
513
|
...firstPromptArg(input.firstPrompt),
|
|
508
514
|
],
|
|
@@ -517,7 +523,13 @@ export const denClaude: Runtime = {
|
|
|
517
523
|
const bin = binFor(input);
|
|
518
524
|
const c = common(input);
|
|
519
525
|
return {
|
|
520
|
-
argv: [
|
|
526
|
+
argv: [
|
|
527
|
+
bin,
|
|
528
|
+
"--resume",
|
|
529
|
+
input.resumeToken,
|
|
530
|
+
...nameArg(input.sessionLabel),
|
|
531
|
+
...c.argv,
|
|
532
|
+
],
|
|
521
533
|
env: {},
|
|
522
534
|
files: c.files,
|
|
523
535
|
warnings: c.warnings,
|
|
@@ -86,6 +86,12 @@ export interface AgentRecord {
|
|
|
86
86
|
displayName?: string;
|
|
87
87
|
/** When displayName was given (ISO 8601): the latest rename wins (AG-4b). */
|
|
88
88
|
displayNameAt?: string;
|
|
89
|
+
/**
|
|
90
|
+
* D24: the name Den last gave its runtime session (Claude Code's
|
|
91
|
+
* `--name`, peerSessionName), so reading it back is never taken for a
|
|
92
|
+
* rename.
|
|
93
|
+
*/
|
|
94
|
+
peerName?: string;
|
|
89
95
|
/**
|
|
90
96
|
* The model version it runs (AG-12.3): "latest" (the model's newest), or
|
|
91
97
|
* a pinned version. Absent: before AG-12, `model` as given.
|
|
@@ -120,6 +120,12 @@ export interface LaunchInput {
|
|
|
120
120
|
* Only a runtime with `firstPrompt: true` is given one.
|
|
121
121
|
*/
|
|
122
122
|
firstPrompt?: string;
|
|
123
|
+
/**
|
|
124
|
+
* D24: the name other sessions see this one by (peerSessionName:
|
|
125
|
+
* `<cli>-<ws>:<name>`). A runtime that can name its session (den-claude:
|
|
126
|
+
* `--name`) does, at launch and at resume.
|
|
127
|
+
*/
|
|
128
|
+
sessionLabel?: string;
|
|
123
129
|
}
|
|
124
130
|
|
|
125
131
|
export interface LaunchOutput {
|
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
* token: Start fresh forgets it). An adapter that fails or throws is no
|
|
13
13
|
* name; it never breaks the list.
|
|
14
14
|
*
|
|
15
|
-
* Den
|
|
15
|
+
* D24: Den names the runtime's session for other sessions to see
|
|
16
|
+
* (peerSessionName: `<cli>-<ws>:<name>`, Claude Code's `--name`) at launch
|
|
17
|
+
* and resume. That name read back is Den's own (the record's peerName),
|
|
18
|
+
* never a rename.
|
|
16
19
|
*/
|
|
17
20
|
import * as fs from "fs";
|
|
18
21
|
import * as path from "path";
|
|
@@ -71,6 +74,8 @@ function runtimeName(
|
|
|
71
74
|
} catch {
|
|
72
75
|
name = null;
|
|
73
76
|
}
|
|
77
|
+
// D24: Den's own --name, read back, is not the runtime's rename.
|
|
78
|
+
if (name && name === rec.peerName) name = null;
|
|
74
79
|
// null: nothing new (not caught up, not found); the last name stays.
|
|
75
80
|
if (name && name !== seen?.name) {
|
|
76
81
|
seen = { token, name, at: now() };
|
|
@@ -83,6 +88,21 @@ function runtimeName(
|
|
|
83
88
|
return seen;
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
/**
|
|
92
|
+
* D24: the name other sessions (ListAgents) see agent `rec` by:
|
|
93
|
+
* `<cli>-<ws>:<its name, else its id>`. No control characters: it is a
|
|
94
|
+
* process argument.
|
|
95
|
+
*/
|
|
96
|
+
export function peerSessionName(
|
|
97
|
+
cli: string,
|
|
98
|
+
ws: string,
|
|
99
|
+
rec: Pick<AgentRecord, "id" | "displayName">,
|
|
100
|
+
): string {
|
|
101
|
+
// eslint-disable-next-line no-control-regex
|
|
102
|
+
const name = (rec.displayName ?? "").replace(/[\u0000-\u001f\u007f]/g, "");
|
|
103
|
+
return `${cli}-${ws}:${name.trim() || rec.id}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
86
106
|
/**
|
|
87
107
|
* The agent's session name: the latest of Den's rename and the runtime's,
|
|
88
108
|
* or undefined when neither has one. Never throws.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "This is my computer" (D17, fix A): how a browser on the machine Den runs
|
|
3
|
+
* on proves it, when the edge can't tell (Docker Desktop, OrbStack or a TCP
|
|
4
|
+
* passthrough hand the edge a gateway's address, so edgeClient says
|
|
5
|
+
* `unattested`).
|
|
6
|
+
*
|
|
7
|
+
* The signed-out edge page navigates the top level to
|
|
8
|
+
* `http://localhost:<port>/attest?return=<its own edge URL>`. Only a browser
|
|
9
|
+
* on this machine reaches this server there: a browser elsewhere reaches
|
|
10
|
+
* nothing, or its own machine's Den, whose web.secret doesn't verify here.
|
|
11
|
+
* The server answers 303 to the edge URL with the code in the fragment
|
|
12
|
+
* (`#den-attest=<code>`: never sent to a server, never in a log), and the
|
|
13
|
+
* page starts Sign in with GitHub with it.
|
|
14
|
+
*
|
|
15
|
+
* The proof alone never makes a session. Any program running as this OS
|
|
16
|
+
* user (an agent with a shell, too) can reach localhost, so a session from
|
|
17
|
+
* it would let an agent approve its own changes. It only lets a GitHub
|
|
18
|
+
* sign-in claim a Den that has no owner, for a member of
|
|
19
|
+
* web.allowed_github_org (github-routes.ts decideFlow): the person still
|
|
20
|
+
* approves on github.com.
|
|
21
|
+
*
|
|
22
|
+
* - `a1.<nonce>.<expiry>.<mac>`, the mac HMAC-SHA256(web.secret, context +
|
|
23
|
+
* the rest + the edge host it is for), its context its own: never a
|
|
24
|
+
* login or a claim code. A code minted for one host fails on another.
|
|
25
|
+
* - Valid for ATTEST_CODE_TTL_MS (2 minutes): the page uses it at once.
|
|
26
|
+
* - Single use: its nonce is spent when the sign-in starts (ClaimNonces,
|
|
27
|
+
* a file, so a restart can't revive one).
|
|
28
|
+
* - `return` must be one of this server's own edge hosts (attestReturn).
|
|
29
|
+
*/
|
|
30
|
+
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
31
|
+
|
|
32
|
+
export const ATTEST_CODE_TTL_MS = 2 * 60_000;
|
|
33
|
+
const SKEW_MS = 5_000;
|
|
34
|
+
const CONTEXT = "den-attest-v1:";
|
|
35
|
+
/** Where the spent attest nonces are kept (ClaimNonces' format). */
|
|
36
|
+
export const ATTEST_NONCES_FILE = "attest-nonces.json";
|
|
37
|
+
|
|
38
|
+
function mac(secret: Buffer, body: string, host: string): Buffer {
|
|
39
|
+
return createHmac("sha256", secret)
|
|
40
|
+
.update(`${CONTEXT}${body}\n${host.toLowerCase()}`)
|
|
41
|
+
.digest();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A fresh attest code for `host` (the edge hostname, no port). */
|
|
45
|
+
export function mintAttestCode(
|
|
46
|
+
secret: Buffer,
|
|
47
|
+
host: string,
|
|
48
|
+
now: number = Date.now(),
|
|
49
|
+
): string {
|
|
50
|
+
const nonce = randomBytes(16).toString("base64url");
|
|
51
|
+
const body = `a1.${nonce}.${(now + ATTEST_CODE_TTL_MS).toString(36)}`;
|
|
52
|
+
return `${body}.${mac(secret, body, host).toString("base64url")}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type AttestCodeCheck =
|
|
56
|
+
| { ok: true; nonce: string; expiresAt: number }
|
|
57
|
+
| { ok: false; reason: "malformed" | "bad_mac" | "expired" };
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether `code` is an attest code this secret minted for `host`, still in
|
|
61
|
+
* its time. Constant-time mac. Single use is the caller's.
|
|
62
|
+
*/
|
|
63
|
+
export function checkAttestCode(
|
|
64
|
+
code: string,
|
|
65
|
+
secret: Buffer,
|
|
66
|
+
host: string,
|
|
67
|
+
now: number = Date.now(),
|
|
68
|
+
): AttestCodeCheck {
|
|
69
|
+
const m =
|
|
70
|
+
/^(a1\.([A-Za-z0-9_-]{22})\.([0-9a-z]{1,11}))\.([A-Za-z0-9_-]{43})$/.exec(
|
|
71
|
+
code,
|
|
72
|
+
);
|
|
73
|
+
if (!m) return { ok: false, reason: "malformed" };
|
|
74
|
+
const given = Buffer.from(m[4], "base64url");
|
|
75
|
+
const want = mac(secret, m[1], host);
|
|
76
|
+
if (given.length !== want.length || !timingSafeEqual(given, want))
|
|
77
|
+
return { ok: false, reason: "bad_mac" };
|
|
78
|
+
const expiresAt = parseInt(m[3], 36);
|
|
79
|
+
if (
|
|
80
|
+
!Number.isFinite(expiresAt) ||
|
|
81
|
+
now >= expiresAt ||
|
|
82
|
+
expiresAt - now > ATTEST_CODE_TTL_MS + SKEW_MS
|
|
83
|
+
)
|
|
84
|
+
return { ok: false, reason: "expired" };
|
|
85
|
+
return { ok: true, nonce: m[2], expiresAt };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `/attest`'s `return`, checked: an http(s) URL whose host is one of
|
|
90
|
+
* `edgeHosts` (this server's own edge hostnames), no credentials in it.
|
|
91
|
+
* Its origin and same-origin path, or null for anything else.
|
|
92
|
+
*/
|
|
93
|
+
export function attestReturn(
|
|
94
|
+
raw: string | null | undefined,
|
|
95
|
+
edgeHosts: readonly string[],
|
|
96
|
+
): { origin: string; host: string; next: string } | null {
|
|
97
|
+
if (!raw) return null;
|
|
98
|
+
let u: URL;
|
|
99
|
+
try {
|
|
100
|
+
u = new URL(raw);
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") return null;
|
|
105
|
+
if (u.username || u.password) return null;
|
|
106
|
+
const host = u.hostname.toLowerCase();
|
|
107
|
+
if (!edgeHosts.map((h) => h.toLowerCase()).includes(host)) return null;
|
|
108
|
+
const next = `${u.pathname}${u.search}`;
|
|
109
|
+
return {
|
|
110
|
+
origin: u.origin,
|
|
111
|
+
host,
|
|
112
|
+
next: next.startsWith("/auth") ? "/" : next,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The fragment key the code rides in back to the edge page. */
|
|
117
|
+
export const ATTEST_FRAGMENT = "den-attest";
|
|
118
|
+
|
|
119
|
+
/** Where /attest sends the browser: the edge page, the code in the fragment. */
|
|
120
|
+
export function attestRedirect(
|
|
121
|
+
ret: { origin: string; next: string },
|
|
122
|
+
code: string,
|
|
123
|
+
): string {
|
|
124
|
+
return `${ret.origin}${ret.next}#${ATTEST_FRAGMENT}=${encodeURIComponent(code)}`;
|
|
125
|
+
}
|
package/src/shared/claim-code.ts
CHANGED
|
@@ -108,10 +108,14 @@ export function claimRefusalForAgent(env: NodeJS.ProcessEnv): string | null {
|
|
|
108
108
|
|
|
109
109
|
/** The used claim nonces, kept until they expire (a file, 0600). */
|
|
110
110
|
export class ClaimNonces {
|
|
111
|
-
constructor(
|
|
111
|
+
constructor(
|
|
112
|
+
private readonly stateDir: string,
|
|
113
|
+
/** The file's name (D17: the attest codes keep their own). */
|
|
114
|
+
private readonly fileName: string = CLAIM_NONCES_FILE,
|
|
115
|
+
) {}
|
|
112
116
|
|
|
113
117
|
private file(): string {
|
|
114
|
-
return path.join(this.stateDir,
|
|
118
|
+
return path.join(this.stateDir, this.fileName);
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
/** The used nonces; null when the file is there but won't parse (fail closed). */
|
|
@@ -15,7 +15,13 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { execFile } from "child_process";
|
|
17
17
|
import { promises as dns } from "dns";
|
|
18
|
-
import
|
|
18
|
+
import * as net from "net";
|
|
19
|
+
|
|
20
|
+
/** A loopback IP (127/8, ::1, or either IPv4-mapped); request-guard.ts has the same. */
|
|
21
|
+
function isLoopbackAddress(addr: string): boolean {
|
|
22
|
+
const a = addr.toLowerCase().replace(/^::ffff:/, "");
|
|
23
|
+
return a === "::1" || (net.isIPv4(a) && a.startsWith("127."));
|
|
24
|
+
}
|
|
19
25
|
|
|
20
26
|
/**
|
|
21
27
|
* The warning, by `web.edge_unattested` (SEC-2a): with `signin` (the
|
package/src/shared/github-app.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface GithubEndpoints {
|
|
|
41
41
|
deviceCode: string;
|
|
42
42
|
accessToken: string;
|
|
43
43
|
user: string;
|
|
44
|
+
/** The REST API's base (D17: an org's membership), no trailing slash. */
|
|
45
|
+
api: string;
|
|
44
46
|
/** A test's fake GitHub is in use (GITHUB_TEST_BASE_ENV). */
|
|
45
47
|
fake: boolean;
|
|
46
48
|
}
|
|
@@ -68,12 +70,14 @@ export function githubEndpoints(
|
|
|
68
70
|
deviceCode: `${fake}/login/device/code`,
|
|
69
71
|
accessToken: `${fake}/login/oauth/access_token`,
|
|
70
72
|
user: `${fake}/user`,
|
|
73
|
+
api: fake,
|
|
71
74
|
fake: true,
|
|
72
75
|
};
|
|
73
76
|
return {
|
|
74
77
|
deviceCode: "https://github.com/login/device/code",
|
|
75
78
|
accessToken: "https://github.com/login/oauth/access_token",
|
|
76
79
|
user: "https://api.github.com/user",
|
|
80
|
+
api: "https://api.github.com",
|
|
77
81
|
fake: false,
|
|
78
82
|
};
|
|
79
83
|
}
|