@agentshouse/kit 0.1.0-alpha.37 → 0.1.0-alpha.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,13 +13,13 @@ The Skill set Kit installs is Matt Pocock's
13
13
  Linux (`amd64`, `arm64`) or macOS (Apple silicon, Intel):
14
14
 
15
15
  ```sh
16
- bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.37/connect-linux.sh)
16
+ bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.39/connect-linux.sh)
17
17
  ```
18
18
 
19
19
  Windows 11 (`amd64`), in PowerShell:
20
20
 
21
21
  ```powershell
22
- & ([scriptblock]::Create((irm https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.37/connect-windows.ps1)))
22
+ & ([scriptblock]::Create((irm https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.39/connect-windows.ps1)))
23
23
  ```
24
24
 
25
25
  The command sets up Docker if it is missing, creates `~/.house-kit` (Kit home)
@@ -21,6 +21,15 @@ RUNTIME='Docker Engine'
21
21
  OPENER=xdg-open
22
22
  NETWORK=(--network host)
23
23
  PUBLISHED=()
24
+ AVAILABILITY=intermittent
25
+ NATIVE=0
26
+ NATIVE_RELEASE=26.04
27
+ NATIVE_ARCHITECTURE=amd64
28
+ NATIVE_PACKAGES=(git tmux)
29
+ NATIVE_PREFIX=/opt/house-kit
30
+ NATIVE_SERVICE=house-kit.service
31
+ NATIVE_UNIT=/etc/systemd/system/house-kit.service
32
+ NATIVE_WORKSPACE=/agents/house
24
33
 
