@vibelord/lanes 1.0.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/LICENSE +21 -0
- package/README.md +70 -0
- package/bin/lanes-install.mjs +158 -0
- package/lib/plan.mjs +65 -0
- package/package.json +37 -0
- package/payload/lanes/README.md +152 -0
- package/payload/lanes/VERSION +1 -0
- package/payload/lanes/bin/lanes +1035 -0
- package/payload/lanes/client/ghostty/common.conf +22 -0
- package/payload/lanes/client/ghostty/macos.conf +6 -0
- package/payload/lanes/client/ghostty/omarchy.conf +19 -0
- package/payload/lanes/client/install.sh +16 -0
- package/payload/lanes/client/lanes-launch +315 -0
- package/payload/lanes/client/lanes-upload +147 -0
- package/payload/lanes/client/lanes-window +36 -0
- package/payload/lanes/client/macos-helper.applescript +14 -0
- package/payload/lanes/client/omarchy/discover.sh +56 -0
- package/payload/lanes/client/omarchy/lanes.conf +5 -0
- package/payload/lanes/client/omarchy/lanes.desktop +9 -0
- package/payload/lanes/client/omarchy/lanes.lua +14 -0
- package/payload/lanes/client/ssh.stanza.in +54 -0
- package/payload/lanes/container/env.sh +16 -0
- package/payload/lanes/container/lane +268 -0
- package/payload/lanes/container/lane-run +34 -0
- package/payload/lanes/docs/ARCHITECTURE.md +93 -0
- package/payload/lanes/docs/HOST-GUARD.md +108 -0
- package/payload/lanes/docs/INSTALL.md +192 -0
- package/payload/lanes/docs/RUNBOOK.md +385 -0
- package/payload/lanes/docs/SHARING.md +124 -0
- package/payload/lanes/docs/ZMX-RING0.md +51 -0
- package/payload/lanes/docs/images/desktop.png +0 -0
- package/payload/lanes/docs/images/iphone.png +0 -0
- package/payload/lanes/holders/exec.sh +21 -0
- package/payload/lanes/holders/tmux.sh +111 -0
- package/payload/lanes/holders/zmx.sh +22 -0
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-phone.sh +43 -0
- package/payload/lanes/install/enable-web.sh +16 -0
- package/payload/lanes/install/host-install.sh +72 -0
- package/payload/lanes/install/services.py +61 -0
- package/payload/lanes/install.sh +603 -0
- package/payload/lanes/libexec/cursor_commit.py +159 -0
- package/payload/lanes/libexec/cursor_sync.py +166 -0
- package/payload/lanes/libexec/guard.py +231 -0
- package/payload/lanes/libexec/lanes_lib.py +610 -0
- package/payload/lanes/libexec/legacy_sessions.py +130 -0
- package/payload/lanes/libexec/session_actions.py +276 -0
- package/payload/lanes/libexec/sidebar +8 -0
- package/payload/lanes/libexec/sidebar.py +872 -0
- package/payload/lanes/libexec/statusd.py +764 -0
- package/payload/lanes/libexec/tmpscan.py +921 -0
- package/payload/lanes/libexec/view.sh +179 -0
- package/payload/lanes/scripts/adopt-sharing-candidate.py +109 -0
- package/payload/lanes/site/README.md +28 -0
- package/payload/lanes/site/beyondwork/known_hosts +1 -0
- package/payload/lanes/site/beyondwork/site.conf +11 -0
- package/payload/lanes/site/site.example.conf +10 -0
- package/payload/lanes/sudo/host-guard.sh +356 -0
- package/payload/lanes/sudo/one-time-root.sh +32 -0
- package/payload/lanes/systemd/lanes-ensure.service +7 -0
- package/payload/lanes/systemd/lanes-ensure.timer +10 -0
- package/payload/lanes/systemd/lanes-gc.service +7 -0
- package/payload/lanes/systemd/lanes-gc.timer +9 -0
- package/payload/lanes/systemd/lanes-guard.service +8 -0
- package/payload/lanes/systemd/lanes-guard.timer +10 -0
- package/payload/lanes/systemd/lanes-mobile.service +23 -0
- package/payload/lanes/systemd/lanes-statusd.service +15 -0
- package/payload/lanes/systemd/lanes-sync.service +10 -0
- package/payload/lanes/systemd/lanes-sync.timer +10 -0
- package/payload/lanes/systemd/lanes-tmpreap.service +11 -0
- package/payload/lanes/systemd/lanes-tmpreap.timer +13 -0
- package/payload/lanes/systemd/lanes-web.service +13 -0
- package/payload/lanes/systemd/lanes.slice +11 -0
- package/payload/lanes/web/Caddyfile +37 -0
- package/payload/lanes/web/README.md +35 -0
- package/payload/lanes/web/icon-192.png +0 -0
- package/payload/lanes/web/icon.svg +4 -0
- package/payload/lanes/web/index.html +30 -0
- package/payload/lanes/web/manifest.webmanifest +15 -0
- package/payload/lanes/web/mobile.css +301 -0
- package/payload/lanes/web/mobile.js +367 -0
- package/payload/lanes/workspaces.example/README.md +27 -0
- package/payload/lanes/workspaces.example/catalog.json +7 -0
- package/payload/lanes/workspaces.example/commit-targets.json +8 -0
- package/payload/lanes/workspaces.example/terminals.example.json +19 -0
- package/payload/lanes/workspaces.example/workspaces.product.json +15 -0
- package/payload/lanes/workspaces.example/workspaces.research.json +16 -0
- package/payload/lanes/zellij/config.kdl +360 -0
- package/payload/manifest.json +31 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# The lanes HOST GUARD — the second and last privileged piece of the lanes server. Run by hand on the workspace host:
|
|
3
|
+
# sudo sh ./sudo/host-guard.sh read-only status + the plan of what --apply would do
|
|
4
|
+
# sudo sh ./sudo/host-guard.sh --apply memory envelope for the harness/login path + the 60 s OOM-score guard
|
|
5
|
+
# sudo sh ./sudo/host-guard.sh --undo recorded values back, guard removed
|
|
6
|
+
# Why root: MemoryLow on user.slice and below is PID 1's to set (a user manager cannot protect itself: its ancestors
|
|
7
|
+
# cap whatever it asks for), and LOWERING an oom_score_adj needs CAP_SYS_RESOURCE.
|
|
8
|
+
# It never restarts, stops or signals anything, never calls docker, never runs or opens anything inside a container.
|
|
9
|
+
# That is all it does, on purpose. The plan's DEFERRED measures (a smaller tmpfs /tmp, a container memory.high) are
|
|
10
|
+
# not code: docs/HOST-GUARD.md has them as by-hand procedures with their preconditions, next to the NEVER list.
|
|
11
|
+
set -eu
|
|
12
|
+
umask 022
|
|
13
|
+
|
|
14
|
+
# The three overrides exist for tests against a fake tree on a Mac; sudo's env_reset keeps them out of a real run.
|
|
15
|
+
P=${LANES_PROC_ROOT:-/proc}
|
|
16
|
+
CG=${LANES_CGROUP_ROOT:-/sys/fs/cgroup}
|
|
17
|
+
D=${LANES_HG_DESTDIR:-}
|
|
18
|
+
PREV=$D/var/lib/lanes-host-guard/prev
|
|
19
|
+
GUARD=$D/usr/local/sbin/lanes-oom-guard
|
|
20
|
+
UNITS=$D/etc/systemd/system
|
|
21
|
+
CONF=$D/etc/lanes-host-guard.conf
|
|
22
|
+
|
|
23
|
+
die() { echo "host-guard: $*" >&2; exit 1; }
|
|
24
|
+
usage() { sed -n '2,10s/^# \{0,1\}//p' "$0"; }
|
|
25
|
+
|
|
26
|
+
mode= guard_user=${SUDO_USER:-}
|
|
27
|
+
while [ "$#" -gt 0 ]; do
|
|
28
|
+
case "$1" in
|
|
29
|
+
--user) guard_user=${2:?--user ACCOUNT}; shift ;;
|
|
30
|
+
--apply|--undo) [ -z "$mode" ] || { usage >&2; exit 2; }; mode=$1 ;;
|
|
31
|
+
-h|--help) usage; echo 'Add --user ACCOUNT when not invoked by the workspace user through sudo.'; exit 0 ;;
|
|
32
|
+
*) usage >&2; exit 2 ;;
|
|
33
|
+
esac
|
|
34
|
+
shift
|
|
35
|
+
done
|
|
36
|
+
[ "$(id -u)" = 0 ] || die 'run with sudo'
|
|
37
|
+
case $guard_user in ''|root|*[!A-Za-z0-9._-]*) die 'specify the workspace account with --user ACCOUNT';; esac
|
|
38
|
+
user=$guard_user
|
|
39
|
+
[ -f "$CG/cgroup.controllers" ] || die "cgroup v2 is not mounted at $CG (a v1/hybrid host has no memory.low to set)"
|
|
40
|
+
uid=$(id -u "$user") || die "no such user: $user"
|
|
41
|
+
slice=$CG/user.slice/user-$uid.slice
|
|
42
|
+
[ -d "$slice" ] || die "$slice does not exist: $user has no session and no lingering user manager"
|
|
43
|
+
eval "guard_home=~$user"
|
|
44
|
+
STATE=${LANES_STATE:-$guard_home/.local/state/lanes}
|
|
45
|
+
|
|
46
|
+
# unit, the MemoryLow --apply gives it, its cgroup. Parent first: a child's protection is capped by every ancestor.
|
|
47
|
+
# 4G on the slices = the ~1 GiB harness + sshd sessions (session-N.scope sits beside user@, not under it) + headroom;
|
|
48
|
+
# 3G of that for the user manager, where statusd, the zellij servers and the viewers live.
|
|
49
|
+
units() {
|
|
50
|
+
echo "user.slice 4G $CG/user.slice"
|
|
51
|
+
echo "user-$uid.slice 4G $slice"
|
|
52
|
+
echo "user@$uid.service 3G $slice/user@$uid.service"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
first_line() { v='?'; if [ -r "$1" ]; then read -r v < "$1" || :; fi; echo "$v"; }
|
|
56
|
+
|
|
57
|
+
gib() { # bytes -> "12.3 GiB"; cgroup files say "max" for "no limit"
|
|
58
|
+
case $1 in ''|*[!0-9]*) echo "${1:-?}";; *) echo "$(($1 / 1073741824)).$(($1 % 1073741824 * 10 / 1073741824)) GiB";; esac
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
docker_cgroup() { # $1 = pid -> its container's cgroup dir; fails unless that IS a docker container's cgroup
|
|
62
|
+
[ -r "$P/$1/cgroup" ] || return 1
|
|
63
|
+
while read -r line; do
|
|
64
|
+
p=${line#0::}
|
|
65
|
+
case $line in
|
|
66
|
+
0::*/docker-*.scope*) p=${p%%.scope*}.scope;; # systemd driver; cut an in-container sub-cgroup off
|
|
67
|
+
0::/docker/*) p=${p#/docker/}; p=/docker/${p%%/*};; # cgroupfs driver
|
|
68
|
+
*) continue;;
|
|
69
|
+
esac
|
|
70
|
+
if [ -d "$CG$p" ]; then echo "$CG$p"; return 0; fi
|
|
71
|
+
done < "$P/$1/cgroup"
|
|
72
|
+
return 1
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# $1 = pid -> "fstype size_kib used_kib" of the container's /tmp: one statfs(2) through /proc/<pid>/root, no nsenter.
|
|
76
|
+
# GNU `stat -f` first, because GNU df insists on NAMING the mount, and a tmpfs that exists only in the container's
|
|
77
|
+
# mount namespace has no name out here (getcwd fails: "no file systems processed"). BSD stat has no -c: df it is.
|
|
78
|
+
tmp_usage() {
|
|
79
|
+
dir=$P/$1/root/tmp
|
|
80
|
+
set -- $(stat -f -c '%T %S %b %f' "$dir" 2>/dev/null) # type, block size, blocks, free blocks
|
|
81
|
+
case $#:${2:-}${3:-}${4:-} in
|
|
82
|
+
4:*[!0-9]*) ;;
|
|
83
|
+
4:*) echo "$1 $(($3 * $2 / 1024)) $((($3 - $4) * $2 / 1024))"; return 0;;
|
|
84
|
+
esac
|
|
85
|
+
df -Pk "$dir" 2>/dev/null | { read -r header; read -r fs size used rest && echo "$fs $size $used"; } || :
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
mem_stat() { # $1 = cgroup dir -> anon, shmem (bytes)
|
|
89
|
+
anon=0 shmem=0
|
|
90
|
+
[ -r "$1/memory.stat" ] || return 0
|
|
91
|
+
while read -r key val; do case $key in anon) anon=$val;; shmem) shmem=$val;; esac; done < "$1/memory.stat"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------------------------------- the 60 s guard
|
|
95
|
+
# One source for the installed script AND for the status summary (run from here with `sh -s status`), so what the
|
|
96
|
+
# status shows is by construction what the timer does.
|
|
97
|
+
guard_src() {
|
|
98
|
+
cat <<'GUARD'
|
|
99
|
+
#!/bin/sh
|
|
100
|
+
# lanes-oom-guard — installed by lanes/sudo/host-guard.sh --apply, which overwrites it: edit the source there.
|
|
101
|
+
# Root, every 60 s (lanes-oom-guard.timer). Goal: when a container hits its memory limit the kernel's OOM killer takes
|
|
102
|
+
# a build, never a lane. Pure /proc reads and writes; the only forks are one find+cat per cgroup tree, never one per
|
|
103
|
+
# pid, so it stays cheap on a host that is already thrashing.
|
|
104
|
+
# lanes-oom-guard protect (the timer)
|
|
105
|
+
# lanes-oom-guard status print "<class> <oom_score_adj>" per protected process, change nothing
|
|
106
|
+
# lanes-oom-guard reset put every mark of ours back to 0 (host-guard.sh --undo)
|
|
107
|
+
set -u
|
|
108
|
+
P=${LANES_PROC_ROOT:-/proc}
|
|
109
|
+
CG=${LANES_CGROUP_ROOT:-/sys/fs/cgroup}
|
|
110
|
+
CONF=${LANES_HOST_GUARD_CONF:-/etc/lanes-host-guard.conf}
|
|
111
|
+
: "${agents:=claude codex codex-code-mode}" "${uid:=1000}" "${state_dir:=}"
|
|
112
|
+
if [ -r "$CONF" ]; then . "$CONF"; fi
|
|
113
|
+
mode=${1:-apply}
|
|
114
|
+
|
|
115
|
+
# The policy is -900 (tmux servers, container init) and -500 (agents, zellij); the values written are one lower ON
|
|
116
|
+
# PURPOSE. oom_score_adj is inherited over fork: every pane shell a marked tmux server starts, and every build a
|
|
117
|
+
# marked agent starts, is born with the parent's mark — left alone, within days everything descended from a lane is
|
|
118
|
+
# as protected as the lane and the killer takes the Coder agent or sshd instead. So each pass also puts an inherited
|
|
119
|
+
# mark back to 0, and -901/-501 (values nobody else uses; docker's own is -500) are how it knows a mark is ours.
|
|
120
|
+
# Never -1000: a cgroup in which nothing can be killed turns an OOM into a hang.
|
|
121
|
+
SERVER=-901
|
|
122
|
+
AGENT=-501
|
|
123
|
+
|
|
124
|
+
ns_init() { # $1 = pid: pid 1 of its own pid namespace AND in a docker cgroup? The pid files are written by the
|
|
125
|
+
# unprivileged lanes server and can be stale; root must never act on a recycled pid.
|
|
126
|
+
[ -r "$P/$1/cgroup" ] && [ -r "$P/$1/status" ] || return 1
|
|
127
|
+
ok=
|
|
128
|
+
while read -r line; do case $line in 0::*/docker-*.scope*|0::/docker/*) ok=1;; esac; done < "$P/$1/cgroup"
|
|
129
|
+
[ -n "$ok" ] || return 1
|
|
130
|
+
while read -r key val; do
|
|
131
|
+
case $key in NSpid:) set -- $val; [ $# -ge 2 ] || return 1; shift $(($# - 1)); [ "$1" = 1 ]; return;; esac
|
|
132
|
+
done < "$P/$1/status"
|
|
133
|
+
return 1
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
inits=' '
|
|
137
|
+
if [ -n "$state_dir" ]; then
|
|
138
|
+
for f in "$state_dir"/containers/*.pid; do
|
|
139
|
+
[ -r "$f" ] || continue
|
|
140
|
+
read -r pid < "$f" || continue
|
|
141
|
+
case $pid in ''|0|*[!0-9]*) continue;; esac
|
|
142
|
+
if ns_init "$pid"; then inits="$inits$pid "; fi
|
|
143
|
+
done
|
|
144
|
+
fi
|
|
145
|
+
|
|
146
|
+
{
|
|
147
|
+
for pid in $inits; do echo "$pid init"; done
|
|
148
|
+
# Every docker container (either cgroup driver) + the whole user slice: the host-side zellij servers and any
|
|
149
|
+
# host tmux server live there, in scopes whose names vary. Classes are exact comm strings, so scanning wide
|
|
150
|
+
# touches nothing else. find, because a container may keep its processes in sub-cgroups.
|
|
151
|
+
for d in "$CG"/system.slice/docker-*.scope "$CG"/docker/* "$CG/user.slice/user-$uid.slice"; do
|
|
152
|
+
if [ -d "$d" ]; then find "$d" -name cgroup.procs -exec cat {} + 2>/dev/null; fi
|
|
153
|
+
done
|
|
154
|
+
} | {
|
|
155
|
+
nset=0 nreset=0
|
|
156
|
+
while read -r pid forced; do
|
|
157
|
+
case $pid in ''|*[!0-9]*) continue;; esac
|
|
158
|
+
if [ -z "$forced" ]; then case $inits in *" $pid "*) continue;; esac; fi # already handled as "init"
|
|
159
|
+
{ read -r comm < "$P/$pid/comm" && read -r adj < "$P/$pid/oom_score_adj"; } 2>/dev/null || continue # gone
|
|
160
|
+
case $adj in ''|*[!0-9-]*) continue;; esac
|
|
161
|
+
if [ -n "$forced" ]; then cls=init want=$SERVER
|
|
162
|
+
else
|
|
163
|
+
cls=$comm
|
|
164
|
+
case $comm in
|
|
165
|
+
'tmux: server') want=$SERVER;; # tmux names itself so (prctl); clients are 'tmux: client'
|
|
166
|
+
zellij) want=$AGENT;;
|
|
167
|
+
'') want=0;;
|
|
168
|
+
# Claude Code's native binary is a file NAMED after its version (…/claude/versions/2.1.274), so that
|
|
169
|
+
# is its comm. Measured on the host 2026-09-19: claude x33, 2.1.274 x16, codex x40, codex-code-mode x10.
|
|
170
|
+
*[!0-9.]*) case " $agents " in *" $comm "*) want=$AGENT;; *) want=0;; esac;;
|
|
171
|
+
[0-9]*.[0-9]*.[0-9]*) want=$AGENT;;
|
|
172
|
+
*) want=0;;
|
|
173
|
+
esac
|
|
174
|
+
fi
|
|
175
|
+
if [ "$mode" = status ]; then
|
|
176
|
+
if [ "$want" != 0 ]; then echo "$cls $adj"
|
|
177
|
+
else case $adj in "$SERVER"|"$AGENT") echo "(inherited-mark) $adj";; esac; fi
|
|
178
|
+
continue
|
|
179
|
+
fi
|
|
180
|
+
if [ "$mode" = reset ]; then want=0; fi
|
|
181
|
+
new=
|
|
182
|
+
case $adj in
|
|
183
|
+
"$want") ;;
|
|
184
|
+
"$SERVER"|"$AGENT") new=$want;; # our mark on the wrong process: inherited, or the class changed
|
|
185
|
+
*) if [ "$want" != 0 ] && [ "$adj" -gt "$want" ]; then new=$want; fi;; # never RAISE a foreign value
|
|
186
|
+
esac
|
|
187
|
+
[ -n "$new" ] || continue
|
|
188
|
+
if { echo "$new" > "$P/$pid/oom_score_adj"; } 2>/dev/null; then
|
|
189
|
+
if [ "$new" = 0 ]; then nreset=$((nreset + 1)); else nset=$((nset + 1)); fi
|
|
190
|
+
fi
|
|
191
|
+
done
|
|
192
|
+
# Quiet when nothing changed: 1440 identical journal lines a day help nobody.
|
|
193
|
+
if [ "$mode" != status ] && [ $((nset + nreset)) -gt 0 ]; then
|
|
194
|
+
echo "lanes-oom-guard: $nset protected, $nreset inherited marks cleared"
|
|
195
|
+
fi
|
|
196
|
+
}
|
|
197
|
+
GUARD
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
run_guard() { # before --apply there is no conf yet: uid and state dir go in through the environment, the conf wins
|
|
201
|
+
guard_src | LANES_PROC_ROOT=$P LANES_CGROUP_ROOT=$CG LANES_HOST_GUARD_CONF=$CONF uid=$uid state_dir=$STATE sh -s "$1"
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------------------------------------------------ status
|
|
205
|
+
status() {
|
|
206
|
+
echo "lanes host guard — status (read-only). user $user, uid $uid"
|
|
207
|
+
echo
|
|
208
|
+
echo 'Memory envelope (MemoryLow):'
|
|
209
|
+
units | while read -r unit val dir; do
|
|
210
|
+
now=$(systemctl show -p MemoryLow --value "$unit" 2>/dev/null </dev/null) || now='?'
|
|
211
|
+
printf ' %-20s systemd %-12s memory.low %-12s --apply: %s\n' "$unit" "${now:-?}" "$(first_line "$dir/memory.low")" "$val"
|
|
212
|
+
done
|
|
213
|
+
recursive=no
|
|
214
|
+
if [ -r "$P/mounts" ]; then
|
|
215
|
+
while read -r dev mp fs opts rest; do
|
|
216
|
+
if [ "$fs" = cgroup2 ]; then case ",$opts," in *,memory_recursiveprot,*) recursive=yes;; esac; fi
|
|
217
|
+
done < "$P/mounts"
|
|
218
|
+
fi
|
|
219
|
+
echo " memory_recursiveprot: $recursive"
|
|
220
|
+
if [ "$recursive" = no ]; then
|
|
221
|
+
echo ' WARNING: without it a MemoryLow on these three units does not reach the leaf cgroups below them.'
|
|
222
|
+
fi
|
|
223
|
+
|
|
224
|
+
echo; echo 'Pressure (PSI):'
|
|
225
|
+
for r in memory io cpu; do
|
|
226
|
+
if [ -r "$P/pressure/$r" ]; then while read -r line; do printf ' %-7s %s\n' "$r" "$line"; done < "$P/pressure/$r"
|
|
227
|
+
else printf ' %-7s (no PSI in this kernel)\n' "$r"; fi
|
|
228
|
+
done
|
|
229
|
+
|
|
230
|
+
echo; echo "Containers (from $STATE/containers/*.pid):"
|
|
231
|
+
for f in "$STATE"/containers/*.pid; do
|
|
232
|
+
[ -r "$f" ] || continue
|
|
233
|
+
name=${f##*/}; name=${name%.pid}; pid=$(first_line "$f")
|
|
234
|
+
case $pid in ''|0|*[!0-9]*) echo " $name: not running"; continue;; esac
|
|
235
|
+
if ! cg=$(docker_cgroup "$pid"); then echo " $name: pid $pid is not in a docker cgroup (stale pid file)"; continue; fi
|
|
236
|
+
mem_stat "$cg"
|
|
237
|
+
kills='?'
|
|
238
|
+
if [ -r "$cg/memory.events" ]; then
|
|
239
|
+
while read -r key val; do case $key in oom_kill) kills=$val;; esac; done < "$cg/memory.events"
|
|
240
|
+
fi
|
|
241
|
+
echo " $name (pid $pid, ${cg##*/})"
|
|
242
|
+
echo " memory.current $(gib "$(first_line "$cg/memory.current")") of max $(gib "$(first_line "$cg/memory.max")")," \
|
|
243
|
+
"high $(gib "$(first_line "$cg/memory.high")"); anon $(gib "$anon"), shmem $(gib "$shmem")"
|
|
244
|
+
echo " swap.max $(gib "$(first_line "$cg/memory.swap.max")"), oom.group $(first_line "$cg/memory.oom.group"), oom_kill $kills"
|
|
245
|
+
set -- $(tmp_usage "$pid")
|
|
246
|
+
if [ $# -eq 3 ]; then
|
|
247
|
+
echo " /tmp ($1): $(gib $(($3 * 1024))) used of $(gib $(($2 * 1024)))"
|
|
248
|
+
else echo ' /tmp: could not statvfs'; fi
|
|
249
|
+
done
|
|
250
|
+
|
|
251
|
+
echo; echo 'oom_score_adj by class (count, class, value; ours are -901 / -501):'
|
|
252
|
+
run_guard status | sort | uniq -c | sed 's/^/ /'
|
|
253
|
+
|
|
254
|
+
echo
|
|
255
|
+
oomd=$(systemctl is-active systemd-oomd.service 2>/dev/null) || :
|
|
256
|
+
echo "systemd-oomd: ${oomd:-unknown}"
|
|
257
|
+
if [ "$oomd" = active ]; then
|
|
258
|
+
echo ' WARNING: check `oomctl` — it must never manage system.slice or a docker scope (it kills whole cgroups).'
|
|
259
|
+
fi
|
|
260
|
+
if [ -e "$UNITS/lanes-oom-guard.timer" ]; then
|
|
261
|
+
timer=$(systemctl is-active lanes-oom-guard.timer 2>/dev/null) || :
|
|
262
|
+
echo "guard timer: installed, ${timer:-unknown}"
|
|
263
|
+
else echo 'guard timer: not installed'; fi
|
|
264
|
+
|
|
265
|
+
cat <<PLAN
|
|
266
|
+
|
|
267
|
+
Plan — what --apply would do (this run changed nothing):
|
|
268
|
+
1. record the current MemoryLow of the three units under $PREV/ (once; a second --apply keeps the first record)
|
|
269
|
+
2. systemctl set-property user.slice MemoryLow=4G · user-$uid.slice MemoryLow=4G · user@$uid.service MemoryLow=3G
|
|
270
|
+
(live and persistent; no daemon-reload, no restart of anything)
|
|
271
|
+
3. install $GUARD + lanes-oom-guard.service/.timer (every 60 s) and $CONF:
|
|
272
|
+
tmux servers and container inits -> -901, agents ($(if [ -r "$CONF" ]; then . "$CONF"; fi; echo "${agents:-claude codex}")) and zellij -> -501, inherited marks -> 0
|
|
273
|
+
--undo puts the recorded values back, clears the marks and removes the guard.
|
|
274
|
+
PLAN
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
# ------------------------------------------------------------------------------------------------------ apply / undo
|
|
278
|
+
apply() {
|
|
279
|
+
mkdir -p "$PREV" "${GUARD%/*}" "$UNITS"
|
|
280
|
+
units | while read -r unit val dir; do
|
|
281
|
+
# Record ONCE: a second --apply must not replace the real previous value with our own.
|
|
282
|
+
if [ ! -e "$PREV/$unit.MemoryLow" ]; then
|
|
283
|
+
now=$(systemctl show -p MemoryLow --value "$unit" </dev/null)
|
|
284
|
+
echo "${now:-0}" > "$PREV/$unit.MemoryLow"
|
|
285
|
+
fi
|
|
286
|
+
systemctl set-property "$unit" "MemoryLow=$val" </dev/null
|
|
287
|
+
echo "$unit: MemoryLow=$val (was $(first_line "$PREV/$unit.MemoryLow"))"
|
|
288
|
+
done
|
|
289
|
+
|
|
290
|
+
# Via a temp name + mv: the timer may exec the script at any moment, and root must never run half a file.
|
|
291
|
+
guard_src > "$GUARD.new"; chmod 755 "$GUARD.new"; mv "$GUARD.new" "$GUARD"
|
|
292
|
+
if [ ! -e "$CONF" ]; then # kept on re-apply: the agent list is the one thing meant to be edited
|
|
293
|
+
cat > "$CONF" <<CONF_EOF
|
|
294
|
+
# Read (sourced, sh syntax) by /usr/local/sbin/lanes-oom-guard. Written once by lanes/sudo/host-guard.sh --apply.
|
|
295
|
+
# agents: exact comm strings (cat /proc/<pid>/comm, max 15 chars, no spaces) of the agent processes to protect.
|
|
296
|
+
agents='claude codex'
|
|
297
|
+
uid=$uid
|
|
298
|
+
state_dir='$STATE'
|
|
299
|
+
CONF_EOF
|
|
300
|
+
fi
|
|
301
|
+
cat > "$UNITS/lanes-oom-guard.service" <<'UNIT'
|
|
302
|
+
[Unit]
|
|
303
|
+
Description=lanes: keep tmux servers, agents and container inits out of the OOM killer's first picks
|
|
304
|
+
|
|
305
|
+
[Service]
|
|
306
|
+
Type=oneshot
|
|
307
|
+
ExecStart=/usr/local/sbin/lanes-oom-guard
|
|
308
|
+
UNIT
|
|
309
|
+
cat > "$UNITS/lanes-oom-guard.timer" <<'UNIT'
|
|
310
|
+
[Unit]
|
|
311
|
+
Description=lanes: run lanes-oom-guard every 60 s
|
|
312
|
+
|
|
313
|
+
[Timer]
|
|
314
|
+
OnBootSec=60
|
|
315
|
+
OnUnitActiveSec=60
|
|
316
|
+
AccuracySec=5
|
|
317
|
+
|
|
318
|
+
[Install]
|
|
319
|
+
WantedBy=timers.target
|
|
320
|
+
UNIT
|
|
321
|
+
systemctl enable --now lanes-oom-guard.timer # `enable` reloads the manager itself: the new units are seen
|
|
322
|
+
systemctl start lanes-oom-guard.service # first pass now, so the verification below shows it
|
|
323
|
+
cat <<VERIFY
|
|
324
|
+
|
|
325
|
+
Applied. Verify:
|
|
326
|
+
sudo sh $0
|
|
327
|
+
memory.low reads 4294967296 / 4294967296 / 3221225472; tmux servers and inits at -901, agents and zellij at -501
|
|
328
|
+
systemctl list-timers lanes-oom-guard.timer; journalctl -u lanes-oom-guard -n 5
|
|
329
|
+
next run within 60 s; the journal says "N protected, M inherited marks cleared" only when something changed
|
|
330
|
+
as $user: lanes invariant check
|
|
331
|
+
no lane changed
|
|
332
|
+
Undo: sudo sh $0 --undo
|
|
333
|
+
VERIFY
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
undo() {
|
|
337
|
+
units | while read -r unit val dir; do
|
|
338
|
+
[ -e "$PREV/$unit.MemoryLow" ] || { echo "$unit: nothing recorded, left alone"; continue; }
|
|
339
|
+
was=$(first_line "$PREV/$unit.MemoryLow")
|
|
340
|
+
systemctl set-property "$unit" "MemoryLow=$was" </dev/null
|
|
341
|
+
rm -f "$PREV/$unit.MemoryLow"
|
|
342
|
+
echo "$unit: MemoryLow=$was restored"
|
|
343
|
+
done
|
|
344
|
+
systemctl disable --now lanes-oom-guard.timer 2>/dev/null || :
|
|
345
|
+
# Marks outlive the timer (and keep being inherited) unless they are cleared; ours are recognisable, so this is exact.
|
|
346
|
+
run_guard reset || :
|
|
347
|
+
rm -f "$GUARD" "$UNITS/lanes-oom-guard.service" "$UNITS/lanes-oom-guard.timer"
|
|
348
|
+
systemctl daemon-reload
|
|
349
|
+
echo "guard removed. $CONF is kept (your agent list); delete it by hand if you like."
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
case $mode in
|
|
353
|
+
'') status;;
|
|
354
|
+
--apply) apply;;
|
|
355
|
+
--undo) undo;;
|
|
356
|
+
esac
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Optional server admin setup: grant Tailscale Serve to the workspace account.
|
|
3
|
+
# sudo sh sudo/one-time-root.sh [--user ACCOUNT] [--ssh-keepalive]
|
|
4
|
+
set -eu
|
|
5
|
+
user=${SUDO_USER:-} keepalive=0
|
|
6
|
+
while [ "$#" -gt 0 ]; do
|
|
7
|
+
case "$1" in
|
|
8
|
+
--user) user=${2:?--user ACCOUNT}; shift ;;
|
|
9
|
+
--ssh-keepalive) keepalive=1 ;;
|
|
10
|
+
-h|--help) sed -n '2,3s/^# //p' "$0"; exit 0 ;;
|
|
11
|
+
*) echo "unknown option: $1" >&2; exit 2 ;;
|
|
12
|
+
esac
|
|
13
|
+
shift
|
|
14
|
+
done
|
|
15
|
+
[ "$(id -u)" = 0 ] || { echo 'run with sudo' >&2; exit 1; }
|
|
16
|
+
case "$user" in ''|root|*[!A-Za-z0-9._-]*) echo 'specify the unprivileged workspace account with --user' >&2; exit 2 ;; esac
|
|
17
|
+
id "$user" >/dev/null
|
|
18
|
+
tailscale set --operator="$user"
|
|
19
|
+
if [ "$keepalive" = 1 ]; then
|
|
20
|
+
conf=/etc/ssh/sshd_config.d/10-lanes.conf
|
|
21
|
+
if [ ! -e "$conf" ]; then
|
|
22
|
+
printf '# lanes: reap dead interactive clients\nClientAliveInterval 15\nClientAliveCountMax 3\n' > "$conf"
|
|
23
|
+
if sshd -t; then
|
|
24
|
+
systemctl reload ssh 2>/dev/null || systemctl reload sshd
|
|
25
|
+
else
|
|
26
|
+
rm -f "$conf"
|
|
27
|
+
echo 'sshd rejected the keepalive snippet; removed it.' >&2
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
fi
|
|
31
|
+
fi
|
|
32
|
+
printf 'Next, as %s: sh ~/.local/share/lanes/current/install/enable-phone.sh --local\n' "$user"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=lanes: responsive phone frontend on loopback
|
|
3
|
+
Requires=lanes-web.service
|
|
4
|
+
After=lanes-web.service
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
ExecStart="@CADDY@" run --config "@REPO@/web/Caddyfile" --adapter caddyfile
|
|
8
|
+
ExecReload=/bin/kill -USR1 $MAINPID
|
|
9
|
+
RuntimeDirectory=lanes-mobile
|
|
10
|
+
Environment=XDG_CONFIG_HOME=%t/lanes-mobile/config
|
|
11
|
+
Environment=XDG_DATA_HOME=%t/lanes-mobile/data
|
|
12
|
+
Restart=on-failure
|
|
13
|
+
RestartSec=3
|
|
14
|
+
NoNewPrivileges=true
|
|
15
|
+
PrivateTmp=true
|
|
16
|
+
ProtectSystem=strict
|
|
17
|
+
ProtectHome=read-only
|
|
18
|
+
MemoryHigh=128M
|
|
19
|
+
MemoryMax=256M
|
|
20
|
+
Slice=lanes.slice
|
|
21
|
+
|
|
22
|
+
[Install]
|
|
23
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=lanes status daemon (observes lanes, never touches them)
|
|
3
|
+
|
|
4
|
+
[Service]
|
|
5
|
+
ExecStart=/usr/bin/python3 -S -E "@REPO@/libexec/statusd.py"
|
|
6
|
+
Restart=always
|
|
7
|
+
RestartSec=2
|
|
8
|
+
Nice=5
|
|
9
|
+
MemoryLow=96M
|
|
10
|
+
MemoryHigh=192M
|
|
11
|
+
MemoryMax=384M
|
|
12
|
+
Slice=lanes.slice
|
|
13
|
+
|
|
14
|
+
[Install]
|
|
15
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=lanes registry sync (read-only: rebuild registry.json from the repos' terminal registries)
|
|
3
|
+
|
|
4
|
+
[Service]
|
|
5
|
+
Type=oneshot
|
|
6
|
+
ExecStart="@REPO@/bin/lanes" sync
|
|
7
|
+
# A momentarily invalid registry keeps the previous lanes; a non-zero exit here is informational.
|
|
8
|
+
SuccessExitStatus=1
|
|
9
|
+
Nice=5
|
|
10
|
+
Slice=lanes.slice
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=lanes: remove dead temp trees from the containers' RAM-backed /tmp (dry-run until flags/tmpreap.armed exists)
|
|
3
|
+
|
|
4
|
+
[Service]
|
|
5
|
+
Type=oneshot
|
|
6
|
+
ExecStart="@REPO@/bin/lanes" tmp reap
|
|
7
|
+
# A oneshot has no start timeout by default: a run stuck on a thrashing host must not block the next hour's.
|
|
8
|
+
TimeoutStartSec=30min
|
|
9
|
+
Nice=19
|
|
10
|
+
IOSchedulingClass=idle
|
|
11
|
+
Slice=lanes.slice
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=lanes tmp reap hourly
|
|
3
|
+
|
|
4
|
+
[Timer]
|
|
5
|
+
# Not enabled by install/host-install.sh: `systemctl --user enable --now lanes-tmpreap.timer`, by hand, after the first
|
|
6
|
+
# real `lanes tmp plan` / `apply`. OnActiveSec, not OnBootSec: a boot-relative time is already in the past on a
|
|
7
|
+
# running host, so enabling would start a walk at once.
|
|
8
|
+
OnActiveSec=1h
|
|
9
|
+
OnUnitActiveSec=1h
|
|
10
|
+
AccuracySec=1min
|
|
11
|
+
|
|
12
|
+
[Install]
|
|
13
|
+
WantedBy=timers.target
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=lanes: zellij web server on loopback only (tailnet exposure is a separate, user-run Tailscale Serve step)
|
|
3
|
+
|
|
4
|
+
[Service]
|
|
5
|
+
ExecStart="@SHARE@/bin/zellij-0.45.1" --config-dir "@REPO@/zellij" --config "@REPO@/zellij/config.kdl" web --start --ip 127.0.0.1 --port 8082
|
|
6
|
+
Restart=always
|
|
7
|
+
RestartSec=5
|
|
8
|
+
MemoryHigh=512M
|
|
9
|
+
MemoryMax=1G
|
|
10
|
+
Slice=lanes.slice
|
|
11
|
+
|
|
12
|
+
[Install]
|
|
13
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=lanes: the harness (services and view sessions). Tiny, but it must be scheduled while the host thrashes
|
|
3
|
+
|
|
4
|
+
[Slice]
|
|
5
|
+
# Weight against its siblings under the user manager (needs the cpu controller delegated to user@; ignored otherwise).
|
|
6
|
+
CPUWeight=10000
|
|
7
|
+
# Protection from reclaim for the harness's ~1 GiB working set. It only becomes effective once root has given the
|
|
8
|
+
# ancestors (user.slice -> user-<uid>.slice -> user@<uid>.service) a MemoryLow: sudo/host-guard.sh --apply. This host
|
|
9
|
+
# mounts cgroup2 WITHOUT memory_recursiveprot, so the leaves below ask explicitly as well (scopes: bin/lanes
|
|
10
|
+
# create_session; statusd: its unit file).
|
|
11
|
+
MemoryLow=2G
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
admin off
|
|
3
|
+
auto_https off
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
# The existing Zellij server owns authentication and both WebSocket channels.
|
|
7
|
+
# This loopback-only frontend adds mobile assets without rebuilding the pinned binary.
|
|
8
|
+
http://:8083 {
|
|
9
|
+
bind 127.0.0.1
|
|
10
|
+
root * "{$LANES_REPO}/web"
|
|
11
|
+
header {
|
|
12
|
+
X-Content-Type-Options nosniff
|
|
13
|
+
X-Frame-Options DENY
|
|
14
|
+
Referrer-Policy strict-origin-when-cross-origin
|
|
15
|
+
Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' ws: wss:; img-src 'self' data:"
|
|
16
|
+
}
|
|
17
|
+
handle_path /lanes-assets/* {
|
|
18
|
+
header Cache-Control "no-cache"
|
|
19
|
+
file_server
|
|
20
|
+
}
|
|
21
|
+
@backend path /assets/* /ws/* /command/* /info/* /session /session-list
|
|
22
|
+
handle @backend {
|
|
23
|
+
reverse_proxy 127.0.0.1:8082
|
|
24
|
+
}
|
|
25
|
+
@page {
|
|
26
|
+
method GET HEAD
|
|
27
|
+
path_regexp session ^/([A-Za-z0-9_.-]+)?$
|
|
28
|
+
}
|
|
29
|
+
handle @page {
|
|
30
|
+
rewrite * /index.html
|
|
31
|
+
header Cache-Control "no-store"
|
|
32
|
+
file_server
|
|
33
|
+
}
|
|
34
|
+
handle {
|
|
35
|
+
reverse_proxy 127.0.0.1:8082
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Phone frontend
|
|
2
|
+
|
|
3
|
+
The Lanes shell enhances the pinned Zellij 0.45.1 web client on phones. It uses that client's existing
|
|
4
|
+
`window.__zjMobileUi`, `window.__zjSendControl`, `window.__zjSoftKbdCapture`, and `window.term` hooks.
|
|
5
|
+
There is no separate auth system or command API. Workspace names are rendered as text, and navigation
|
|
6
|
+
uses Zellij's authenticated `FocusPane` messages. Desktop browsers keep the upstream interface.
|
|
7
|
+
|
|
8
|
+
The phone keyboard opens through a native tap on an input field. The bottom **Type here** field contains
|
|
9
|
+
Zellij's original capture input in its closed shadow root, preserving its keyboard/IME handling. The client
|
|
10
|
+
blocks programmatic focus on text editors and keeps `__zjSoftKbdEnabled` false, so upstream touch handlers,
|
|
11
|
+
two-finger gestures, server `SetSoftKeyboard` messages and modal autofocus cannot summon it. Search and
|
|
12
|
+
login inputs still focus normally when tapped. Keep this policy in place when upgrading the upstream client.
|
|
13
|
+
|
|
14
|
+
`lanes-mobile.service` runs a separate, loopback-only Caddy on port 8083 with its admin API disabled.
|
|
15
|
+
It does not change the system Caddy. Tailscale Serve forwards private HTTPS on 8443 to this frontend;
|
|
16
|
+
the original Zellij service stays on 8082. Caddy must already be installed on the host.
|
|
17
|
+
Run `sh install/enable-phone.sh --host lanes` from an installed client, or `--local` on the server.
|
|
18
|
+
The [installation guide](../docs/INSTALL.md) covers Caddy, Tailscale operator permission and the selected SSH key.
|
|
19
|
+
Service registration supplies `LANES_REPO` so Caddy serves the actual checkout from any installation path.
|
|
20
|
+
|
|
21
|
+
`index.html` keeps the upstream asset integrity hashes. When upgrading Zellij, compare the HTML and
|
|
22
|
+
window hooks against the new version before changing the pinned binary. The mobile JS and CSS have
|
|
23
|
+
`no-cache` headers, and entry pages use `no-store`, so reopening the app picks up changes without
|
|
24
|
+
clearing login cookies. There is no service worker caching terminal data or credentials.
|
|
25
|
+
|
|
26
|
+
Layout uses the visual viewport and safe-area insets, with independent handling for portrait, landscape,
|
|
27
|
+
keyboard-open and Home Screen views. The default terminal font is 16 px; the user's text-size choice
|
|
28
|
+
is stored locally. Minimum touch targets are 44 px (workspace cards are 74 px). The lane picker in
|
|
29
|
+
`libexec/sidebar.py` uses three terminal rows per lane on mobile. `tests/mobile_view.py` verifies
|
|
30
|
+
the touch-to-lane path with isolated holders; sidebar unit tests cover the complete touch target.
|
|
31
|
+
|
|
32
|
+
Validate on Safari/WebKit at 440 × 763 (iPhone 17 Pro Max with browser chrome), 440 × 956 (Home Screen),
|
|
33
|
+
838 × 390 (landscape Safari), and smaller phone widths. Check login, workspace search and switching,
|
|
34
|
+
lane selection, pane navigation, font controls, rotation and the visible viewport while the keyboard opens.
|
|
35
|
+
Use disposable view sessions and revoke any temporary test token afterward.
|
|
Binary file
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
|
|
2
|
+
<rect width="192" height="192" rx="42" fill="#101318"/>
|
|
3
|
+
<g fill="#b4e3bf"><rect x="44" y="44" width="104" height="24" rx="8"/><rect x="44" y="84" width="80" height="24" rx="8"/><rect x="44" y="124" width="56" height="24" rx="8"/></g>
|
|
4
|
+
</svg>
|