@agent-compose/sdk 0.8.2 → 0.8.3

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.
Files changed (40) hide show
  1. package/dist/agent/__tests__/perf-sampler.test.d.ts +10 -0
  2. package/dist/agent/agent-context.d.ts +1 -1
  3. package/dist/agent/agent-loop.d.ts +5 -1
  4. package/dist/agent/desktop-open.d.ts +184 -0
  5. package/dist/agent/perf-sampler.d.ts +99 -0
  6. package/dist/agent/services-manifest.d.ts +88 -0
  7. package/dist/agent/services-restore.d.ts +58 -0
  8. package/dist/client.d.ts +164 -8
  9. package/dist/display.d.ts +17 -0
  10. package/dist/index.d.ts +13 -4
  11. package/dist/index.js +1374 -51
  12. package/dist/runtimes/_cli-agent.d.ts +347 -2
  13. package/dist/runtimes/claude-code.d.ts +12 -0
  14. package/dist/runtimes/codex.d.ts +8 -0
  15. package/dist/runtimes/openai-desktop.js +1312 -51
  16. package/dist/runtimes/session-env.test.d.ts +14 -0
  17. package/dist/types/api-conversations.d.ts +309 -1
  18. package/dist/types/api-factory.d.ts +115 -10
  19. package/dist/types/api-runs.d.ts +21 -0
  20. package/dist/types/protocol.d.ts +32 -1
  21. package/dist/types/runtime.d.ts +120 -0
  22. package/package.json +1 -1
  23. package/src/agent/agent-context.ts +100 -11
  24. package/src/agent/agent-loop.ts +10 -3
  25. package/src/agent/desktop-open.ts +418 -0
  26. package/src/agent/perf-sampler.ts +202 -0
  27. package/src/agent/services-manifest.ts +356 -0
  28. package/src/agent/services-restore.ts +195 -0
  29. package/src/client.ts +328 -12
  30. package/src/display.ts +44 -1
  31. package/src/index.ts +63 -1
  32. package/src/runtimes/_cli-agent.ts +891 -35
  33. package/src/runtimes/claude-code.ts +187 -12
  34. package/src/runtimes/codex.ts +58 -1
  35. package/src/sandbox/providers/local.ts +16 -4
  36. package/src/types/api-conversations.ts +307 -3
  37. package/src/types/api-factory.ts +118 -10
  38. package/src/types/api-runs.ts +23 -0
  39. package/src/types/protocol.ts +30 -1
  40. package/src/types/runtime.ts +122 -0