25
34
  refuse() {
26
35
  printf 'kit_bootstrap_refused: %s\n' "$1" >&2
@@ -32,13 +41,25 @@ bind_mount() {
32
41
  printf 'type=bind,"src=%s",dst=%s' "$source" "$2"
33
42
  }
34
43
 
44
+ native_kit() {
45
+ PATH="$NATIVE_PREFIX/bin:$PATH" HOUSE_KIT_HOME="$KIT_HOME" "$NATIVE_PREFIX/bin/node" "$NATIVE_PREFIX/bin/$1" "${@:2}"
46
+ }
47
+
35
48
  login_kit() {
36
49
  if [[ " $* " == *' --manual '* ]]; then
37
- "${DOCKER_RUN[@]:0:2}" -i "${DOCKER_RUN[@]:2}" login "$@"
50
+ if ((NATIVE)); then
51
+ native_kit kit login "$@"
52
+ else
53
+ "${DOCKER_RUN[@]:0:2}" -i "${DOCKER_RUN[@]:2}" login "$@"
54
+ fi
38
55
  return
39
56
  fi
40
57
  command -v "$OPENER" >/dev/null || refuse 'no host browser opener; rerun with --manual'
41
- "${DOCKER_RUN[@]:0:2}" ${PUBLISHED[@]+"${PUBLISHED[@]}"} "${DOCKER_RUN[@]:2}" login "$@" --host-browser | while IFS= read -r line; do
58
+ if ((NATIVE)); then
59
+ native_kit kit login "$@" --host-browser
60
+ else
61
+ "${DOCKER_RUN[@]:0:2}" ${PUBLISHED[@]+"${PUBLISHED[@]}"} "${DOCKER_RUN[@]:2}" login "$@" --host-browser
62
+ fi | while IFS= read -r line; do
42
63
  if [[ "$line" == 'Open '* ]]; then
43
64
  printf 'Opening House Login in the host browser.\n'
44
65
  "$OPENER" "${line#Open }" >/dev/null 2>&1 || refuse 'host browser did not open; rerun with --manual'
@@ -169,6 +190,204 @@ establish_docker_engine() {
169
190
  docker_answers || refuse "Docker Engine does not answer: $(docker_cause)"
170
191
  }
171
192
 
193
+ check_native_host() {
194
+ local distribution='' release='' described=macOS
195
+ if [[ "$RUNTIME" == 'Docker Engine' ]]; then
196
+ described=Linux
197
+ if [[ -r /etc/os-release ]]; then
198
+ distribution=$(. /etc/os-release && printf '%s' "${ID:-}")
199
+ release=$(. /etc/os-release && printf '%s' "${VERSION_ID:-}")
200
+ described=$(. /etc/os-release && printf '%s' "${PRETTY_NAME:-$described}")
201
+ fi
202
+ fi
203
+ [[ "$distribution" == ubuntu && "$release" == "$NATIVE_RELEASE" && "$ARCH" == "$NATIVE_ARCHITECTURE" ]] ||
204
+ refuse "--linux runs only on Ubuntu $NATIVE_RELEASE LTS on $NATIVE_ARCHITECTURE, not $described on $ARCH"
205
+ }
206
+
207
+
208
+ establish_native_packages() {
209
+ local present
210
+ present=$(installed_packages "${NATIVE_PACKAGES[@]}" | wc -l)
211
+ ((present == ${#NATIVE_PACKAGES[@]})) && return
212
+ printf 'Installing git and tmux from Ubuntu'\''s apt repository; sudo may ask for your password.\n'
213
+ elevated apt-get update -qq >/dev/null || refuse 'apt could not refresh its package lists'
214
+ elevated apt-get install -y -qq "${NATIVE_PACKAGES[@]}" >/dev/null || refuse 'apt could not install git and tmux'
215
+ }
216
+
217
+ stage_native_kit() {
218
+ local copy
219
+ NATIVE_STAGE=$(mktemp -d) || refuse 'no temporary directory for the Kit package'
220
+ trap 'rm -rf "$NATIVE_STAGE"' EXIT
221
+ docker pull --quiet --platform "linux/$ARCH" "$IMAGE" >/dev/null
222
+ copy=$(docker create --platform "linux/$ARCH" "$IMAGE") || refuse 'the Kit image could not be opened'
223
+ mkdir "$NATIVE_STAGE/bin" "$NATIVE_STAGE/lib"
224
+ if ! docker cp --quiet "$copy:/usr/local/bin/node" "$NATIVE_STAGE/bin/node" ||
225
+ ! docker cp --quiet "$copy:/usr/local/lib/node_modules" "$NATIVE_STAGE/lib/node_modules"; then
226
+ docker rm "$copy" >/dev/null 2>&1 || true
227
+ refuse 'the Kit package could not be copied from its image'
228
+ fi
229
+ docker rm "$copy" >/dev/null
230
+ ln -s ../lib/node_modules/@agentshouse/kit/bin/kit.js "$NATIVE_STAGE/bin/kit"
231
+ ln -s ../lib/node_modules/@agentshouse/kit/src/session-bin/house "$NATIVE_STAGE/bin/house"
232
+ ln -s ../lib/node_modules/npm/bin/npm-cli.js "$NATIVE_STAGE/bin/npm"
233
+ ln -s ../lib/node_modules/npm/bin/npx-cli.js "$NATIVE_STAGE/bin/npx"
234
+ printf '%s\n' "$IMAGE" > "$NATIVE_STAGE/image"
235
+ chmod 0755 "$NATIVE_STAGE" "$NATIVE_STAGE/bin" "$NATIVE_STAGE/lib"
236
+ chmod 0644 "$NATIVE_STAGE/image"
237
+ }
238
+
239
+ place_native_kit() {
240
+ printf 'Installing House Kit in %s; sudo may ask for your password.\n' "$NATIVE_PREFIX"
241
+ elevated rm -rf "$NATIVE_PREFIX" && elevated mv "$NATIVE_STAGE" "$NATIVE_PREFIX" && elevated chown -R root:root "$NATIVE_PREFIX" ||
242
+ refuse "House Kit could not be installed in $NATIVE_PREFIX"
243
+ }
244
+
245
+ start_native_service() {
246
+ printf '[Unit]\nDescription=House Kit\nWants=network-online.target\nAfter=network-online.target docker.service\n\n[Service]\nUser=%s\nWorkingDirectory=%s\nEnvironment=PATH=%s/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\nExecStart=%s/bin/node %s/bin/kit resident\nRestart=always\nRestartSec=5\n\n[Install]\nWantedBy=multi-user.target\n' \
247
+ "$(id -un)" "$NATIVE_WORKSPACE" "$NATIVE_PREFIX" "$NATIVE_PREFIX" "$NATIVE_PREFIX" | elevated tee "$NATIVE_UNIT" >/dev/null &&
248
+ elevated chmod 0644 "$NATIVE_UNIT" || refuse "$NATIVE_UNIT could not be written"
249
+ elevated systemctl daemon-reload || refuse 'systemd could not read the House Kit service'
250
+ elevated systemctl --quiet enable --now "$NATIVE_SERVICE" || refuse 'the House Kit service could not be started'
251
+ }
252
+
253
+ run_kit() {
254
+ if ((NATIVE)); then
255
+ native_kit kit "$@"
256
+ else
257
+ "${DOCKER_RUN[@]}" "$@"
258
+ fi
259
+ }
260
+
261
+ kit_running() {
262
+ if ((NATIVE)); then
263
+ systemctl is-active --quiet "$NATIVE_SERVICE"
264
+ else
265
+ [[ "$(docker inspect --format '{{.State.Running}}' "$NAME")" == true ]]
266
+ fi
267
+ }
268
+
269
+ kit_installed() {
270
+ if ((NATIVE)); then
271
+ [[ -f "$NATIVE_UNIT" ]]
272
+ else
273
+ docker container inspect "$NAME" >/dev/null 2>&1
274
+ fi
275
+ }
276
+
277
+ installed_image() {
278
+ if ((NATIVE == 0)); then
279
+ docker inspect --format '{{.Config.Image}}' "$NAME"
280
+ elif [[ -f "$NATIVE_PREFIX/image" ]]; then
281
+ cat "$NATIVE_PREFIX/image"
282
+ fi
283
+ }
284
+
285
+ check_installation() {
286
+ if ((NATIVE)); then
287
+ grep -qx "User=$(id -un)" "$NATIVE_UNIT" || refuse 'House Kit runs natively on this host for another account'
288
+ return
289
+ fi
290
+ [[ "$(docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' "$NAME")" == unless-stopped ]] || refuse 'the installed Kit has a different lifecycle'
291
+ [[ "$(docker inspect --format '{{len .Mounts}}' "$NAME")" == 2 ]] || refuse 'the installed Kit has unexpected mounts'
292
+ [[ "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/kit-home"}}{{.Source}}{{end}}{{end}}' "$NAME")" == "$KIT_HOME" ]] || refuse 'the installed Kit uses another Kit home'
293
+ [[ "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/kit-home"}}{{.Type}}/{{.RW}}{{end}}{{end}}' "$NAME")" == bind/true ]] || refuse 'the installed Kit home is not a writable bind mount'
294
+ [[ "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/agents/house"}}{{.Source}}{{end}}{{end}}' "$NAME")" == "$WORKSPACE" ]] || refuse 'the installed Kit uses another workspace root'
295
+ [[ "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/agents/house"}}{{.Type}}/{{.RW}}{{end}}{{end}}' "$NAME")" == bind/true ]] || refuse 'the installed workspace is not a writable bind mount'
296
+ [[ "$(docker inspect --format '{{index .Config.Labels "agentshouse.house"}}' "$NAME")" == "$HOUSE" ]] || refuse 'the installed Kit uses another House origin'
297
+ }
298
+
299
+ forward_kit() {
300
+ if ((NATIVE)); then
301
+ native_kit "$FORWARDED" ${FORWARD[@]+"${FORWARD[@]}"}
302
+ return
303
+ fi
304
+ local placed=() entry=() terminal=() here
305
+ if [[ "$FORWARDED" == house ]]; then
306
+ here=$(pwd -P)
307
+ [[ "$here" == "$WORKSPACE" || "$here" == "$WORKSPACE"/* ]] || refuse "the current directory is outside the workspace $WORKSPACE"
308
+ placed=(--workdir "/agents/house${here#"$WORKSPACE"}")
309
+ entry=(--entrypoint house)
310
+ fi
311
+ if [[ -t 0 && -t 1 ]]; then terminal=(-t); fi
312
+ if [[ -t 0 && ! -t 1 ]]; then
313
+ ECHOED=$(stty -g)
314
+ trap 'stty "$ECHOED"' EXIT
315
+ stty -echo
316
+ fi
317
+ if kit_running; then
318
+ docker exec -i ${terminal[@]+"${terminal[@]}"} ${placed[@]+"${placed[@]}"} "$NAME" "$FORWARDED" ${FORWARD[@]+"${FORWARD[@]}"}
319
+ else
320
+ "${DOCKER_RUN[@]:0:2}" -i ${terminal[@]+"${terminal[@]}"} ${placed[@]+"${placed[@]}"} ${entry[@]+"${entry[@]}"} "${DOCKER_RUN[@]:2}" ${FORWARD[@]+"${FORWARD[@]}"}
321
+ fi
322
+ }
323
+
324
+ start_kit() {
325
+ if ((NATIVE)); then
326
+ start_native_service
327
+ else
328
+ start_resident
329
+ fi
330
+ }
331
+
332
+ update_kit() {
333
+ local stopped=0
334
+ if ((NATIVE)); then
335
+ stage_native_kit
336
+ else
337
+ docker pull --quiet --platform "linux/$ARCH" "$IMAGE" >/dev/null
338
+ fi
339
+ if ! kit_running; then
340
+ stopped=1
341
+ elif ((NATIVE)); then
342
+ native_kit kit resident drain
343
+ else
344
+ docker exec "$NAME" kit resident drain
345
+ fi
346
+ if ((NATIVE)); then
347
+ elevated systemctl stop "$NATIVE_SERVICE" || refuse 'the House Kit service could not be stopped'
348
+ place_native_kit
349
+ else
350
+ docker rm -f "$NAME" >/dev/null
351
+ fi
352
+ rm -f "$KIT_HOME/state/update-drain"
353
+ ((stopped == 0)) || run_kit credential check
354
+ start_kit
355
+ printf 'House Kit updated for Environment %s.\n' "$(cat "$KIT_HOME/environment")"
356
+ }
357
+
358
+ resume_kit() {
359
+ run_kit credential check
360
+ if kit_running; then
361
+ printf 'House Kit is already running for Environment %s.\n' "$(cat "$KIT_HOME/environment")"
362
+ return
363
+ fi
364
+ if ((NATIVE)); then
365
+ elevated systemctl start "$NATIVE_SERVICE" || refuse 'the House Kit service could not be started'
366
+ else
367
+ docker start "$NAME" >/dev/null
368
+ fi
369
+ printf 'House Kit restarted for Environment %s.\n' "$(cat "$KIT_HOME/environment")"
370
+ }
371
+
372
+ connect_kit() {
373
+ if [[ -f "$KIT_HOME/credential" ]]; then
374
+ run_kit credential check
375
+ else
376
+ local login=()
377
+ if ((HOUSE_SELECTED)); then login+=(--house "$HOUSE"); fi
378
+ if ((MANUAL)); then login+=(--manual); fi
379
+ login_kit ${login[@]+"${login[@]}"}
380
+ fi
381
+ run_kit apply
382
+ start_kit
383
+ printf 'House Kit connected for Environment %s.\n' "$(cat "$KIT_HOME/environment")"
384
+ }
385
+
386
+ if [[ "${1:-}" == --linux && ( "${2:-}" == kit || "${2:-}" == house ) ]]; then
387
+ NATIVE=1
388
+ shift
389
+ fi
390
+
172
391
  if [[ "${1:-}" == kit || "${1:-}" == house ]]; then
173
392
  FORWARDING=1
174
393
  FORWARDED="$1"
@@ -199,9 +418,20 @@ while (($#)); do
199
418
  SKILLS=false
200
419
  shift
201
420
  ;;
421
+ --linux)
422
+ NATIVE=1
423
+ shift
424
+ ;;
202
425
  *) refuse "unknown argument $1" ;;
203
426
  esac
204
427
  done
428
+ if ((NATIVE)); then
429
+ ((WORKSPACE_SELECTED == 0)) || refuse "--linux keeps its workspace at $NATIVE_WORKSPACE; --workspace is for a container"
430
+ WORKSPACE=$NATIVE_WORKSPACE
431
+ AVAILABILITY=continuous
432
+ elif [[ -e "$NATIVE_UNIT" ]]; then
433
+ refuse 'House Kit runs natively on this host; rerun this bootstrap with --linux'
434
+ fi
205
435
 
206
436
  [[ "$IMAGE" =~ ^ghcr\.io/agentshouse/kit@sha256:[0-9a-f]{64}$ ]] || refuse 'this bootstrap has not been published with an immutable image digest'
207
437
  case "$(uname -s)" in
@@ -225,21 +455,36 @@ case "$(uname -s)" in
225
455
  ;;
226
456
  *) refuse "unsupported host $(uname -s); this bootstrap is for Linux and macOS" ;;
227
457
  esac
458
+ ((NATIVE == 0)) || check_native_host
228
459
  [[ "$WORKSPACE" == /* ]] || refuse 'workspace root must be an absolute path'
229
- if [[ "$RUNTIME" == 'Docker Desktop' ]]; then
230
- establish_docker_desktop "$CHIP"
231
- DOCKER_SYSTEM=$(docker info --format '{{.OperatingSystem}}' 2>/dev/null) || refuse "Docker Desktop does not answer: $(docker_cause)"
232
- [[ "$DOCKER_SYSTEM" == 'Docker Desktop' ]] || refuse "Docker answers from $DOCKER_SYSTEM, not Docker Desktop; stop that engine or switch Docker to Docker Desktop, then rerun this bootstrap"
460
+ if ((NATIVE && FORWARDING)); then
461
+ [[ -f "$NATIVE_UNIT" ]] || refuse 'House Kit is not installed; run this bootstrap with --linux first'
233
462
  else
234
- establish_docker_engine
463
+ if [[ "$RUNTIME" == 'Docker Desktop' ]]; then
464
+ establish_docker_desktop "$CHIP"
465
+ DOCKER_SYSTEM=$(docker info --format '{{.OperatingSystem}}' 2>/dev/null) || refuse "Docker Desktop does not answer: $(docker_cause)"
466
+ [[ "$DOCKER_SYSTEM" == 'Docker Desktop' ]] || refuse "Docker answers from $DOCKER_SYSTEM, not Docker Desktop; stop that engine or switch Docker to Docker Desktop, then rerun this bootstrap"
467
+ else
468
+ establish_docker_engine
469
+ fi
470
+ case "$DOCKER_PLATFORM" in
471
+ linux/x86_64|linux/amd64) DOCKER_ARCH=amd64 ;;
472
+ linux/aarch64|linux/arm64) DOCKER_ARCH=arm64 ;;
473
+ *) refuse "unsupported $RUNTIME platform $DOCKER_PLATFORM" ;;
474
+ esac
475
+ [[ "$DOCKER_ARCH" == "$ARCH" ]] || refuse "host linux/$ARCH does not match $RUNTIME linux/$DOCKER_ARCH"
235
476
  fi
236
- case "$DOCKER_PLATFORM" in
237
- linux/x86_64|linux/amd64) DOCKER_ARCH=amd64 ;;
238
- linux/aarch64|linux/arm64) DOCKER_ARCH=arm64 ;;
239
- *) refuse "unsupported $RUNTIME platform $DOCKER_PLATFORM" ;;
240
- esac
241
- [[ "$DOCKER_ARCH" == "$ARCH" ]] || refuse "host linux/$ARCH does not match $RUNTIME linux/$DOCKER_ARCH"
242
- if ((FORWARDING)); then
477
+ if ((NATIVE)); then
478
+ if ((FORWARDING == 0)) && docker container inspect "$NAME" >/dev/null 2>&1; then
479
+ refuse "House Kit runs in the container $NAME on this computer; remove that container, then rerun this bootstrap with --linux"
480
+ fi
481
+ [[ ! -L "${NATIVE_WORKSPACE%/*}" ]] || refuse "${NATIVE_WORKSPACE%/*} is a symbolic link"
482
+ if [[ ! -e "$NATIVE_WORKSPACE" && ! -L "$NATIVE_WORKSPACE" ]]; then
483
+ printf 'Creating %s for the Agents on this host; sudo may ask for your password.\n' "$NATIVE_WORKSPACE"
484
+ { [[ -d "${NATIVE_WORKSPACE%/*}" ]] || elevated install -d -m 0755 "${NATIVE_WORKSPACE%/*}"; } &&
485
+ elevated install -d -m 0700 -o "$(id -u)" -g "$(id -g)" "$NATIVE_WORKSPACE" || refuse "$NATIVE_WORKSPACE could not be created"
486
+ fi
487
+ elif ((FORWARDING)); then
243
488
  docker container inspect "$NAME" >/dev/null 2>&1 || refuse 'House Kit is not installed; run this bootstrap without arguments first'
244
489
  WORKSPACE=$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/agents/house"}}{{.Source}}{{end}}{{end}}' "$NAME")
245
490
  fi
@@ -270,92 +515,61 @@ fi
270
515
  [[ "$HOUSE" == https://* || "$HOUSE" == http://127.0.0.1:* || "$HOUSE" == http://localhost:* ]] || refuse 'House origin must use HTTPS or local loopback HTTP'
271
516
  if ((FORWARDING == 0)); then
272
517
  if [[ ! -f "$KIT_HOME/kit.json" ]]; then
273
- printf '{"availability":"intermittent","execution_authority":"allow","migrators":[],"instances":[],"clients":[],"host":null,"skills":%s}\n' "$SKILLS" > "$KIT_HOME/kit.json"
518
+ printf '{"availability":"%s","execution_authority":"allow","migrators":[],"instances":[],"clients":[],"host":null,"skills":%s}\n' "$AVAILABILITY" "$SKILLS" > "$KIT_HOME/kit.json"
274
519
  elif grep -Eq '"skills"[[:space:]]*:' "$KIT_HOME/kit.json"; then
275
520
  sed -E -i.old "s/(\"skills\"[[:space:]]*:[[:space:]]*)(true|false)/\\1$SKILLS/" "$KIT_HOME/kit.json" || refuse 'kit.json could not be updated'
276
521
  else
277
522
  sed -E -i.old "1s/^([[:space:]]*)\\{/\\1{\"skills\":$SKILLS,/" "$KIT_HOME/kit.json" || refuse 'kit.json could not be updated'
278
523
  fi
524
+ if ((NATIVE)); then
525
+ sed -E -i.old "s/(\"availability\"[[:space:]]*:[[:space:]]*)\"[^\"]*\"/\\1\"$AVAILABILITY\"/" "$KIT_HOME/kit.json" || refuse 'kit.json could not be updated'
526
+ fi
279
527
  rm -f "$KIT_HOME/kit.json.old"
280
528
  grep -Eq "\"skills\"[[:space:]]*:[[:space:]]*$SKILLS" "$KIT_HOME/kit.json" || refuse 'kit.json could not be updated'
529
+ ((NATIVE == 0)) || grep -Eq "\"availability\"[[:space:]]*:[[:space:]]*\"$AVAILABILITY\"" "$KIT_HOME/kit.json" || refuse 'kit.json could not be updated'
281
530
  fi
282
- if [[ "$RUNTIME" == 'Docker Desktop' && "$HOUSE" == https://* ]]; then
283
- NETWORK=()
284
- LOGIN_PORT=$((49152 + RANDOM % 16384))
285
- PUBLISHED=(--publish "127.0.0.1:$LOGIN_PORT:$LOGIN_PORT" --env "HOUSE_KIT_LOGIN_PORT=$LOGIN_PORT")
531
+
532
+ if ((NATIVE)); then
533
+ RERUN='with --linux'
534
+ else
535
+ RERUN='without arguments'
536
+ if [[ "$RUNTIME" == 'Docker Desktop' && "$HOUSE" == https://* ]]; then
537
+ NETWORK=()
538
+ LOGIN_PORT=$((49152 + RANDOM % 16384))
539
+ PUBLISHED=(--publish "127.0.0.1:$LOGIN_PORT:$LOGIN_PORT" --env "HOUSE_KIT_LOGIN_PORT=$LOGIN_PORT")
540
+ fi
541
+ DOCKER_RUN=(docker run --rm ${NETWORK[@]+"${NETWORK[@]}"} --user "$(id -u):$(id -g)" --mount "$KIT_HOME_MOUNT" --mount "$WORKSPACE_MOUNT" --env HOME=/kit-home --env HOUSE_KIT_HOME=/kit-home --env HOUSE_KIT_CONTAINER=1 --env "HOUSE_KIT_HOSTNAME=$(hostname)" "$IMAGE")
286
542
  fi
287
- DOCKER_RUN=(docker run --rm ${NETWORK[@]+"${NETWORK[@]}"} --user "$(id -u):$(id -g)" --mount "$KIT_HOME_MOUNT" --mount "$WORKSPACE_MOUNT" --env HOME=/kit-home --env HOUSE_KIT_HOME=/kit-home --env HOUSE_KIT_CONTAINER=1 --env "HOUSE_KIT_HOSTNAME=$(hostname)" "$IMAGE")
288
543
 
289
- if docker container inspect "$NAME" >/dev/null 2>&1; then
290
- INSTALLED_IMAGE=$(docker inspect --format '{{.Config.Image}}' "$NAME")
291
- ((FORWARDING == 0)) || [[ "$INSTALLED_IMAGE" == "$IMAGE" ]] || refuse 'an installed Kit uses a different image; rerun this bootstrap without arguments to update it'
292
- [[ "$(docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' "$NAME")" == unless-stopped ]] || refuse 'the installed Kit has a different lifecycle'
293
- [[ "$(docker inspect --format '{{len .Mounts}}' "$NAME")" == 2 ]] || refuse 'the installed Kit has unexpected mounts'
294
- [[ "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/kit-home"}}{{.Source}}{{end}}{{end}}' "$NAME")" == "$KIT_HOME" ]] || refuse 'the installed Kit uses another Kit home'
295
- [[ "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/kit-home"}}{{.Type}}/{{.RW}}{{end}}{{end}}' "$NAME")" == bind/true ]] || refuse 'the installed Kit home is not a writable bind mount'
296
- [[ "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/agents/house"}}{{.Source}}{{end}}{{end}}' "$NAME")" == "$WORKSPACE" ]] || refuse 'the installed Kit uses another workspace root'
297
- [[ "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/agents/house"}}{{.Type}}/{{.RW}}{{end}}{{end}}' "$NAME")" == bind/true ]] || refuse 'the installed workspace is not a writable bind mount'
298
- [[ "$(docker inspect --format '{{index .Config.Labels "agentshouse.house"}}' "$NAME")" == "$HOUSE" ]] || refuse 'the installed Kit uses another House origin'
544
+ if kit_installed; then
545
+ check_installation
299
546
  [[ -f "$KIT_HOME/credential" && -f "$KIT_HOME/environment" && -f "$KIT_HOME/house" ]] || refuse 'the installed Kit has no enrolled authority'
300
547
  if ((FORWARDING)); then
548
+ [[ "$(installed_image)" == "$IMAGE" ]] || refuse "an installed Kit uses a different image; rerun this bootstrap $RERUN to update it"
301
549
  if [[ "$FORWARDED" == kit && "${FORWARD[0]:-}" == login ]]; then
302
550
  login_kit ${FORWARD[@]+"${FORWARD[@]:1}"}
303
551
  exit 0
304
552
  fi
305
- PLACED=()
306
- ENTRY=()
307
- if [[ "$FORWARDED" == house ]]; then
308
- HERE=$(pwd -P)
309
- [[ "$HERE" == "$WORKSPACE" || "$HERE" == "$WORKSPACE"/* ]] || refuse "the current directory is outside the workspace $WORKSPACE"
310
- PLACED=(--workdir "/agents/house${HERE#"$WORKSPACE"}")
311
- ENTRY=(--entrypoint house)
312
- fi
313
- TERMINAL=()
314
- if [[ -t 0 && -t 1 ]]; then TERMINAL=(-t); fi
315
- if [[ -t 0 && ! -t 1 ]]; then
316
- ECHOED=$(stty -g)
317
- trap 'stty "$ECHOED"' EXIT
318
- stty -echo
319
- fi
320
- if [[ "$(docker inspect --format '{{.State.Running}}' "$NAME")" == true ]]; then
321
- docker exec -i ${TERMINAL[@]+"${TERMINAL[@]}"} ${PLACED[@]+"${PLACED[@]}"} "$NAME" "$FORWARDED" ${FORWARD[@]+"${FORWARD[@]}"}
322
- else
323
- "${DOCKER_RUN[@]:0:2}" -i ${TERMINAL[@]+"${TERMINAL[@]}"} ${PLACED[@]+"${PLACED[@]}"} ${ENTRY[@]+"${ENTRY[@]}"} "${DOCKER_RUN[@]:2}" ${FORWARD[@]+"${FORWARD[@]}"}
324
- fi
553
+ forward_kit
325
554
  exit
326
555
  fi
327
- if [[ "$INSTALLED_IMAGE" != "$IMAGE" ]]; then
328
- docker pull --quiet --platform "linux/$ARCH" "$IMAGE" >/dev/null
329
- if [[ "$(docker inspect --format '{{.State.Running}}' "$NAME")" == true ]]; then
330
- docker exec "$NAME" kit resident drain
331
- fi
332
- docker rm -f "$NAME" >/dev/null
333
- rm -f "$KIT_HOME/state/update-drain"
334
- start_resident
335
- printf 'House Kit updated for Environment %s.\n' "$(cat "$KIT_HOME/environment")"
336
- exit 0
337
- fi
338
- "${DOCKER_RUN[@]}" credential check
339
- if [[ "$(docker inspect --format '{{.State.Running}}' "$NAME")" == true ]]; then
340
- printf 'House Kit is already running for Environment %s.\n' "$(cat "$KIT_HOME/environment")"
556
+ ((NATIVE == 0)) || establish_native_packages
557
+ if [[ "$(installed_image)" != "$IMAGE" ]]; then
558
+ update_kit
341
559
  else
342
- docker start "$NAME" >/dev/null
343
- printf 'House Kit restarted for Environment %s.\n' "$(cat "$KIT_HOME/environment")"
560
+ resume_kit
344
561
  fi
562
+ ((NATIVE == 0)) || native_kit kit workspace update
345
563
  exit 0
346
564
  fi
347
565
 
348
- docker pull --quiet --platform "linux/$ARCH" "$IMAGE" >/dev/null
349
-
350
- if [[ -f "$KIT_HOME/credential" ]]; then
351
- "${DOCKER_RUN[@]}" credential check
566
+ if ((NATIVE)); then
567
+ establish_native_packages
568
+ if [[ "$(installed_image)" != "$IMAGE" ]]; then
569
+ stage_native_kit
570
+ place_native_kit
571
+ fi
352
572
  else
353
- LOGIN=()
354
- if ((HOUSE_SELECTED)); then LOGIN+=(--house "$HOUSE"); fi
355
- if ((MANUAL)); then LOGIN+=(--manual); fi
356
- login_kit ${LOGIN[@]+"${LOGIN[@]}"}
573
+ docker pull --quiet --platform "linux/$ARCH" "$IMAGE" >/dev/null
357
574
  fi
358
-
359
- "${DOCKER_RUN[@]}" apply
360
- start_resident
361
- printf 'House Kit connected for Environment %s.\n' "$(cat "$KIT_HOME/environment")"
575
+ connect_kit
@@ -364,7 +364,7 @@ function Connect-Kit {
364
364
  $entry = @('--entrypoint', 'house')
365
365
  }
366
366
  $terminal = @()
367
- if (-not [Console]::IsInputRedirected -and -not [Console]::IsOutputRedirected) { $terminal = @('-t') }
367
+ if (-not [Console]::IsInputRedirected) { $terminal = @('-t') }
368
368
  if ((Read-Inspect '{{.State.Running}}') -ceq 'true') {
369
369
  & docker exec -i @terminal @placed $Name $Forwarded @Forward
370
370
  } else {
package/bin/kit.js CHANGED
@@ -13,7 +13,7 @@ import { runResident, runWorkers } from "../src/worker.js";
13
13
  import { openSession, releaseControl, requestDrain } from "../src/house.js";
14
14
  import { reportHost } from "../src/host.js";
15
15
  import { signInAgent, updateAgents } from "../src/agents.js";
16
- import { serveWorkspaces } from "../src/workspace.js";
16
+ import { serveWorkspaces, updateWorkspaces } from "../src/workspace.js";
17
17
  import { captureRepository } from "../src/repository.js";
18
18
  import { bootstrap, pull, rebootstrap, submit } from "../src/sync.js";
19
19
  import { formatMount, formatUnmount, mountPackage, unmountSubtree } from "../src/mount.js";
@@ -35,7 +35,7 @@ const LOGIN_USAGE =
35
35
  const HOST_USAGE = "usage: kit host report [--config <path>]";
36
36
  const AGENTS_USAGE =
37
37
  "usage: kit agents update [--config <path>] | kit agents sign-in <kind> [--config <path>]";
38
- const WORKSPACE_USAGE = "usage: kit workspace host [--config <path>] [--passes <count>]";
38
+ const WORKSPACE_USAGE = "usage: kit workspace host [--config <path>] [--passes <count>] | kit workspace update";
39
39
  const SOURCE_USAGE =
40
40
  "usage: kit source <capture|status> <account|instance> [service] [--config <path>]\n" +
41
41
  " kit source <reset|retry-files> <instance> [service] [--config <path>]\n" +
@@ -895,6 +895,21 @@ async function runWorkspaceHost(args, directory) {
895
895
  }
896
896
  }
897
897
 
898
+ async function runWorkspaceUpdate(args, directory) {
899
+ declared(args, [], 0, { exact: true });
900
+ const answered = await updateWorkspaces({
901
+ house: await storedHouse(directory),
902
+ configuration: join(directory, "kit.json"),
903
+ });
904
+ for (const { workspace, running } of answered.updated) {
905
+ write(running ? `Updated workspace ${workspace}.` : `Updated workspace ${workspace}; it stays stopped.`);
906
+ }
907
+ for (const { workspace, cause } of answered.unchanged) {
908
+ write(`Workspace ${workspace} is unchanged: ${cause}`);
909
+ }
910
+ if (answered.unchanged.length > 0) process.exitCode = 1;
911
+ }
912
+
898
913
  async function runTelegramConnect(args, directory) {
899
914
  declared(args, [], 0, { exact: true });
900
915
  const house = await storedHouse(directory);
@@ -1027,6 +1042,7 @@ async function main() {
1027
1042
  if (command === "workspace") {
1028
1043
  const [action, ...rest] = args;
1029
1044
  if (action === "host") return runWorkspaceHost(rest, directory);
1045
+ if (action === "update") return runWorkspaceUpdate(rest, directory);
1030
1046
  throw new Error(WORKSPACE_USAGE);
1031
1047
  }
1032
1048
  if (command === "telegram") {
package/bootstrap.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.1.0-alpha.37",
3
+ "version": "0.1.0-alpha.39",
4
4
  "hosts": {
5
5
  "linux": {
6
- "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.37/connect-linux.sh)",
6
+ "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.39/connect-linux.sh)",
7
7
  "asset": {
8
8
  "name": "connect-linux.sh",
9
- "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.37/connect-linux.sh"
9
+ "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.39/connect-linux.sh"
10
10
  },
11
11
  "workspace": {
12
12
  "argument": "--workspace",
@@ -14,10 +14,10 @@
14
14
  }
15
15
  },
16
16
  "windows": {
17
- "command": "& ([scriptblock]::Create((irm https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.37/connect-windows.ps1)))",
17
+ "command": "& ([scriptblock]::Create((irm https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.39/connect-windows.ps1)))",
18
18
  "asset": {
19
19
  "name": "connect-windows.ps1",
20
- "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.37/connect-windows.ps1"
20
+ "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.39/connect-windows.ps1"
21
21
  },
22
22
  "workspace": {
23
23
  "argument": "--workspace",
package/docs/linux.md CHANGED
@@ -44,3 +44,34 @@ bash <(curl -fsSL …/connect-linux.sh) house git push --owner
44
44
 
45
45
  `--house http://127.0.0.1:<port>` or `--house http://localhost:<port>` runs Kit
46
46
  on the host network so it can reach a House served on this computer.
47
+
48
+ ## An Always-on server that hosts containers
49
+
50
+ An Always-on Ubuntu server that will run persistent container Environments
51
+ connects natively instead of in a container. Run the command with `--linux`:
52
+
53
+ ```sh
54
+ bash <(curl -fsSL …/connect-linux.sh) --linux
55
+ ```
56
+
57
+ It needs Ubuntu 26.04 LTS on `amd64`; any other host is refused. Every other
58
+ computer takes the ordinary command. It sets up:
59
+
60
+ - Docker Engine, as above, and `git` and `tmux` from Ubuntu's apt repository.
61
+ - `/opt/house-kit`: House Kit and the Node.js it runs on, copied from the
62
+ release's image and owned by root.
63
+ - `~/.house-kit`: Kit home, readable only by you.
64
+ - `/agents/house`: your Agents' workspace, readable only by you.
65
+ - `house-kit.service`: the systemd service that runs Kit as you and starts it
66
+ with the server.
67
+
68
+ The server connects as a continuous Environment. Put `--linux` before `kit` or
69
+ `house` to run a Kit command, as in
70
+ `bash <(curl -fsSL …/connect-linux.sh) --linux kit apply`, and run a newer
71
+ release's command with `--linux` to update it, as above.
72
+
73
+ The same run updates the container Environments the server hosts to that
74
+ release. A running one waits, and is named, until its own Agent sessions end;
75
+ run the command again then. A stopped one is updated and stays stopped. Each
76
+ keeps its files, sign-ins and CPU, memory and disk limits; only what was
77
+ installed outside its workspace and Kit home is not kept.
package/docs/windows.md CHANGED
@@ -30,8 +30,7 @@ refused.
30
30
 
31
31
  - Append `kit` and a command to run it, as on [Linux](linux.md). Each run
32
32
  leaves its exit status in `$LASTEXITCODE`.
33
- - When a command asks for a secret, such as a Telegram bot token, keep its
34
- output on the terminal: with output redirected, what you type is echoed.
33
+ - A command whose output is redirected also writes its prompts and errors there.
35
34
  - A House on this computer needs host networking enabled in Docker Desktop's
36
35
  settings.
37
36
  - This bootstrap has not yet been run on a real Windows computer.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentshouse/kit",
3
- "version": "0.1.0-alpha.37",
3
+ "version": "0.1.0-alpha.39",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -160,12 +160,11 @@ function maximaOf(value, declaration) {
160
160
 
161
161
  function hostOf(value, declaration) {
162
162
  if (value === null) return null;
163
- const host = closed(value, ["workspace", "maxima", "image"], declaration);
163
+ const host = closed(value, ["workspace", "maxima"], declaration);
164
164
  if (host.maxima === undefined) refuse(`${declaration}.maxima`);
165
165
  return {
166
166
  workspace: absolutePath(host.workspace, `${declaration}.workspace`),
167
167
  maxima: maximaOf(host.maxima, `${declaration}.maxima`),
168
- image: host.image === undefined || host.image === null ? null : text(host.image, `${declaration}.image`, 255),
169
168
  };
170
169
  }
171
170
 
package/src/contract.js CHANGED
@@ -276,16 +276,20 @@ export const KIT_WORKSPACES_ACQUIRE_PATH = "/kit/workspaces/acquire";
276
276
  export const WORKSPACE_ACQUIRE_MAX = 10;
277
277
  export const WORKSPACE_POLL_MS = 2_000;
278
278
  export const WORKSPACE_LABEL = "house-kit.workspace";
279
- export const WORKSPACE_MOUNT = "/workspace";
280
- export const WORKSPACE_KIT_HOME = "/workspace/.house-kit";
279
+ export const WORKSPACE_KIT_HOME = "/kit-home";
281
280
  export const WORKSPACE_DISK_IMAGE = "disk.img";
282
281
  export const WORKSPACE_DISK_MOUNT = "mnt";
282
+ export const WORKSPACE_DISK_KIT_HOME = "kit-home";
283
+ export const WORKSPACE_DISK_HOUSE = "house";
284
+ export const WORKSPACE_SELECTED_IMAGE = "/opt/house-kit/image";
285
+ export const WORKSPACE_RELEASE_IMAGE = /^ghcr\.io\/agentshouse\/kit@sha256:[0-9a-f]{64}$/;
283
286
  export const WORKSPACE_MOUNT_OPTIONS = "loop,nosuid,nodev";
284
287
  export const HOST_MOUNT_INFO = "/proc/self/mountinfo";
285
288
  export const WORKSPACE_SYSTEM_RESERVE_BYTES = 512 * 1024 * 1024;
286
289
  export const WORKSPACE_COMMAND_TIMEOUT_MS = 120_000;
287
290
  export const WORKSPACE_READY_ATTEMPTS = 60;
288
291
  export const WORKSPACE_READY_POLL_MS = 1_000;
292
+ export const WORKSPACE_LOCK_POLL_MS = 1_000;
289
293
  export const WORKSPACE_STOP_TIMEOUT_SECONDS = 10;
290
294
  export const WORKSPACE_OPERATION_WITHDRAWN = "hosted_environment_operation_not_found";
291
295
  export const CGROUP_CONTROLLERS = "/sys/fs/cgroup/cgroup.controllers";
package/src/workspace.js CHANGED
@@ -1,22 +1,28 @@
1
1
  import { access, mkdir, readFile, rm } from "node:fs/promises";
2
+ import { createServer } from "node:net";
2
3
  import { join } from "node:path";
3
4
  import { readConfiguration } from "./configuration.js";
4
5
  import {
5
6
  CGROUP_CONTROLLERS,
6
7
  HOST_MOUNT_INFO,
8
+ LAUNCH_DIRECTORY_BASE,
7
9
  LOOP_CONTROL,
8
10
  WORKSPACE_ACQUIRE_MAX,
9
11
  WORKSPACE_COMMAND_TIMEOUT_MS,
12
+ WORKSPACE_DISK_HOUSE,
10
13
  WORKSPACE_DISK_IMAGE,
14
+ WORKSPACE_DISK_KIT_HOME,
11
15
  WORKSPACE_DISK_MOUNT,
12
16
  WORKSPACE_KIT_HOME,
13
17
  WORKSPACE_MOUNT_OPTIONS,
14
18
  WORKSPACE_LABEL,
15
- WORKSPACE_MOUNT,
19
+ WORKSPACE_LOCK_POLL_MS,
16
20
  WORKSPACE_OPERATION_WITHDRAWN,
17
21
  WORKSPACE_POLL_MS,
18
22
  WORKSPACE_READY_ATTEMPTS,
19
23
  WORKSPACE_READY_POLL_MS,
24
+ WORKSPACE_RELEASE_IMAGE,
25
+ WORKSPACE_SELECTED_IMAGE,
20
26
  WORKSPACE_STOP_TIMEOUT_SECONDS,
21
27
  WORKSPACE_SYSTEM_RESERVE_BYTES,
22
28
  } from "./contract.js";
@@ -27,10 +33,13 @@ import {
27
33
  openSession,
28
34
  releaseControl,
29
35
  reportWorkspaceOutcome,
36
+ updateDrain,
30
37
  } from "./house.js";
31
38
  import { houseFailed, KitRefusal } from "./refusal.js";
32
39
 
33
40
  const REQUIRED_CONTROLLERS = ["cpu", "memory"];
41
+ const PROVISIONED_DISK = "/disk";
42
+ const STOPPED_STATES = ["created", "exited"];
34
43
  const MEMINFO = "/proc/meminfo";
35
44
  const MEM_AVAILABLE = /^MemAvailable:\s+(\d+)\s+kB$/m;
36
45
 
@@ -106,30 +115,35 @@ export function mountedPaths(document) {
106
115
  );
107
116
  }
108
117
 
109
- export function workspaceCreateArguments(identity, operation, image, house, root) {
118
+ export function workspaceCreateArguments(identity, workspace, image, house, root) {
119
+ const disk = workspaceMountPath(root, identity);
110
120
  return [
111
121
  "create",
112
122
  "--name",
113
123
  identity,
114
124
  "--label",
115
- `${WORKSPACE_LABEL}=${operation.environment}`,
125
+ `${WORKSPACE_LABEL}=${workspace.environment}`,
116
126
  "--cpus",
117
- (operation.maxima.cpu_millicores / 1000).toFixed(3),
127
+ (workspace.maxima.cpu_millicores / 1000).toFixed(3),
118
128
  "--memory",
119
- `${operation.maxima.memory_bytes}b`,
129
+ `${workspace.maxima.memory_bytes}b`,
120
130
  "--memory-swap",
121
- `${operation.maxima.memory_bytes}b`,
131
+ `${workspace.maxima.memory_bytes}b`,
122
132
  "--restart",
123
133
  "no",
134
+ ...(new URL(house).protocol === "https:" ? [] : ["--network", "host"]),
124
135
  "--mount",
125
- `type=bind,source=${workspaceMountPath(root, identity)},target=${WORKSPACE_MOUNT}`,
136
+ `type=bind,source=${disk}/${WORKSPACE_DISK_KIT_HOME},target=${WORKSPACE_KIT_HOME}`,
137
+ "--mount",
138
+ `type=bind,source=${disk}/${WORKSPACE_DISK_HOUSE},target=${LAUNCH_DIRECTORY_BASE}`,
139
+ "--env",
140
+ `HOME=${WORKSPACE_KIT_HOME}`,
126
141
  "--env",
127
142
  `HOUSE_KIT_HOME=${WORKSPACE_KIT_HOME}`,
128
143
  "--env",
129
- `HOUSE_KIT_HOUSE=${house}`,
130
- "--workdir",
131
- WORKSPACE_MOUNT,
144
+ "HOUSE_KIT_CONTAINER=1",
132
145
  image,
146
+ "resident",
133
147
  ];
134
148
  }
135
149
 
@@ -198,7 +212,7 @@ export async function workspacePrerequisites(host, image) {
198
212
  if (formatter.status !== 0) {
199
213
  return {
200
214
  supported: false,
201
- refusal: `the declared workspace image ${image} carries no mkfs.ext4, so no volume ceiling can be created`,
215
+ refusal: `the selected Kit image ${image} carries no mkfs.ext4, so no volume ceiling can be created`,
202
216
  };
203
217
  }
204
218
  return { supported: true, runtime: `docker ${version.stdout.trim()}` };
@@ -215,34 +229,45 @@ function bootstrapConfiguration() {
215
229
  });
216
230
  }
217
231
 
218
- function provisionArguments(root, identity, image, target, mode) {
232
+ function diskArguments(root, identity, image, script) {
219
233
  return [
220
234
  "run",
221
235
  "--rm",
222
236
  "--interactive",
223
237
  "--mount",
224
- `type=bind,source=${workspaceMountPath(root, identity)},target=${WORKSPACE_MOUNT}`,
238
+ `type=bind,source=${workspaceMountPath(root, identity)},target=${PROVISIONED_DISK}`,
225
239
  "--entrypoint",
226
240
  "sh",
227
241
  image,
228
242
  "-c",
229
- `set -e; mkdir -p ${WORKSPACE_KIT_HOME}; chmod 700 ${WORKSPACE_KIT_HOME}; cat > ${target}; chmod ${mode} ${target}`,
243
+ script,
230
244
  ];
231
245
  }
232
246
 
233
- async function provisionWorkspaceState(host, root, identity, image, authority) {
234
- for (const [target, mode, content] of [
235
- [`${WORKSPACE_KIT_HOME}/credential`, "600", `${authority.credential}\n`],
236
- [`${WORKSPACE_KIT_HOME}/kit.json`, "600", `${bootstrapConfiguration()}\n`],
237
- ]) {
238
- const written = await docker(
247
+ async function onDisk(host, settings, identity, script, stdin = "") {
248
+ const written = await docker(host, diskArguments(settings.root, identity, settings.image, script), stdin);
249
+ if (written.status !== 0) {
250
+ throw new KitRefusal("kit_workspace_unprovisioned", written.stderr.trim() || identity);
251
+ }
252
+ }
253
+
254
+ async function provisionWorkspaceState(host, settings, identity, operation) {
255
+ const home = `${PROVISIONED_DISK}/${WORKSPACE_DISK_KIT_HOME}`;
256
+ const house = `${PROVISIONED_DISK}/${WORKSPACE_DISK_HOUSE}`;
257
+ const files = [
258
+ ...(operation.authority === undefined ? [] : [["credential", operation.authority.credential]]),
259
+ ["kit.json", bootstrapConfiguration()],
260
+ ["house", settings.house],
261
+ ["environment", operation.environment],
262
+ ];
263
+ for (const [name, content] of files) {
264
+ await onDisk(
239
265
  host,
240
- provisionArguments(root, identity, image, target, mode),
241
- content,
266
+ settings,
267
+ identity,
268
+ `set -e; mkdir -p ${home} ${house}; chmod 700 ${home} ${house}; cat > ${home}/${name}; chmod 600 ${home}/${name}`,
269
+ `${content}\n`,
242
270
  );
243
- if (written.status !== 0) {
244
- throw new KitRefusal("kit_workspace_unprovisioned", written.stderr.trim() || identity);
245
- }
246
271
  }
247
272
  }
248
273
 
@@ -289,9 +314,7 @@ export async function createWorkspace(host, settings, operation) {
289
314
  }
290
315
  const mountRefusal = await mountWorkspace(host, settings, identity);
291
316
  if (mountRefusal !== null) return { outcome: "unsupported", refusal: mountRefusal };
292
- if (operation.authority !== undefined) {
293
- await provisionWorkspaceState(host, settings.root, identity, settings.image, operation.authority);
294
- }
317
+ await provisionWorkspaceState(host, settings, identity, operation);
295
318
  const created = await docker(
296
319
  host,
297
320
  workspaceCreateArguments(identity, operation, settings.image, settings.house, settings.root),
@@ -312,9 +335,42 @@ async function workspacePresent(host, identity) {
312
335
  return inspected.status === 0 ? inspected.stdout.trim() : null;
313
336
  }
314
337
 
338
+ async function heldWorkspace(host, identity) {
339
+ const inspected = await docker(host, [
340
+ "container",
341
+ "inspect",
342
+ "--format",
343
+ `{{.State.Status}} {{.Config.Image}} {{index .Config.Labels "${WORKSPACE_LABEL}"}} {{.HostConfig.NanoCpus}} {{.HostConfig.Memory}}`,
344
+ identity,
345
+ ]);
346
+ if (inspected.status !== 0) return null;
347
+ const [state, image, environment, nanoCpus, memory] = inspected.stdout.trim().split(/\s+/);
348
+ return {
349
+ state,
350
+ image,
351
+ environment,
352
+ maxima: { cpu_millicores: Number(nanoCpus) / 1_000_000, memory_bytes: Number(memory) },
353
+ };
354
+ }
355
+
356
+ async function replaceWorkspace(host, settings, identity, held) {
357
+ const mountRefusal = await mountWorkspace(host, settings, identity);
358
+ if (mountRefusal !== null) return mountRefusal;
359
+ await onDisk(host, settings, identity, `rm -f ${updateDrain(`${PROVISIONED_DISK}/${WORKSPACE_DISK_KIT_HOME}`)}`);
360
+ const removed = await docker(host, ["rm", "--force", identity]);
361
+ if (removed.status !== 0) return `the workspace was not replaced: ${removed.stderr.trim()}`;
362
+ const created = await docker(
363
+ host,
364
+ workspaceCreateArguments(identity, held, settings.image, settings.house, settings.root),
365
+ );
366
+ if (created.status !== 0) return `the workspace was not recreated: ${created.stderr.trim()}`;
367
+ return null;
368
+ }
369
+
315
370
  export async function startWorkspace(host, settings, operation) {
316
371
  const identity = operation.workspace ?? workspaceIdentity(operation);
317
- if ((await workspacePresent(host, identity)) === null) return { outcome: "absent" };
372
+ const held = await heldWorkspace(host, identity);
373
+ if (held === null) return { outcome: "absent" };
318
374
  const available = memoryAvailable(await host.read(MEMINFO));
319
375
  if (available - operation.maxima.memory_bytes < WORKSPACE_SYSTEM_RESERVE_BYTES) {
320
376
  return {
@@ -324,6 +380,10 @@ export async function startWorkspace(host, settings, operation) {
324
380
  }
325
381
  const mountRefusal = await mountWorkspace(host, settings, identity);
326
382
  if (mountRefusal !== null) return { outcome: "unsupported", refusal: mountRefusal };
383
+ if (STOPPED_STATES.includes(held.state) && held.image !== settings.image) {
384
+ const refusal = await replaceWorkspace(host, settings, identity, held);
385
+ if (refusal !== null) return { outcome: "failed", refusal };
386
+ }
327
387
  const started = await docker(host, ["start", identity]);
328
388
  if (started.status !== 0) {
329
389
  return { outcome: "failed", refusal: `the workspace did not start: ${started.stderr.trim()}` };
@@ -425,37 +485,123 @@ export async function reconcileWorkspaces(host, authorized) {
425
485
  );
426
486
  const stopped = [];
427
487
  for (const managed of await managedWorkspaces(host)) {
428
- if (managed.state !== "running") continue;
429
488
  if (expected.get(managed.environment) === "running") continue;
430
- const settled = await docker(host, [
431
- "stop",
432
- "--timeout",
433
- String(WORKSPACE_STOP_TIMEOUT_SECONDS),
434
- managed.name,
435
- ]);
436
- if (settled.status === 0) stopped.push(managed.name);
489
+ const settled = await exclusively(host, managed.name, async () => {
490
+ if ((await workspacePresent(host, managed.name)) !== "running") return false;
491
+ const stopping = await docker(host, [
492
+ "stop",
493
+ "--timeout",
494
+ String(WORKSPACE_STOP_TIMEOUT_SECONDS),
495
+ managed.name,
496
+ ]);
497
+ return stopping.status === 0;
498
+ });
499
+ if (settled) stopped.push(managed.name);
437
500
  }
438
501
  return stopped;
439
502
  }
440
503
 
504
+ function workspaceLock(identity) {
505
+ return new Promise((resolve, reject) => {
506
+ const lock = createServer();
507
+ lock.once("error", reject);
508
+ lock.listen({ path: `\0${WORKSPACE_LABEL}/${identity}` }, () => resolve(lock));
509
+ });
510
+ }
511
+
512
+ async function exclusively(host, identity, act) {
513
+ let lock = null;
514
+ while (lock === null) {
515
+ try {
516
+ lock = await workspaceLock(identity);
517
+ } catch (error) {
518
+ if (error.code !== "EADDRINUSE") throw error;
519
+ await host.wait(WORKSPACE_LOCK_POLL_MS);
520
+ }
521
+ }
522
+ try {
523
+ return await act();
524
+ } finally {
525
+ await new Promise((resolve) => lock.close(resolve));
526
+ }
527
+ }
528
+
441
529
  export async function performOperation(host, settings, operation) {
442
- if (operation.kind === "create") return createWorkspace(host, settings, operation);
443
- if (operation.kind === "start") return startWorkspace(host, settings, operation);
444
- if (operation.kind === "stop") return stopWorkspace(host, operation);
445
- return deleteWorkspace(host, settings, operation);
530
+ const selected = operation.kind === "stop" ? settings : { ...settings, image: await selectedImage(host) };
531
+ return exclusively(host, operation.workspace ?? workspaceIdentity(operation), () => {
532
+ if (operation.kind === "stop") return stopWorkspace(host, operation);
533
+ if (operation.kind === "create") return createWorkspace(host, selected, operation);
534
+ if (operation.kind === "start") return startWorkspace(host, selected, operation);
535
+ return deleteWorkspace(host, selected, operation);
536
+ });
446
537
  }
447
538
 
448
- export async function serveWorkspaces(options, dependencies = {}) {
539
+ async function selectedImage(host) {
540
+ let image;
541
+ try {
542
+ image = (await host.read(WORKSPACE_SELECTED_IMAGE)).trim();
543
+ } catch {
544
+ image = null;
545
+ }
546
+ if (image === null || !WORKSPACE_RELEASE_IMAGE.test(image)) {
547
+ throw new KitRefusal("kit_workspace_host_unconfigured", WORKSPACE_SELECTED_IMAGE);
548
+ }
549
+ return image;
550
+ }
551
+
552
+ async function hostSettings(options) {
449
553
  const configuration = await readConfiguration(options.configuration);
450
- if (configuration.host === null || configuration.host.image === null) {
554
+ if (configuration.host === null) {
451
555
  throw new KitRefusal("kit_workspace_host_unconfigured", options.configuration);
452
556
  }
557
+ return { root: configuration.host.workspace, house: options.house };
558
+ }
559
+
560
+ async function updateWorkspace(host, settings, name) {
561
+ try {
562
+ const held = await heldWorkspace(host, name);
563
+ if (held === null || held.image === settings.image) return null;
564
+ const running = !STOPPED_STATES.includes(held.state);
565
+ if (running) {
566
+ const drained = await docker(host, ["exec", name, "kit", "resident", "drain"]);
567
+ if (drained.status !== 0) {
568
+ return { workspace: name, cause: drained.stdout.trim() || drained.stderr.trim() };
569
+ }
570
+ }
571
+ const refusal = await replaceWorkspace(host, settings, name, held);
572
+ if (refusal !== null) return { workspace: name, cause: refusal };
573
+ if (running) {
574
+ const started = await docker(host, ["start", name]);
575
+ if (started.status !== 0) {
576
+ return { workspace: name, cause: `the workspace did not start: ${started.stderr.trim()}` };
577
+ }
578
+ }
579
+ return { workspace: name, running };
580
+ } catch (error) {
581
+ if (!(error instanceof KitRefusal)) throw error;
582
+ return { workspace: name, cause: error.message };
583
+ }
584
+ }
585
+
586
+ export async function updateWorkspaces(options, dependencies = {}) {
453
587
  const host = adapter(dependencies);
454
- const settings = {
455
- root: configuration.host.workspace,
456
- image: configuration.host.image,
457
- house: options.house,
458
- };
588
+ const updated = [];
589
+ const unchanged = [];
590
+ const managed = await managedWorkspaces(host);
591
+ if (managed.length === 0) return { updated, unchanged };
592
+ const settings = { ...(await hostSettings(options)), image: await selectedImage(host) };
593
+ for (const { name } of managed) {
594
+ const answer = await exclusively(host, name, () => updateWorkspace(host, settings, name));
595
+ if (answer === null) continue;
596
+ if (answer.cause === undefined) updated.push(answer);
597
+ else unchanged.push(answer);
598
+ }
599
+ return { updated, unchanged };
600
+ }
601
+
602
+ export async function serveWorkspaces(options, dependencies = {}) {
603
+ const host = adapter(dependencies);
604
+ const settings = await hostSettings(options);
459
605
  const session = await openSession(options, dependencies);
460
606
  const settled = [];
461
607
  const withdrawn = [];