@@ -0,0 +1,418 @@
1
+ /**
2
+ * `ac-open` — the in-guest desktop launcher that SURVIVES the command that
3
+ * invoked it.
4
+ *
5
+ * Why it exists (the 2026-08-16 cloud-desktop transcript): the sandbox exec
6
+ * layer (E2B envd) runs each command as its own session/process group and
7
+ * reaps that whole group when the command returns — and a child that inherits
8
+ * the exec's stdout/stderr pipes tethers the exec's stream to its own
9
+ * lifetime. So a GUI app launched with a plain `&` dies the moment the
10
+ * launching shell finishes ("It exited when the shell finished" — the agent's
11
+ * own words, after 8 turns of desktop archaeology). This is the SAME failure
12
+ * class as the v0.10.39/40 runner regression, fixed there with
13
+ * `setsid + >/dev/null 2>&1 </dev/null + pidfile` (sdk/src/runtimes/
14
+ * _cli-agent.ts — see its LOAD-BEARING comment). `ac-open` packages that
15
+ * exact discipline as a one-word verb so no agent ever has to rediscover it.
16
+ *
17
+ * ONE script, TWO delivery doors (the ac-record pattern,
18
+ * server/src/sandbox/attach/desktop-recorder.ts):
19
+ * - BAKED into the desktop-carrying E2B images by the template recipe
20
+ * (infra/e2b-template/parts.ts DESKTOP_OPEN_INSTALL) — covers workflow
21
+ * runs and fresh session images;
22
+ * - INSTALLED at session desktop-ensure by the server
23
+ * (server/src/sandbox/persistent.ts ensureSessionDesktop) — covers
24
+ * GRANDFATHERED session images that predate the bake, on every fresh boot.
25
+ * Both doors run `installDesktopOpenCmd()`, a plain truncating rewrite, so a
26
+ * contract change reaches old VMs on the next boot.
27
+ *
28
+ * The install also shims `xdg-open` and `sensible-browser` at /usr/local/bin
29
+ * (which precedes /usr/bin on PATH — the VS Code wrapper precedent), so
30
+ * generic "open a URL" flows inherit the detach semantics instead of dying
31
+ * with the exec.
32
+ *
33
+ * This module composes STRINGS only (pure + unit-tested, the
34
+ * desktop-recorder.ts posture); nothing here touches a sandbox.
35
+ */
36
+
37
+ /** Where the launcher lands on the guest PATH. */
38
+ export const AC_OPEN_PATH = "/usr/local/bin/ac-open";
39
+
40
+ /** SHARED in-guest state dir: the browser-backfill markers, written by ROOT
41
+ * (the backfill runs sudo) and only ever READ by ac-open. /tmp (ext4) —
42
+ * machine-tier state that dies with the VM, exactly like the recorder's.
43
+ *
44
+ * ac-open's own pidfiles + logs do NOT live here — they live in a PER-UID
45
+ * sibling (`${AC_OPEN_STATE_DIR}.<uid>`, composed in the script). The
46
+ * 2026-08-21 owner transcript is why: the backfill's root-owned `mkdir -p`
47
+ * landed this dir 0755 root:root, the session user's `>>"$LOG"` redirect
48
+ * then failed, and the spawned subshell died before chromium ever ran —
49
+ * surfacing as "chromium exited immediately: see …/browser.log" with an
50
+ * EMPTY log (the redirect that would have written it is what failed). A
51
+ * per-uid dir makes the launcher's writes collision-free for every
52
+ * identity (root workflow runner AND the default session user). */
53
+ export const AC_OPEN_STATE_DIR = "/tmp/.ac-desktop-open";
54
+
55
+ /** Generic-open entrypoints routed through the launcher. */
56
+ export const AC_OPEN_SHIM_PATHS = [
57
+ "/usr/local/bin/xdg-open",
58
+ "/usr/local/bin/sensible-browser",
59
+ ] as const;
60
+
61
+ /**
62
+ * Browser-backfill marker protocol (self-healing browser presence). The
63
+ * server's boot-time desktop-ensure detects a desktop-carrying image WITHOUT
64
+ * chromium — only possible on GRANDFATHERED bakes, where the chromium install
65
+ * was tolerant (`|| true`) before the 2026-08-16 hard-install — and
66
+ * apt-installs it in the BACKGROUND, with the same setsid + stdio-redirect +
67
+ * pidfile detach discipline ac-open itself uses, so no boot path ever blocks
68
+ * on apt (20-40s). These markers are the shared contract: `browserBackfillCmd`
69
+ * writes them, `browserBackfillStatusCmd` and the ac-open script read them —
70
+ * that's how ac-open answers the THIRD state honestly (browser missing but
71
+ * ARRIVING — retry) instead of "no browser, give up".
72
+ */
73
+ export const BROWSER_BACKFILL_PIDFILE = `${AC_OPEN_STATE_DIR}/browser-install.pid`;
74
+ export const BROWSER_BACKFILL_LOG = `${AC_OPEN_STATE_DIR}/browser-install.log`;
75
+ export const BROWSER_BACKFILL_FAILED = `${AC_OPEN_STATE_DIR}/browser-install.failed`;
76
+
77
+ /** The PINNED third-state line ac-open prints while the background install
78
+ * runs: honest, actionable, and never an invitation to give up or to mount
79
+ * an apt expedition of the agent's own. */
80
+ export const BROWSER_INSTALL_IN_PROGRESS_MSG =
81
+ "ac-open: no browser YET — the platform is installing chromium in the background right now; retry this exact command in ~30s";
82
+
83
+ /**
84
+ * The `ac-open` script body. POSIX sh (dash is /bin/sh on the Debian base).
85
+ *
86
+ * Contract, pinned by the unit tests:
87
+ * - every spawn is `setsid <cmd> </dev/null >>"$LOG" 2>&1 &` — its own
88
+ * session (survives the exec's process-group reap), stdio OFF the exec's
89
+ * pipes (so the exec's stream can settle), a durable pidfile;
90
+ * - pidfiles + logs live in a PER-UID state dir (`$D`), so the root runner
91
+ * and the session user never fight over one root-owned dir (the
92
+ * 2026-08-21 "exited immediately, empty log" failure — see
93
+ * AC_OPEN_STATE_DIR's header); the shared dir (`$M`) is read-only here,
94
+ * for the backfill markers root writes;
95
+ * - IDEMPOTENT: re-invoking for a running app FOCUSES its window
96
+ * (xdotool windowactivate) instead of spawning a second copy; the
97
+ * browser and file manager are singletons, so a re-invoke hands the
98
+ * target to the existing instance (a new tab / window) by design.
99
+ * Liveness checks are SAME-UID only (`pgrep -u`): another identity's
100
+ * instance shares neither profile nor single-instance socket, so it can
101
+ * neither take a hand-off nor make this spawn redundant. And a fresh
102
+ * spawn that exits within the liveness window while a same-uid instance
103
+ * is alive IS the hand-off (single-instance apps hand the target over
104
+ * and exit 0) — reported as success, never as a crash. N calls =
105
+ * N tabs, zero crashes;
106
+ * - HONEST degrades, one line each: no display → say so; no browser /
107
+ * unknown app → name the missing tool and tell the agent to REPORT it
108
+ * rather than mount a package-manager expedition. A REAL immediate
109
+ * death carries the app's own log tail as the cause.
110
+ */
111
+ export const AC_OPEN_SCRIPT = `#!/bin/sh
112
+ # ac-open — open a URL, file, or GUI app on this machine's desktop, DETACHED.
113
+ #
114
+ # ac-open https://github.com the browser (a running instance gets a tab)
115
+ # ac-open ./report.html a local file, in the browser
116
+ # ac-open . a directory, in the file manager
117
+ # ac-open gimp [args...] any GUI app by command name
118
+ #
119
+ # Why: the sandbox reaps each exec'd command's process group when the command
120
+ # returns, so a GUI app launched with a plain \`&\` dies the moment the
121
+ # launching shell finishes. ac-open detaches the app into its own session
122
+ # (setsid, stdio off the exec's pipes), records a pidfile, and returns at
123
+ # once; re-invoking it for a running app focuses the existing window instead
124
+ # of spawning a second copy. Logs + pidfiles: /tmp/.ac-desktop-open.<uid>/.
125
+ # Written by agent-compose (sdk/src/agent/desktop-open.ts) — do not edit in place.
126
+ set -u
127
+ export DISPLAY="\${DISPLAY:-:0}"
128
+ # Per-UID state (pidfiles + logs): the shared dir is root-owned when the
129
+ # backfill created it, and a user-side >>$LOG into a root 0755 dir fails
130
+ # BEFORE the app runs — the false "exited immediately" with an empty log.
131
+ D="/tmp/.ac-desktop-open.$(id -u)"
132
+ # Shared, root-written backfill markers — READ-ONLY here.
133
+ M=/tmp/.ac-desktop-open
134
+ mkdir -p "$D" 2>/dev/null || true
135
+ [ -w "$D" ] || { echo "ac-open: state dir $D is not writable by uid $(id -u)" >&2; exit 1; }
136
+
137
+ [ $# -ge 1 ] || { echo 'usage: ac-open <url|file|dir|app> [args...]' >&2; exit 2; }
138
+ TARGET=$1; shift
139
+
140
+ # No display = no desktop on this image. One honest line, no expedition.
141
+ if ! timeout 3 xdotool getdisplaygeometry >/dev/null 2>&1; then
142
+ echo "ac-open: no desktop display on $DISPLAY — this machine has no GUI session up" >&2
143
+ exit 1
144
+ fi
145
+
146
+ pick_browser() {
147
+ for b in chromium chromium-browser x-www-browser google-chrome; do
148
+ command -v "$b" >/dev/null 2>&1 && { echo "$b"; return 0; }
149
+ done
150
+ return 1
151
+ }
152
+
153
+ # Best-effort: raise the newest visible window whose class matches $1.
154
+ focus_class() {
155
+ WID=$(xdotool search --onlyvisible --class "$1" 2>/dev/null | tail -1) || WID=''
156
+ [ -n "\${WID:-}" ] && xdotool windowactivate "$WID" 2>/dev/null || true
157
+ }
158
+
159
+ # Same-UID instance probe: another identity's instance shares neither profile
160
+ # nor single-instance socket, so only our own uid's counts as "running".
161
+ alive_same_uid() { pgrep -x -u "$(id -u)" "$1" >/dev/null 2>&1; }
162
+
163
+ # Singleton apps (browser, file manager): the second invocation hands the
164
+ # target to the running instance and exits — that IS the idempotent path, so
165
+ # spawn detached every time and only liveness-check a FRESH instance.
166
+ singleton_open() { # $1 = state key, rest = command + target
167
+ NAME=$1; shift
168
+ BIN=$(basename "$1")
169
+ PIDFILE=$D/$NAME.pid; LOG=$D/$NAME.log
170
+ RUNNING=0
171
+ alive_same_uid "$BIN" && RUNNING=1
172
+ [ "$RUNNING" = 0 ] && [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null && RUNNING=1
173
+ setsid "$@" </dev/null >>"$LOG" 2>&1 &
174
+ PID=$!
175
+ if [ "$RUNNING" = 1 ]; then
176
+ sleep 1
177
+ focus_class "$BIN"
178
+ echo "ac-open: handed off to the running $BIN — no second window"
179
+ return 0
180
+ fi
181
+ echo "$PID" > "$PIDFILE"
182
+ sleep 1
183
+ if ! kill -0 "$PID" 2>/dev/null; then
184
+ # Died within the liveness window — but if OUR instance of $BIN is alive
185
+ # NOW, this was the single-instance HAND-OFF: the pre-check raced a
186
+ # still-starting instance, the fresh process handed the target over and
187
+ # exited 0. That is success (N calls = N tabs), never a crash report.
188
+ if alive_same_uid "$BIN"; then
189
+ focus_class "$BIN"
190
+ echo "ac-open: handed off to the running $BIN — no second window"
191
+ return 0
192
+ fi
193
+ TAIL=$(tail -c 300 "$LOG" 2>/dev/null | tr '\\n' ' ')
194
+ echo "ac-open: $BIN exited immediately: \${TAIL:-no output captured — see $LOG}" >&2
195
+ exit 1
196
+ fi
197
+ focus_class "$BIN"
198
+ echo "ac-open: launched $BIN (pid $PID), detached — it survives this command"
199
+ }
200
+
201
+ open_url() { # $1 = url
202
+ B=$(pick_browser) || {
203
+ # Grandfathered image mid-heal: the boot-time desktop-ensure found no
204
+ # browser and detached an apt install (browserBackfillCmd — the pidfile
205
+ # below is ITS single-flight marker, root-written in the SHARED dir).
206
+ # Third honest state: arriving.
207
+ if [ -f "$M/browser-install.pid" ] && kill -0 "$(cat "$M/browser-install.pid" 2>/dev/null)" 2>/dev/null; then
208
+ echo "${BROWSER_INSTALL_IN_PROGRESS_MSG}" >&2
209
+ exit 75
210
+ fi
211
+ if [ -f "$M/browser-install.failed" ]; then
212
+ TAIL=$(tail -c 300 "$M/browser-install.log" 2>/dev/null | tr '\\n' ' ')
213
+ echo "ac-open: the platform's background chromium install FAILED: \${TAIL:-see $M/browser-install.log} — report that cause in one line; do not apt-get one yourself." >&2
214
+ exit 1
215
+ fi
216
+ echo "ac-open: no graphical browser on this image (expected chromium) — cannot open $1. Report the missing browser in one line; do not apt-get one mid-session." >&2
217
+ exit 1
218
+ }
219
+ singleton_open browser "$B" "$1"
220
+ }
221
+
222
+ open_app() { # $1 = command, rest = args
223
+ command -v "$1" >/dev/null 2>&1 || {
224
+ echo "ac-open: '$1' is not installed on this machine — report the missing tool in one line instead of package-hunting for it" >&2
225
+ exit 127
226
+ }
227
+ BIN=$(basename "$1"); PIDFILE=$D/app-$BIN.pid; LOG=$D/app-$BIN.log
228
+ OLD=$(cat "$PIDFILE" 2>/dev/null || true)
229
+ if [ -n "\${OLD:-}" ] && kill -0 "$OLD" 2>/dev/null; then
230
+ WID=$(xdotool search --onlyvisible --pid "$OLD" 2>/dev/null | tail -1) || WID=''
231
+ [ -z "\${WID:-}" ] && { WID=$(xdotool search --onlyvisible --class "$BIN" 2>/dev/null | tail -1) || WID=''; }
232
+ [ -n "\${WID:-}" ] && xdotool windowactivate "$WID" 2>/dev/null || true
233
+ echo "ac-open: $BIN is already running (pid $OLD) — focused it instead of starting a second copy"
234
+ exit 0
235
+ fi
236
+ CMD=$1; shift
237
+ setsid "$CMD" "$@" </dev/null >>"$LOG" 2>&1 &
238
+ PID=$!
239
+ echo "$PID" > "$PIDFILE"
240
+ sleep 1
241
+ if ! kill -0 "$PID" 2>/dev/null; then
242
+ # Same hand-off grace as the singletons: VS Code and friends are
243
+ # single-instance too — a fresh spawn that exits at once while our own
244
+ # instance is alive HANDED OFF, it did not crash.
245
+ if alive_same_uid "$BIN"; then
246
+ focus_class "$BIN"
247
+ echo "ac-open: handed off to the running $BIN — no second window"
248
+ exit 0
249
+ fi
250
+ TAIL=$(tail -c 300 "$LOG" 2>/dev/null | tr '\\n' ' ')
251
+ echo "ac-open: $BIN exited immediately: \${TAIL:-no output captured — see $LOG}" >&2
252
+ exit 1
253
+ fi
254
+ echo "ac-open: launched $BIN (pid $PID), detached — it survives this command"
255
+ }
256
+
257
+ case "$TARGET" in
258
+ http://*|https://*|file://*|about:*|chrome://*)
259
+ open_url "$TARGET" ;;
260
+ localhost|localhost:*|localhost/*|127.0.0.1|127.0.0.1:*|127.0.0.1/*)
261
+ open_url "http://$TARGET" ;;
262
+ *)
263
+ if [ -d "$TARGET" ]; then
264
+ command -v pcmanfm >/dev/null 2>&1 || {
265
+ echo "ac-open: no file manager on this image (expected pcmanfm) — report it in one line" >&2
266
+ exit 1
267
+ }
268
+ case "$TARGET" in /*) ABS=$TARGET ;; *) ABS=$PWD/$TARGET ;; esac
269
+ singleton_open files pcmanfm "$ABS"
270
+ elif [ -f "$TARGET" ]; then
271
+ case "$TARGET" in /*) ABS=$TARGET ;; *) ABS=$PWD/$TARGET ;; esac
272
+ open_url "file://$ABS"
273
+ else
274
+ open_app "$TARGET" "$@"
275
+ fi ;;
276
+ esac
277
+ `;
278
+
279
+ /** Shim body for the generic-open entrypoints (`xdg-open`,
280
+ * `sensible-browser`): exec straight into the detaching launcher, so any
281
+ * tool that "opens a URL" survives its exec too. */
282
+ export const AC_OPEN_SHIM = `#!/bin/sh
283
+ # agent-compose: generic open routes through ac-open (the detaching launcher).
284
+ exec ${AC_OPEN_PATH} "$@"
285
+ `;
286
+
287
+ /**
288
+ * Install (or refresh) `ac-open` + the generic-open shims — run as root.
289
+ * The script carries `$`/quotes/backslashes, so it rides the base64 idiom
290
+ * (the WRITE_AGENTS_MD / installRecorderCmd pattern): escaping-proof through
291
+ * the sandbox command channel. Always a plain truncating rewrite — the files
292
+ * are wholly platform-owned and tiny, so idempotence is free and a contract
293
+ * change reaches grandfathered VMs on the next session boot. Each write is
294
+ * `sh -n`-checked so a corrupted transit fails the install loudly, never the
295
+ * agent's first `ac-open`.
296
+ */
297
+ export function installDesktopOpenCmd(): string {
298
+ const scriptB64 = Buffer.from(AC_OPEN_SCRIPT, "utf8").toString("base64");
299
+ const shimB64 = Buffer.from(AC_OPEN_SHIM, "utf8").toString("base64");
300
+ return [
301
+ `printf %s '${scriptB64}' | base64 -d > ${AC_OPEN_PATH}`,
302
+ `chmod 0755 ${AC_OPEN_PATH}`,
303
+ `sh -n ${AC_OPEN_PATH}`,
304
+ ...AC_OPEN_SHIM_PATHS.flatMap((p) => [
305
+ `printf %s '${shimB64}' | base64 -d > ${p}`,
306
+ `chmod 0755 ${p}`,
307
+ `sh -n ${p}`,
308
+ ]),
309
+ ].join(" && ");
310
+ }
311
+
312
+ /**
313
+ * Chromium session-desktop defaults — SINGLE-SOURCED here (the ac-open
314
+ * two-door pattern): baked into fresh images by infra/e2b-template/parts.ts
315
+ * (CHROMIUM_DEFAULTS_INSTALL), re-installed at session desktop-ensure by the
316
+ * server (persistent.ts), and written by the browser backfill right after it
317
+ * lands chromium on a grandfathered image — so a backfilled browser behaves
318
+ * exactly like a baked one. Before this, a backfilled chromium had NO flags
319
+ * file: a root launch died on the missing --no-sandbox ("exited
320
+ * immediately"), and every launch paid the first-run wizard.
321
+ *
322
+ * Debian's /usr/bin/chromium wrapper dot-sources every /etc/chromium.d/*
323
+ * fragment, so these flags apply to EVERY door into the browser — the dock
324
+ * launcher, the openbox menu, ac-open, an agent's bare `chromium`.
325
+ *
326
+ * Flag notes:
327
+ * --disable-gpu — the session display is Xkasmvnc, which exposes NO GLX
328
+ * (probe-verified in a live sandbox, 2026-08-21). Without the flag,
329
+ * chromium's GPU process walks both EGL display types (OpenGL, then
330
+ * OpenGLES) through ANGLE, fails both ("Initialization of all (2) EGL
331
+ * display types failed"), respawns, and only then falls back to the
332
+ * software raster it was always going to use — measured at 200-500ms of
333
+ * pure startup waste per launch on an idle host, worse on a shared
334
+ * 2-vCPU guest. Software WebGL (SwiftShader) still works.
335
+ * --disable-session-crashed-bubble — a paused/killed VM otherwise greets
336
+ * every reattach with the restore bubble.
337
+ * --no-sandbox only under root — chromium refuses to start as root
338
+ * without it; the per-session microVM is the isolation boundary.
339
+ */
340
+ export const CHROMIUM_DEFAULTS_PATH = "/etc/chromium.d/00-ac-defaults";
341
+
342
+ export const CHROMIUM_DEFAULTS = `# agent-compose session-desktop chromium defaults
343
+ # (single-sourced in sdk/src/agent/desktop-open.ts — sourced by /usr/bin/chromium)
344
+ CHROMIUM_FLAGS="$CHROMIUM_FLAGS --no-first-run --no-default-browser-check --password-store=basic --disable-session-crashed-bubble --disable-dev-shm-usage --start-maximized --disable-gpu"
345
+ if [ "$(id -u)" = "0" ]; then CHROMIUM_FLAGS="$CHROMIUM_FLAGS --no-sandbox"; fi
346
+ `;
347
+
348
+ /**
349
+ * Install (or refresh) the chromium defaults fragment — run as root.
350
+ * QUOTE-FREE by construction (base64 payload, no quoting anywhere): the
351
+ * string embeds verbatim inside browserBackfillCmd's single-quoted `sh -c`
352
+ * payload as well as standing alone in a template runCmd / an ensure exec.
353
+ */
354
+ export function installChromiumDefaultsCmd(): string {
355
+ const b64 = Buffer.from(CHROMIUM_DEFAULTS, "utf8").toString("base64");
356
+ return [
357
+ "mkdir -p /etc/chromium.d",
358
+ `printf %s ${b64} | base64 -d > ${CHROMIUM_DEFAULTS_PATH}`,
359
+ `chmod 0644 ${CHROMIUM_DEFAULTS_PATH}`,
360
+ `sh -n ${CHROMIUM_DEFAULTS_PATH}`,
361
+ ].join(" && ");
362
+ }
363
+
364
+ /**
365
+ * Probe-then-heal for the browser on a GRANDFATHERED image — run as root at
366
+ * session desktop-ensure (server/src/sandbox/attach/browser-backfill.ts).
367
+ *
368
+ * Scope is exactly chromium, deliberately not a package set: every desktop
369
+ * bake since the first (8329c9c4) hard-installs xdotool/scrot/pcmanfm in the
370
+ * SAME `&&`-joined apt line as the desktop stack itself, so any image that
371
+ * probes a desktop necessarily carries them — chromium alone rode a tolerant
372
+ * `|| true` until the 2026-08-16 hard-install, making it the only
373
+ * manual-promised desktop tool that can be missing. (ac-open and ac-record
374
+ * are re-installed on every fresh boot by ensureSessionDesktop already.)
375
+ *
376
+ * Shape: a three-way verdict on stdout (the desktop-capability-probe idiom —
377
+ * `backfill=present` / `backfill=in-progress` / `backfill=started`), and on
378
+ * `started` the apt install runs DETACHED — `setsid … </dev/null >>log 2>&1 &`
379
+ * plus a pidfile, the exact ac-open/cli-agent discipline — so this command
380
+ * returns in milliseconds while apt takes its 20-40s. Single-flight is the
381
+ * pidfile liveness gate (a second ensure sees `in-progress` and starts
382
+ * nothing); apt's own dpkg lock backstops the millisecond race window. The
383
+ * installer's last act is its verdict: a failure touches the `.failed` marker
384
+ * (cause in the log) and either way the pidfile is removed, so a machine
385
+ * suspended mid-install simply retries on its next fresh boot.
386
+ */
387
+ export function browserBackfillCmd(): string {
388
+ const pid = BROWSER_BACKFILL_PIDFILE;
389
+ // The defaults install rides INSIDE the success chain: a backfilled
390
+ // chromium without its flags fragment still crashes every root launch
391
+ // (missing --no-sandbox — the 2026-08-21 transcript's other half), so a
392
+ // browser without flags is a failed heal, reported as one.
393
+ // installChromiumDefaultsCmd() is quote-free by construction, so it embeds
394
+ // verbatim in this single-quoted payload.
395
+ return (
396
+ `mkdir -p ${AC_OPEN_STATE_DIR} && ` +
397
+ `if command -v chromium >/dev/null 2>&1; then echo backfill=present; ` +
398
+ `elif [ -f ${pid} ] && kill -0 "$(cat ${pid} 2>/dev/null)" 2>/dev/null; then echo backfill=in-progress; ` +
399
+ `else rm -f ${BROWSER_BACKFILL_FAILED}; ` +
400
+ `setsid sh -c 'if apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get install -y -q chromium && command -v chromium >/dev/null 2>&1 && ${installChromiumDefaultsCmd()}; then echo backfill-install=ok; else echo backfill-install=failed; touch ${BROWSER_BACKFILL_FAILED}; fi; rm -f ${pid}' </dev/null >>${BROWSER_BACKFILL_LOG} 2>&1 & ` +
401
+ `echo $! > ${pid}; echo backfill=started; fi`
402
+ );
403
+ }
404
+
405
+ /**
406
+ * The watcher's status probe (read-only, safe from any user): `backfill=ok` /
407
+ * `backfill=failed` (+ the log tail as the cause, one line) /
408
+ * `backfill=in-progress` / `backfill=lost` (installer died without a verdict
409
+ * — suspend/recycle mid-apt; the next fresh boot's ensure retries).
410
+ */
411
+ export function browserBackfillStatusCmd(): string {
412
+ return (
413
+ `if command -v chromium >/dev/null 2>&1; then echo backfill=ok; ` +
414
+ `elif [ -f ${BROWSER_BACKFILL_FAILED} ]; then echo backfill=failed; tail -c 300 ${BROWSER_BACKFILL_LOG} 2>/dev/null | tr '\\n' ' '; ` +
415
+ `elif [ -f ${BROWSER_BACKFILL_PIDFILE} ] && kill -0 "$(cat ${BROWSER_BACKFILL_PIDFILE} 2>/dev/null)" 2>/dev/null; then echo backfill=in-progress; ` +
416
+ `else echo backfill=lost; fi`
417
+ );
418
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Guest-side session perf sampling — the `<promptPath>.perf` token contract.
3
+ *
4
+ * The session guest has NO push channel: every durable signal is a flat file
5
+ * next to the turn's prompt path, read by bounded server execs (the durable
6
+ * trio — see tailer.ts / _cli-agent.ts). Perf sampling rides that exact
7
+ * plane:
8
+ *
9
+ * - LIVE turn: the runner's 10s heartbeat subshell calls `ac_perf_tick`
10
+ * each beat; every 6th beat (~60s) it burst-reads /proc (stat jiffies
11
+ * delta, loadavg, meminfo) plus one `df -P`, and rewrites the perf
12
+ * token file atomically. The subshell dies with the wrapper, so
13
+ * sampling stops within one beat of turn end — it can never keep the
14
+ * setsid group alive (the 2026-08-17 spurious-wake class).
15
+ * - The token then rides the EXISTING durable-trio probe exec as one
16
+ * trailing field (`turnLivenessProbeCommand`) — no new exec, no new
17
+ * socket, ~70 extra bytes on a line the server already pulls.
18
+ * - PARKED-AWAKE: the server's 5-min compute-span checkpoint (which
19
+ * already holds the provider's RUNNING list — a suspended machine is
20
+ * structurally never probed) runs `standalonePerfProbeCommand`: the
21
+ * same read burst twice across a short window, one bounded exec.
22
+ *
23
+ * Token format (ONE whitespace-free field, so it can ride a space-split
24
+ * probe line): `v=1,cpu=12,l1=0.42,mem=37,dsk=52,vc=2,ram=4096,ts=1755…`
25
+ * cpu — busy % of all vcpus over the sampling window (-1 = no window yet)
26
+ * l1 — 1-min loadavg (-1 = unreadable)
27
+ * mem — used % of MemTotal, via MemAvailable (-1 = unreadable)
28
+ * dsk — used % of the root filesystem (-1 = unreadable)
29
+ * vc — vcpu count (cpuN lines in /proc/stat)
30
+ * ram — MemTotal in MB (the sandbox-size context the fold tags by)
31
+ * ts — guest clock, epoch seconds (dedupe only, never arithmetic)
32
+ *
33
+ * The guest is hostile by doctrine (registry-activities.ts house rule):
34
+ * `parsePerfToken` clamps every field to its closed range and nulls
35
+ * anything out of bounds — server folds re-use this parser, so no guest
36
+ * number reaches a metric or a Temporal payload unclamped.
37
+ */
38
+
39
+ import { shellQuote } from "../runtimes/_cli-agent.js";
40
+
41
+ /** Sample every Nth heartbeat beat (10s beats → ~60s cadence). */
42
+ export const PERF_SAMPLE_EVERY_BEATS = 6;
43
+
44
+ /** CPU window of the standalone (parked-awake) probe, seconds. */
45
+ export const PERF_PROBE_WINDOW_SECONDS = 2;
46
+
47
+ /** The standalone probe's output line leads with this prefix. */
48
+ export const PERF_PROBE_LINE_PREFIX = "perf ";
49
+
50
+ /** Longest token the parsers accept — anything bigger is garbage. */
51
+ export const PERF_TOKEN_MAX_CHARS = 200;
52
+
53
+ /** One clamped guest perf sample. Null fields = unreadable/absent on the
54
+ * guest — never zero-filled (a zero is a claim; null is honesty). */
55
+ export interface GuestPerfSample {
56
+ /** Busy % of all vcpus over the sample window, 0–100. */
57
+ cpuBusyPct: number | null;
58
+ /** 1-minute load average. */
59
+ load1: number | null;
60
+ /** Used % of MemTotal (MemAvailable-based), 0–100. */
61
+ memUsedPct: number | null;
62
+ /** Used % of the root filesystem, 0–100. */
63
+ diskUsedPct: number | null;
64
+ /** vcpu count — the sandbox-size context tag. */
65
+ vcpus: number | null;
66
+ /** MemTotal in MB — the other half of the size context. */
67
+ ramMb: number | null;
68
+ /** Guest clock at sample time, epoch seconds. Dedupe only. */
69
+ sampledAtS: number | null;
70
+ }
71
+
72
+ export interface PerfSamplerPaths {
73
+ /** Durable token file (`<promptPath>.perf`). */
74
+ perfPath: string;
75
+ /** Override for tests ONLY — fixture dir standing in for /proc. */
76
+ procRoot?: string;
77
+ /** Filesystem to `df` (default "/"). */
78
+ diskPath?: string;
79
+ }
80
+
81
+ /**
82
+ * POSIX-sh function definitions for the read burst. `ac_perf_read` reads
83
+ * /proc once, computes the jiffies delta against the PREVIOUS call (state
84
+ * lives in shell variables — the heartbeat subshell persists across beats,
85
+ * so no state file), and leaves the finished token in `$ac_tok`. All
86
+ * `ac_p*` variable names are prefixed to stay clear of the loop's own
87
+ * `ac_busy` / `ac_idle` vocabulary.
88
+ *
89
+ * Every read is `2>/dev/null`-guarded and every field degrades to -1 — a
90
+ * missing /proc entry can never wedge the heartbeat loop.
91
+ */
92
+ function perfReadFunctionFragment(procRoot: string, diskPath: string): string {
93
+ const stat = shellQuote(`${procRoot}/stat`);
94
+ const loadavg = shellQuote(`${procRoot}/loadavg`);
95
+ const meminfo = shellQuote(`${procRoot}/meminfo`);
96
+ const disk = shellQuote(diskPath);
97
+ return `ac_perf_read() { `
98
+ // cpu line: user nice system idle iowait irq softirq steal …
99
+ + `IFS=' ' read -r ac_pl ac_pu ac_pn2 ac_psy ac_pid ac_pio ac_pirq ac_psq ac_pst ac_prest 2>/dev/null < ${stat} || return 0; `
100
+ + `ac_pbz=$(( \${ac_pu:-0} + \${ac_pn2:-0} + \${ac_psy:-0} + \${ac_pirq:-0} + \${ac_psq:-0} + \${ac_pst:-0} )); `
101
+ + `ac_ptt=$(( ac_pbz + \${ac_pid:-0} + \${ac_pio:-0} )); `
102
+ + `ac_pcpu=-1; `
103
+ + `if [ "\${ac_ppt:-0}" -gt 0 ] && [ "$ac_ptt" -gt "\${ac_ppt:-0}" ]; then `
104
+ + `ac_pcpu=$(( 100 * (ac_pbz - \${ac_ppb:-0}) / (ac_ptt - ac_ppt) )); `
105
+ + `[ "$ac_pcpu" -lt 0 ] && ac_pcpu=0; [ "$ac_pcpu" -gt 100 ] && ac_pcpu=100; `
106
+ + `fi; `
107
+ + `ac_ppb=$ac_pbz; ac_ppt=$ac_ptt; `
108
+ + `IFS=' ' read -r ac_pl1 ac_plr 2>/dev/null < ${loadavg} || ac_pl1=-1; `
109
+ + `ac_pvc=$(grep -c '^cpu[0-9]' ${stat} 2>/dev/null); [ -n "$ac_pvc" ] || ac_pvc=0; `
110
+ + `ac_pmt=$(awk '$1=="MemTotal:"{print $2}' ${meminfo} 2>/dev/null); `
111
+ + `ac_pma=$(awk '$1=="MemAvailable:"{print $2}' ${meminfo} 2>/dev/null); `
112
+ + `ac_pmem=-1; `
113
+ + `if [ "\${ac_pmt:-0}" -gt 0 ] && [ -n "\${ac_pma:-}" ]; then ac_pmem=$(( 100 * (ac_pmt - ac_pma) / ac_pmt )); fi; `
114
+ + `[ "$ac_pmem" -gt 100 ] && ac_pmem=100; [ "$ac_pmem" -lt -1 ] && ac_pmem=-1; `
115
+ + `ac_pdsk=$(df -kP ${disk} 2>/dev/null | awk 'NR==2 && $2>0 {print int(100*$3/$2)}'); `
116
+ + `ac_tok="v=1,cpu=$ac_pcpu,l1=\${ac_pl1:--1},mem=$ac_pmem,dsk=\${ac_pdsk:--1},vc=\${ac_pvc:-0},ram=$(( \${ac_pmt:-0} / 1024 )),ts=$(date +%s)"; `
117
+ + `}; `;
118
+ }
119
+
120
+ /**
121
+ * The heartbeat-loop member: function definitions plus `ac_perf_tick`,
122
+ * which gates the burst to every `PERF_SAMPLE_EVERY_BEATS`th beat (beat 1
123
+ * establishes the jiffies baseline — its token carries cpu=-1; beat 7 is
124
+ * the first real cpu window) and rewrites the token file atomically
125
+ * (tmp + `mv -f`, so a concurrent probe `head` reads old-or-new, never a
126
+ * torn write). Place the returned fragment INSIDE the heartbeat subshell,
127
+ * before its `while`; call `ac_perf_tick` once per beat in the body.
128
+ */
129
+ export function perfSamplerFunctionFragment(paths: PerfSamplerPaths): string {
130
+ const perf = shellQuote(paths.perfPath);
131
+ const perfTmp = shellQuote(`${paths.perfPath}.tmp`);
132
+ return perfReadFunctionFragment(paths.procRoot ?? "/proc", paths.diskPath ?? "/")
133
+ + `ac_perf_tick() { `
134
+ + `ac_pbeat=$(( \${ac_pbeat:-0} + 1 )); `
135
+ + `[ $(( (ac_pbeat - 1) % ${PERF_SAMPLE_EVERY_BEATS} )) -eq 0 ] || return 0; `
136
+ + `ac_perf_read; `
137
+ + `[ -n "\${ac_tok:-}" ] || return 0; `
138
+ + `printf '%s\\n' "$ac_tok" > ${perfTmp} 2>/dev/null && mv -f ${perfTmp} ${perf} 2>/dev/null; `
139
+ + `}; `;
140
+ }
141
+
142
+ /**
143
+ * Self-contained one-exec probe for the parked-awake lane (the server's
144
+ * 5-min compute-span checkpoint): two read bursts across a short window
145
+ * give a real cpu delta, then the token prints to stdout as
146
+ * `perf <token>`. Bounded by the caller's exec timeout; ~window+ε runtime.
147
+ */
148
+ export function standalonePerfProbeCommand(opts: {
149
+ procRoot?: string; diskPath?: string; windowSeconds?: number;
150
+ } = {}): string {
151
+ const win = opts.windowSeconds ?? PERF_PROBE_WINDOW_SECONDS;
152
+ return perfReadFunctionFragment(opts.procRoot ?? "/proc", opts.diskPath ?? "/")
153
+ + `ac_perf_read; sleep ${win}; ac_perf_read; `
154
+ + `printf '${PERF_PROBE_LINE_PREFIX}%s\\n' "\${ac_tok:--}"`;
155
+ }
156
+
157
+ /** Parse + CLAMP one perf token. Null = no usable sample (absent file, a
158
+ * garbled write, a hostile guest). Out-of-range fields null out
159
+ * individually; a token with no usable utilization field at all is null. */
160
+ export function parsePerfToken(token: string): GuestPerfSample | null {
161
+ const t = token.trim();
162
+ if (t.length === 0 || t.length > PERF_TOKEN_MAX_CHARS) return null;
163
+ if (!/^v=1(?:,|$)/.test(t)) return null;
164
+ const kv = new Map<string, string>();
165
+ for (const part of t.split(",")) {
166
+ const i = part.indexOf("=");
167
+ if (i > 0) kv.set(part.slice(0, i), part.slice(i + 1));
168
+ }
169
+ const pct = (k: string): number | null => {
170
+ const n = Number(kv.get(k));
171
+ return Number.isFinite(n) && n >= 0 && n <= 100 ? Math.round(n) : null;
172
+ };
173
+ const bounded = (k: string, min: number, max: number): number | null => {
174
+ const n = Number(kv.get(k));
175
+ return Number.isInteger(n) && n >= min && n <= max ? n : null;
176
+ };
177
+ const l1raw = Number(kv.get("l1"));
178
+ const sample: GuestPerfSample = {
179
+ cpuBusyPct: pct("cpu"),
180
+ load1: Number.isFinite(l1raw) && l1raw >= 0 && l1raw <= 100_000 ? l1raw : null,
181
+ memUsedPct: pct("mem"),
182
+ diskUsedPct: pct("dsk"),
183
+ vcpus: bounded("vc", 1, 1024),
184
+ ramMb: bounded("ram", 1, 16 * 1024 * 1024),
185
+ // Bounded to [2020, 2100) in epoch seconds — dedupe-grade only.
186
+ sampledAtS: bounded("ts", 1_577_836_800, 4_102_444_800),
187
+ };
188
+ if (sample.cpuBusyPct === null && sample.load1 === null
189
+ && sample.memUsedPct === null && sample.diskUsedPct === null) return null;
190
+ return sample;
191
+ }
192
+
193
+ /** Parse the standalone probe's stdout (the LAST `perf ` line wins — envd
194
+ * occasionally prepends shell noise). */
195
+ export function parsePerfProbeOutput(stdout: string): GuestPerfSample | null {
196
+ const lines = stdout.split("\n").filter((l) => l.startsWith(PERF_PROBE_LINE_PREFIX));
197
+ const last = lines[lines.length - 1];
198
+ if (!last) return null;
199
+ const token = last.slice(PERF_PROBE_LINE_PREFIX.length).trim();
200
+ if (token === "-") return null;
201
+ return parsePerfToken(token);
202
+ }