From 0b3c8f929197431fa7b80863e74b227c37de0c38 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Fri, 31 Jul 2026 23:14:35 +0000 Subject: [PATCH] Release 0.0.30: Windows WSL OCI, storage bridge, mobile, Linux Podman networks Ship the synchronized desktop train for Windows shared-WSL OCI, host-side Files/Cowork storage helpers, mobile keyboard stability, Skipper mention dedupe, and Linux channel networks that fall back when Podman rejects Docker-style bridge options. Linux packaging injects the sealed channel image required by the host installer. --- .gitignore | 6 + CHANGELOG.md | 17 +- README.md | 2 +- deploy/1helm-oci-runtime-v1.conf | 2 + package-lock.json | 4 +- package.json | 3 +- public/index.html | 4 +- scripts/1helm-oci-runtime | 389 ++++++++++++++++- scripts/build-oci-channel-image.sh | 57 +++ scripts/install-wsl-runtime.ps1 | 306 ++++++++++---- scripts/package-linux-host.mjs | 47 ++- scripts/package-windows.cjs | 21 +- site/public/apply-linux-release.sh | 2 + site/public/install-oci-runtime.sh | 17 +- site/public/install.sh | 2 + site/public/uninstall-host.sh | 2 +- site/public/update-host.sh | 2 + src/client/api.ts | 21 + src/client/app.ts | 5 +- src/client/cowork.ts | 282 ++++++++++++- src/client/dom.ts | 1 + src/client/mobile.ts | 43 +- src/client/onboarding.ts | 175 +++++++- src/client/settings.ts | 70 ++- src/client/styles.css | 35 +- src/server/agents.ts | 296 ++++++++++++- src/server/bots.ts | 13 +- src/server/channel-computers.ts | 654 +++++++++++++++++++++++++++-- src/server/collaboration.ts | 8 +- src/server/connectors.ts | 18 +- src/server/db.ts | 2 +- src/server/index.ts | 98 ++++- test/channel-computers.mjs | 2 +- test/cowork-browser.mjs | 53 ++- test/desktop.mjs | 17 +- test/fake-oci-runtime.mjs | 13 +- test/onboarding-browser.mjs | 28 +- test/site.mjs | 3 +- test/workspace-interactions.mjs | 2 +- 39 files changed, 2465 insertions(+), 257 deletions(-) create mode 100755 scripts/build-oci-channel-image.sh diff --git a/.gitignore b/.gitignore index 547c684..2142fdd 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,12 @@ public/excalidraw/ *.sqlite-shm dist/ +# Sealed OCI channel image (Linux/Windows). Built by scripts/build-oci-channel-image.sh; +# never commit the multi‑hundred‑MB archive into git. +container/channel-machine.oci.tar +container/channel-machine.oci.sha256 +container/channel-machine.oci.json + # local runtime state & session artifacts data-refactored/ .deploy-backups/ diff --git a/CHANGELOG.md b/CHANGELOG.md index be9d11d..e038f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.30] - 2026-07-31 + +### Fixed +- Windows shared WSL OCI bootstrap: script-scoped downloads, UTF-16 WSL output handling, tracked setup progress, and honest readiness errors after cold start. +- Windows channel Files/Cowork no longer depend on `\\wsl.localhost` (interop-off kills 9p). Host IO uses the OCI helper `storage-*` operations. +- Channel storage root stays `0711` so helper verification and Files layout stay consistent. +- Mobile keyboard: focus no longer self-dismisses on tall threads; form controls stay at 16px on small screens to avoid iOS auto-zoom. +- Main-channel `@` suggestions no longer list Skipper twice. +- Linux OCI channel networks: when Podman rejects Docker-style `com.docker.network.bridge.name` (CNI and some netavark builds), the helper falls back to a labeled network create so channel computers provision on Debian/Ubuntu hosts that previously failed with “owned network missing / labels incomplete”. +- Linux package archives now include the sealed channel-machine OCI image required by the host installer. + +### Notes +- Desktop train: macOS notarized DMG + updater ZIP, Linux host archive, Windows Setup + Squirrel package from one version. + ## [0.0.29] - 2026-07-30 ### Changed @@ -863,7 +877,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 notarization, stapled tickets, Gatekeeper verification, persistent Application Support, and isolated Apple container machines. -[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.29...HEAD +[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.30...HEAD +[0.0.30]: https://github.com/gitcommit90/1Helm/compare/v0.0.29...v0.0.30 [0.0.29]: https://github.com/gitcommit90/1Helm/compare/v0.0.28...v0.0.29 [0.0.28]: https://github.com/gitcommit90/1Helm/compare/v0.0.27...v0.0.28 [0.0.27]: https://github.com/gitcommit90/1Helm/compare/v0.0.26...v0.0.27 diff --git a/README.md b/README.md index e9f56f3..0dd1b5b 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to | `PORT` | `8123` | HTTP/WebSocket control-plane port. | | `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and non-OCI development/Apple workspace mirrors. | | `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `oci` on Linux and Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. | -| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.29` | Versioned channel-machine image contract. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.30` | Versioned channel-machine image contract. | ### Agent-first JSON CLI diff --git a/deploy/1helm-oci-runtime-v1.conf b/deploy/1helm-oci-runtime-v1.conf index f552f0f..5ff0ed4 100644 --- a/deploy/1helm-oci-runtime-v1.conf +++ b/deploy/1helm-oci-runtime-v1.conf @@ -2,6 +2,8 @@ ONEHELM_OCI_RUNTIME_VERSION="1helm-oci-runtime-v1" ONEHELM_OCI_STATE_ROOT="/var/lib/1helm-oci-v1/runtime/oci" ONEHELM_OCI_RUN_ROOT="/run/1helm-oci" ONEHELM_OCI_CONTAINERFILE="/usr/lib/1helm-oci/Containerfile.oci" +ONEHELM_OCI_IMAGE_ARCHIVE="/usr/lib/1helm-oci/channel-machine.oci.tar" +ONEHELM_OCI_IMAGE_SHA256_FILE="/usr/lib/1helm-oci/channel-machine.oci.sha256" ONEHELM_OCI_SERVICE_USER="1helm" ONEHELM_OCI_AGENT_UID="1000" ONEHELM_OCI_AGENT_GID="1000" diff --git a/package-lock.json b/package-lock.json index 1d95e34..bfc5a91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1helm", - "version": "0.0.29", + "version": "0.0.30", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.29", + "version": "0.0.30", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index 6fe8748..5e28b3c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.29", + "version": "0.0.30", "private": true, "type": "module", "license": "AGPL-3.0-only", @@ -47,6 +47,7 @@ "desktop": "electron .", "package:mac": "node scripts/package-mac-dmg.cjs", "package:dmg:release": "HELM_REQUIRE_NOTARIZATION=1 node scripts/package-mac-dmg.cjs", + "package:channel-image": "bash scripts/build-oci-channel-image.sh", "package:linux": "node scripts/package-linux-host.mjs", "package:windows": "node scripts/package-windows.cjs", "package:windows:release": "set HELM_REQUIRE_WINDOWS_SIGNATURE=1&& node scripts/package-windows.cjs", diff --git a/public/index.html b/public/index.html index 4b45f99..14b1adb 100644 --- a/public/index.html +++ b/public/index.html @@ -30,12 +30,12 @@ document.querySelectorAll('meta[name="theme-color"]').forEach(function (m) { m.setAttribute("content", color); }); })(); - +
- + diff --git a/scripts/1helm-oci-runtime b/scripts/1helm-oci-runtime index 44a1401..95a96ce 100755 --- a/scripts/1helm-oci-runtime +++ b/scripts/1helm-oci-runtime @@ -14,18 +14,30 @@ if [[ -n "${HELM_OCI_CONTAINERFILE_OVERRIDE:-}" ]]; then [[ "${EUID}" -eq 0 && -z "${SUDO_USER:-}" ]] || { echo "1Helm OCI runtime: recipe override is development-only" >&2; exit 1; } ONEHELM_OCI_CONTAINERFILE="$HELM_OCI_CONTAINERFILE_OVERRIDE" fi +if [[ -n "${HELM_OCI_IMAGE_ARCHIVE_OVERRIDE:-}" ]]; then + [[ "${EUID}" -eq 0 && -z "${SUDO_USER:-}" ]] || { echo "1Helm OCI runtime: image archive override is development-only" >&2; exit 1; } + ONEHELM_OCI_IMAGE_ARCHIVE="$HELM_OCI_IMAGE_ARCHIVE_OVERRIDE" +fi +if [[ -n "${HELM_OCI_IMAGE_SHA256_FILE_OVERRIDE:-}" ]]; then + [[ "${EUID}" -eq 0 && -z "${SUDO_USER:-}" ]] || { echo "1Helm OCI runtime: image digest override is development-only" >&2; exit 1; } + ONEHELM_OCI_IMAGE_SHA256_FILE="$HELM_OCI_IMAGE_SHA256_FILE_OVERRIDE" +fi die() { printf '1Helm OCI runtime: %s\n' "$*" >&2; exit 1; } [[ "${ONEHELM_OCI_RUNTIME_VERSION:-}" == "$RUNTIME_VERSION" ]] || die "runtime manifest version mismatch" [[ "${ONEHELM_OCI_STATE_ROOT:-}" =~ ^/.+/runtime/oci$ ]] || die "unsafe runtime state root" [[ "${ONEHELM_OCI_RUN_ROOT:-}" == /run/1helm-oci ]] || die "unsafe runtime run root" [[ "${ONEHELM_OCI_CONTAINERFILE:-}" == /* ]] || die "unsafe container recipe path" +[[ "${ONEHELM_OCI_IMAGE_ARCHIVE:-}" == /* ]] || die "unsafe channel image archive path" +[[ "${ONEHELM_OCI_IMAGE_SHA256_FILE:-}" == /* ]] || die "unsafe channel image digest path" [[ "${ONEHELM_OCI_SERVICE_USER:-}" =~ ^[a-z0-9_][a-z0-9_-]*$ ]] || die "unsafe service account" [[ "${ONEHELM_OCI_AGENT_UID:-}" =~ ^[0-9]+$ && "${ONEHELM_OCI_AGENT_GID:-}" =~ ^[0-9]+$ ]] || die "invalid agent identity" STATE_ROOT="$ONEHELM_OCI_STATE_ROOT" RUN_ROOT="$ONEHELM_OCI_RUN_ROOT" CONTAINERFILE="$ONEHELM_OCI_CONTAINERFILE" +IMAGE_ARCHIVE="$ONEHELM_OCI_IMAGE_ARCHIVE" +IMAGE_SHA256_FILE="$ONEHELM_OCI_IMAGE_SHA256_FILE" AGENT_UID="$ONEHELM_OCI_AGENT_UID" AGENT_GID="$ONEHELM_OCI_AGENT_GID" STORAGE_ROOT="$STATE_ROOT/storage" @@ -136,9 +148,8 @@ if actual != expected: attached=(row.get("NetworkSettings") or {}).get("Networks") or {} if set(attached) != {network}: raise SystemExit("container network attachment does not match") attachment=attached[network] or {} -network_id=str(network_row.get("id") or network_row.get("ID") or "") -if not network_id or str(attachment.get("NetworkID") or "") != network_id: - raise SystemExit("container network ID does not match") +network_id=str(network_row.get("id") or network_row.get("ID") or network_row.get("network_id") or "").strip() +attached_id=str(attachment.get("NetworkID") or attachment.get("network_id") or "").strip() def option_values(flag): command=(row.get("Config") or {}).get("CreateCommand") or [] @@ -154,20 +165,88 @@ def option_values(flag): if option_values("--network") != [network] or option_values("--ip") != [str(ip)] or option_values("--mac-address") != [mac]: raise SystemExit("container static network creation contract does not match") +def network_ids_compatible(declared, observed, network_name): + """Podman/netavark may use full id, short id, network name, or leave NetworkID empty until start.""" + declared=(declared or "").strip().lower() + observed=(observed or "").strip().lower() + name=(network_name or "").strip().lower() + if not declared and not observed: + return True + if observed and observed == name: + return True + if declared and observed and declared == observed: + return True + if declared and observed and (declared.startswith(observed) or observed.startswith(declared)): + return True + return False + state=str((row.get("State") or {}).get("Status") or "") if state == "running": + if not network_id or not network_ids_compatible(network_id, attached_id, network): + raise SystemExit("container network ID does not match") if str(attachment.get("IPAddress") or "") != str(ip) or str(attachment.get("MacAddress") or "").lower() != mac: raise SystemExit("running container network identity does not match") prefix=int(attachment.get("IPPrefixLen") or 0) if ipaddress.ip_network(f"{ip}/{prefix}", strict=False) != subnet: raise SystemExit("running container subnet does not match") +elif attached_id and network_id and not network_ids_compatible(network_id, attached_id, network): + # Created-but-not-running containers sometimes omit NetworkID; only fail when both sides are present and conflict. + raise SystemExit("container network ID does not match") PY } +# Docker (and some host firewalls) set FORWARD policy DROP and only punch holes +# for their own bridges. 1Helm's netavark networks need explicit accept + NAT so +# resident guests can reach package mirrors and the public internet. Prefer the +# DOCKER-USER chain when present so rules stay ahead of Docker's DROP path. +ensure_guest_egress() { + local network="$1" bridge subnet network_json + command -v iptables >/dev/null || die "iptables is required for channel computer internet access" + # Rootful netavark needs host IPv4 forwarding. + if [[ "$(sysctl -n net.ipv4.ip_forward 2>/dev/null || echo 0)" != "1" ]]; then + sysctl -w net.ipv4.ip_forward=1 >/dev/null + fi + network_json="$("${PODMAN[@]}" network inspect "$network")" + read -r bridge subnet < <(python3 -c ' +import json, sys +rows=json.loads(sys.argv[1]) +if not isinstance(rows, list) or not rows: raise SystemExit("owned network metadata is invalid") +row=rows[0] +bridge=str(row.get("network_interface") or row.get("NetworkInterface") or "") +subnets=row.get("subnets") or row.get("Subnets") or [] +if not subnets: raise SystemExit("owned network subnet is missing") +subnet=str(subnets[0].get("subnet") or subnets[0].get("Subnet") or "") +if not bridge or bridge == "": raise SystemExit("owned network interface is missing") +if "/" not in subnet: raise SystemExit("owned network subnet is invalid") +print(bridge, subnet) +' "$network_json") || die "owned network interface/subnet could not be resolved" + + insert_if_missing() { + local table="${1:-filter}" chain="$2"; shift 2 + if [[ "$table" == filter ]]; then + iptables -C "$chain" "$@" 2>/dev/null || iptables -I "$chain" 1 "$@" + else + iptables -t "$table" -C "$chain" "$@" 2>/dev/null || iptables -t "$table" -I "$chain" 1 "$@" + fi + } + + # Accept guest→world and established replies. Insert at head so Docker DROP cannot bury them. + if iptables -nL DOCKER-USER >/dev/null 2>&1; then + insert_if_missing filter DOCKER-USER -i "$bridge" -j ACCEPT + insert_if_missing filter DOCKER-USER -o "$bridge" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT + fi + insert_if_missing filter FORWARD -i "$bridge" -j ACCEPT + insert_if_missing filter FORWARD -o "$bridge" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT + # SNAT guest traffic leaving the host (not hairpin back onto the bridge). + insert_if_missing nat POSTROUTING -s "$subnet" ! -o "$bridge" -j MASQUERADE +} + ensure_network() { - local owner="$1" installation network labels + local owner="$1" installation network labels bridge_name installation="$(owner_installation "$owner")" network="1helm-$installation" + # Linux IFNAMSIZ is 15 chars; keep a stable, installation-scoped bridge name. + bridge_name="1h${installation:0:12}" if "${PODMAN[@]}" network exists "$network"; then labels="$("${PODMAN[@]}" network inspect --format '{{json .Labels}}' "$network")" python3 - "$labels" "$installation" <<'PY' @@ -177,9 +256,25 @@ if labels.get("com.1helm.managed") != "true" or labels.get("com.1helm.installati raise SystemExit("network ownership labels do not match") PY else - "${PODMAN[@]}" network create --disable-dns=false \ - --label com.1helm.managed=true --label "com.1helm.installation=$installation" "$network" >/dev/null + # Prefer a stable IFNAMSIZ-safe bridge name when the backend supports Docker- + # style bridge options (netavark). Debian bookworm's CNI backend rejects + # com.docker.network.bridge.name; fall back to a labeled default bridge and + # let ensure_guest_egress read the real interface from network inspect. + create_out="" + if ! create_out="$("${PODMAN[@]}" network create --disable-dns=false \ + --opt "com.docker.network.bridge.name=${bridge_name}" \ + --label com.1helm.managed=true --label "com.1helm.installation=$installation" "$network" 2>&1)"; then + if grep -qiE 'unsupported (bridge )?network option|unknown network option' <<<"$create_out"; then + "${PODMAN[@]}" network create --disable-dns=false \ + --label com.1helm.managed=true --label "com.1helm.installation=$installation" "$network" >/dev/null \ + || die "channel network could not be created" + else + printf '%s\n' "$create_out" >&2 + die "channel network could not be created" + fi + fi fi + ensure_guest_egress "$network" printf '%s' "$network" } @@ -331,6 +426,9 @@ prepare_storage() { [[ ! -e "$root/var-lib-1helm/owner" || "$(tr -d '\r\n' <"$root/var-lib-1helm/owner")" == "$owner" ]] || die "existing channel storage has a different owner" fi install -d -o root -g root -m 0711 "$root" + # Agent-owned workspace/files. On Windows, Files/Cowork never touch this tree + # via \\wsl.localhost (interop=false kills 9p); they use the storage_* helper + # operations instead. Keep modes tight. install -d -o "$AGENT_UID" -g "$AGENT_GID" -m 0700 "$root/workspace" "$root/files" "$root/home" chown -R "$AGENT_UID:$AGENT_GID" "$root/workspace" "$root/files" "$root/home" install -d -o root -g root -m 0700 "$root/var-lib-1helm" @@ -339,6 +437,190 @@ prepare_storage() { for shared in "$root/workspace" "$root/files" "$root/home"; do grant_shared_access "$shared"; done } +# Resolve a Files/Cowork relative path into an absolute, containment-checked +# path under the channel's workspace/ or files/ tree. Empty path is the tree root. +resolve_storage_path() { + local name="$1" area="$2" rel="$3" root base candidate + root="$(channel_root "$name")" + case "$area" in + workspace) base="$root/workspace" ;; + files) base="$root/files" ;; + *) die "invalid storage area" ;; + esac + [[ -d "$base" && ! -L "$base" ]] || die "storage area is missing" + if [[ -z "$rel" || "$rel" == "." ]]; then + printf '%s' "$base" + return + fi + # Reject absolute paths. Multi-dot filenames are fine (board.whiteboard.json, + # deck.slides.json); only whole-segment "." / ".." are blocked. + [[ "$rel" != /* ]] || die "unsafe storage path" + local segment + IFS='/' read -r -a segments <<<"$rel" + for segment in "${segments[@]}"; do + [[ -n "$segment" ]] || die "unsafe storage path" + [[ "$segment" != "." && "$segment" != ".." ]] || die "unsafe storage path" + # Allow letters, digits, dot, underscore, hyphen, space. No slashes (already split). + if [[ ! "$segment" =~ ^[A-Za-z0-9._\ -]+$ ]]; then + die "unsafe storage path" + fi + done + candidate="$base/$rel" + # Containment: realpath of candidate must stay under realpath of base. + local real_base real_candidate parent + real_base="$(realpath -m "$base")" + if [[ -e "$candidate" || -L "$candidate" ]]; then + real_candidate="$(realpath -m "$candidate")" + else + parent="$(realpath -m "$(dirname "$candidate")")" + [[ "$parent" == "$real_base" || "$parent" == "$real_base"/* ]] || die "storage path escapes channel tree" + real_candidate="$parent/$(basename "$candidate")" + fi + [[ "$real_candidate" == "$real_base" || "$real_candidate" == "$real_base"/* ]] || die "storage path escapes channel tree" + # Refuse any symlink component along the path. + local cursor="$real_base" part + IFS='/' read -r -a parts <<<"${real_candidate#"$real_base"/}" + if [[ "$real_candidate" != "$real_base" ]]; then + for part in "${parts[@]}"; do + [[ -n "$part" ]] || continue + cursor="$cursor/$part" + if [[ -L "$cursor" ]]; then die "storage path must not traverse symlinks"; fi + done + fi + printf '%s' "$real_candidate" +} + +storage_list() { + local name="$1" owner="$2" area="$3" rel="${4:-}" path + verify_storage "$name" "$owner" + path="$(resolve_storage_path "$name" "$area" "$rel")" + [[ -d "$path" && ! -L "$path" ]] || die "Folder not found." + python3 - "$path" <<'PY' +import json, os, sys +path = sys.argv[1] +entries = [] +for name in sorted(os.listdir(path)): + full = os.path.join(path, name) + if os.path.islink(full): + continue + if os.path.isdir(full): + st = os.stat(full) + entries.append({"name": name, "kind": "directory", "size": 0, "modified": int(st.st_mtime * 1000)}) + elif os.path.isfile(full): + st = os.stat(full) + entries.append({"name": name, "kind": "file", "size": int(st.st_size), "modified": int(st.st_mtime * 1000)}) +print(json.dumps({"entries": entries}, separators=(",", ":"))) +PY +} + +storage_stat() { + local name="$1" owner="$2" area="$3" rel="${4:-}" path + verify_storage "$name" "$owner" + path="$(resolve_storage_path "$name" "$area" "$rel")" + [[ -e "$path" && ! -L "$path" ]] || die "File or folder not found." + python3 - "$path" <<'PY' +import json, os, sys +path = sys.argv[1] +st = os.stat(path) +kind = "directory" if os.path.isdir(path) else "file" if os.path.isfile(path) else "other" +print(json.dumps({ + "name": os.path.basename(path) or ".", + "kind": kind, + "size": 0 if kind == "directory" else int(st.st_size), + "modified": int(st.st_mtime * 1000), + "exists": True, +}, separators=(",", ":"))) +PY +} + +storage_mkdir() { + local name="$1" owner="$2" area="$3" rel="$4" path parent + verify_storage "$name" "$owner" + [[ -n "$rel" ]] || die "mkdir requires a path" + path="$(resolve_storage_path "$name" "$area" "$rel")" + parent="$(dirname "$path")" + [[ -d "$parent" && ! -L "$parent" ]] || die "Parent folder not found." + [[ ! -e "$path" ]] || die "A file or folder with that name already exists." + install -d -o "$AGENT_UID" -g "$AGENT_GID" -m 0700 "$path" + storage_stat "$name" "$owner" "$area" "$rel" +} + +storage_write() { + local name="$1" owner="$2" area="$3" rel="$4" mode="${5:-overwrite}" path parent + verify_storage "$name" "$owner" + [[ -n "$rel" ]] || die "write requires a path" + path="$(resolve_storage_path "$name" "$area" "$rel")" + parent="$(dirname "$path")" + [[ -d "$parent" && ! -L "$parent" ]] || die "Parent folder not found." + if [[ "$mode" == "create" && -e "$path" ]]; then die "A file or folder with that name already exists."; fi + if [[ -e "$path" && ( -L "$path" || -d "$path" ) ]]; then die "Choose a file to edit."; fi + # Body arrives on stdin (binary-safe). + cat >"$path" + chown "$AGENT_UID:$AGENT_GID" "$path" + chmod 0600 "$path" + storage_stat "$name" "$owner" "$area" "$rel" +} + +storage_read() { + local name="$1" owner="$2" area="$3" rel="$4" path + verify_storage "$name" "$owner" + [[ -n "$rel" ]] || die "read requires a path" + path="$(resolve_storage_path "$name" "$area" "$rel")" + [[ -f "$path" && ! -L "$path" ]] || die "File not found." + cat -- "$path" +} + +storage_rm() { + local name="$1" owner="$2" area="$3" rel="$4" recursive="${5:-0}" path + verify_storage "$name" "$owner" + [[ -n "$rel" ]] || die "rm requires a path" + path="$(resolve_storage_path "$name" "$area" "$rel")" + local base + base="$(resolve_storage_path "$name" "$area" "")" + [[ "$path" != "$base" ]] || die "Refusing to remove the storage root." + [[ -e "$path" && ! -L "$path" ]] || die "File or folder not found." + if [[ -d "$path" ]]; then + [[ "$recursive" == "1" ]] || die "Folder is not empty; remove recursively." + rm -rf -- "$path" + else + rm -f -- "$path" + fi + printf '{"ok":true}\n' +} + +storage_rename() { + local name="$1" owner="$2" area="$3" from_rel="$4" to_rel="$5" from to to_parent + verify_storage "$name" "$owner" + [[ -n "$from_rel" && -n "$to_rel" ]] || die "rename requires source and destination" + from="$(resolve_storage_path "$name" "$area" "$from_rel")" + to="$(resolve_storage_path "$name" "$area" "$to_rel")" + [[ -e "$from" && ! -L "$from" ]] || die "File or folder not found." + to_parent="$(dirname "$to")" + [[ -d "$to_parent" && ! -L "$to_parent" ]] || die "Parent folder not found." + [[ ! -e "$to" ]] || die "A file or folder with that name already exists." + mv -- "$from" "$to" + storage_stat "$name" "$owner" "$area" "$to_rel" +} + +storage_copy() { + local name="$1" owner="$2" area="$3" from_rel="$4" to_rel="$5" from to to_parent + verify_storage "$name" "$owner" + [[ -n "$from_rel" && -n "$to_rel" ]] || die "copy requires source and destination" + from="$(resolve_storage_path "$name" "$area" "$from_rel")" + to="$(resolve_storage_path "$name" "$area" "$to_rel")" + [[ -e "$from" && ! -L "$from" ]] || die "File or folder not found." + to_parent="$(dirname "$to")" + [[ -d "$to_parent" && ! -L "$to_parent" ]] || die "Parent folder not found." + [[ ! -e "$to" ]] || die "A file or folder with that name already exists." + if [[ -d "$from" ]]; then + cp -a -- "$from" "$to" + else + cp -- "$from" "$to" + fi + chown -R "$AGENT_UID:$AGENT_GID" "$to" + storage_stat "$name" "$owner" "$area" "$to_rel" +} + create_container() { local name="$1" owner="$2" cpus="$3" memory_mb="$4" image="$5" engine_image root network network_identity ip mac valid_name "$name" && valid_owner "$owner" && valid_resources "$cpus" "$memory_mb" && valid_image "$image" || die "invalid create arguments" @@ -361,8 +643,12 @@ create_container() { } start_container() { - local name="$1" owner="$2" state + local name="$1" owner="$2" state network verify_container "$name" "$owner" + network="$(network_name "$owner")" + # Re-assert egress on every start: Docker and host firewall reloads can drop + # our FORWARD/NAT inserts after boot or package updates. + if "${PODMAN[@]}" network exists "$network"; then ensure_guest_egress "$network"; fi state="$(container_state "$name")" if [[ "$state" != running ]]; then "${PODMAN[@]}" start "$name" >/dev/null; fi for _ in {1..100}; do @@ -411,16 +697,48 @@ print(json.dumps({ PY } +load_pinned_image() { + local image="$1" engine_image expected actual + valid_image "$image" || die "invalid image reference" + engine_image="$(podman_image "$image")" + if "${PODMAN[@]}" image exists "$engine_image"; then return; fi + [[ -f "$IMAGE_ARCHIVE" && ! -L "$IMAGE_ARCHIVE" ]] || die "sealed channel image archive is missing: $IMAGE_ARCHIVE" + [[ -f "$IMAGE_SHA256_FILE" && ! -L "$IMAGE_SHA256_FILE" ]] || die "sealed channel image digest file is missing: $IMAGE_SHA256_FILE" + expected="$(tr -d '[:space:]' <"$IMAGE_SHA256_FILE")" + [[ "$expected" =~ ^[a-f0-9]{64}$ ]] || die "sealed channel image digest is invalid" + actual="$(sha256sum "$IMAGE_ARCHIVE" | awk '{print $1}')" + [[ "$actual" == "$expected" ]] || die "sealed channel image digest does not match" + # Customer hosts load a CI-built archive. They never apt against live mirrors. + "${PODMAN[@]}" load --input "$IMAGE_ARCHIVE" >/dev/null + if ! "${PODMAN[@]}" image exists "$engine_image"; then + # oci-archive load may restore a different local name; retag the only loaded ref if needed. + loaded="$("${PODMAN[@]}" images --format '{{.Repository}}:{{.Tag}}' | awk -F: '$1 ~ /1helm-channel-machine$/ {print; exit}')" + [[ -n "$loaded" && "$loaded" != ":" ]] || die "sealed channel image did not load under a usable name" + "${PODMAN[@]}" tag "$loaded" "$engine_image" + fi + "${PODMAN[@]}" image exists "$engine_image" || die "sealed channel image was not present after load" +} + build_image() { local image="$1" engine_image valid_image "$image" || die "invalid image reference" engine_image="$(podman_image "$image")" - [[ -r "$CONTAINERFILE" ]] || die "installed OCI image recipe is missing" if "${PODMAN[@]}" image exists "$engine_image"; then return; fi - "${PODMAN[@]}" build --pull=missing \ - --build-arg "AGENT_UID=$AGENT_UID" --build-arg "AGENT_GID=$AGENT_GID" \ - --tag "$engine_image" --file "$CONTAINERFILE" "$(dirname "$CONTAINERFILE")" - "${PODMAN[@]}" image exists "$engine_image" || die "OCI image did not exist after build" + if [[ -f "$IMAGE_ARCHIVE" && -f "$IMAGE_SHA256_FILE" ]]; then + load_pinned_image "$image" + return + fi + # Live Containerfile builds are development-only. Product Linux/Windows installs + # ship the sealed archive and must never depend on customer-side apt mirrors. + if [[ "${HELM_OCI_ALLOW_LIVE_BUILD:-}" == "1" ]]; then + [[ -r "$CONTAINERFILE" ]] || die "installed OCI image recipe is missing" + "${PODMAN[@]}" build --network=host --pull=missing \ + --build-arg "AGENT_UID=$AGENT_UID" --build-arg "AGENT_GID=$AGENT_GID" \ + --tag "$engine_image" --file "$CONTAINERFILE" "$(dirname "$CONTAINERFILE")" + "${PODMAN[@]}" image exists "$engine_image" || die "OCI image did not exist after build" + return + fi + die "sealed channel image archive is not installed; product hosts load a pinned image instead of building live" } copy_tree_from_container() { @@ -556,7 +874,7 @@ case "$operation" in ;; ready) (($# == 0)) || die "ready takes no arguments" - for command in find flock getfacl podman python3 setfacl sha256sum stat tar; do command -v "$command" >/dev/null || die "missing $command"; done + for command in find flock getfacl iptables podman python3 setfacl sha256sum stat tar; do command -v "$command" >/dev/null || die "missing $command"; done [[ -r "$CONTAINERFILE" ]] || die "installed OCI image recipe is missing" "${PODMAN[@]}" info >/dev/null printf '{"ready":true,"version":"%s","engine":"podman"}\n' "$RUNTIME_VERSION" @@ -565,6 +883,16 @@ case "$operation" in (($# == 1)) || die "image requires a reference" build_image "$1" ;; + image-status) + (($# == 1)) || die "image-status requires a reference" + valid_image "$1" || die "invalid image reference" + engine_image="$(podman_image "$1")" + if "${PODMAN[@]}" image exists "$engine_image"; then + printf '{"exists":true,"image":"%s"}\n' "$1" + else + printf '{"exists":false,"image":"%s"}\n' "$1" + fi + ;; create) (($# == 5)) || die "create requires name, owner, CPUs, memory MiB, and image" ! container_exists "$1" || die "container already exists" @@ -642,5 +970,40 @@ case "$operation" in [[ "$root" == "$CHANNELS_ROOT/$1" ]] || die "unsafe channel cleanup target" rm -rf -- "$root" ;; + # Host Files/Cowork bridge: Windows cannot use \\wsl.localhost when interop is + # disabled (isolation contract). These ops read/write channel storage as root + # under the helper's ownership checks, never via the Windows 9p share. + storage-list) + (($# == 3 || $# == 4)) || die "storage-list requires name, owner, area [, relative path]" + storage_list "$1" "$2" "$3" "${4:-}" + ;; + storage-stat) + (($# == 3 || $# == 4)) || die "storage-stat requires name, owner, area [, relative path]" + storage_stat "$1" "$2" "$3" "${4:-}" + ;; + storage-mkdir) + (($# == 4)) || die "storage-mkdir requires name, owner, area, relative path" + storage_mkdir "$1" "$2" "$3" "$4" + ;; + storage-write) + (($# == 4 || $# == 5)) || die "storage-write requires name, owner, area, relative path [, create|overwrite]" + storage_write "$1" "$2" "$3" "$4" "${5:-overwrite}" + ;; + storage-read) + (($# == 4)) || die "storage-read requires name, owner, area, relative path" + storage_read "$1" "$2" "$3" "$4" + ;; + storage-rm) + (($# == 4 || $# == 5)) || die "storage-rm requires name, owner, area, relative path [, 0|1 recursive]" + storage_rm "$1" "$2" "$3" "$4" "${5:-0}" + ;; + storage-rename) + (($# == 5)) || die "storage-rename requires name, owner, area, from, to" + storage_rename "$1" "$2" "$3" "$4" "$5" + ;; + storage-copy) + (($# == 5)) || die "storage-copy requires name, owner, area, from, to" + storage_copy "$1" "$2" "$3" "$4" "$5" + ;; *) die "unsupported operation" ;; esac diff --git a/scripts/build-oci-channel-image.sh b/scripts/build-oci-channel-image.sh new file mode 100755 index 0000000..ddcc82d --- /dev/null +++ b/scripts/build-oci-channel-image.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Build the sealed Linux/Windows channel-computer image once on a builder host. +# Output is a digest-pinned OCI archive next to the recipe — not for git. +# Apple/Mac channel machines are unaffected. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VERSION="$(sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$ROOT/package.json" | head -n1)" +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "package.json version is required" >&2; exit 1; } + +case "$(uname -m)" in + x86_64|amd64) ARCH=amd64 ;; + aarch64|arm64) ARCH=arm64 ;; + *) echo "Unsupported builder architecture: $(uname -m)" >&2; exit 1 ;; +esac + +IMAGE_REF="local/1helm-channel-machine:${VERSION}" +ENGINE_IMAGE="localhost/${IMAGE_REF}" +CONTAINERFILE="$ROOT/container/Containerfile.oci" +OUT_TAR="$ROOT/container/channel-machine.oci.tar" +OUT_SHA="$ROOT/container/channel-machine.oci.sha256" +OUT_META="$ROOT/container/channel-machine.oci.json" + +[[ -r "$CONTAINERFILE" ]] || { echo "Missing $CONTAINERFILE" >&2; exit 1; } +command -v podman >/dev/null || { echo "podman is required to build the sealed channel image" >&2; exit 1; } + +# Host network avoids Docker FORWARD=DROP and broken IPv6 on many builder hosts. +# This is builder-only; customer hosts load the sealed archive and never apt. +echo "Building sealed channel image ${IMAGE_REF} (${ARCH})…" +podman build --network=host --pull=missing \ + --build-arg AGENT_UID=1000 --build-arg AGENT_GID=1000 \ + --tag "$ENGINE_IMAGE" \ + --file "$CONTAINERFILE" \ + "$ROOT/container" + +podman image exists "$ENGINE_IMAGE" +rm -f -- "$OUT_TAR" "$OUT_SHA" "$OUT_META" +podman save --format oci-archive --output "$OUT_TAR" "$ENGINE_IMAGE" +sha256sum "$OUT_TAR" | awk '{print $1}' >"$OUT_SHA" +DIGEST="$(tr -d '[:space:]' <"$OUT_SHA")" +python3 - "$OUT_META" "$IMAGE_REF" "$ARCH" "$VERSION" "$DIGEST" "$OUT_TAR" <<'PY' +import json, os, sys +meta_path, image, arch, version, digest, archive = sys.argv[1:] +json.dump({ + "image": image, + "arch": arch, + "version": version, + "sha256": digest, + "archive": os.path.basename(archive), + "backend": "oci", + "platforms": ["linux", "windows"], +}, open(meta_path, "w", encoding="utf-8"), indent=2) +print(meta_path) +PY +chmod 0644 "$OUT_TAR" "$OUT_SHA" "$OUT_META" +ls -lh "$OUT_TAR" "$OUT_SHA" "$OUT_META" +printf 'Sealed channel image ready: %s sha256=%s\n' "$OUT_TAR" "$DIGEST" diff --git a/scripts/install-wsl-runtime.ps1 b/scripts/install-wsl-runtime.ps1 index 6bd8d69..910f30f 100644 --- a/scripts/install-wsl-runtime.ps1 +++ b/scripts/install-wsl-runtime.ps1 @@ -1,7 +1,9 @@ param( [Parameter(Mandatory = $true)][string]$RuntimeName, [Parameter(Mandatory = $true)][string]$AppRoot, - [switch]$HostSetup + [switch]$HostSetup, + # Shared status file path so elevated HostSetup can report real errors to the parent/UI. + [string]$StatusPath = "" ) $ErrorActionPreference = "Stop" @@ -17,47 +19,143 @@ if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne [Sys throw "This 1Helm build requires Microsoft's x64 WSL runtime." } +if ($StatusPath) { $env:HELM_WSL_SETUP_STATUS = $StatusPath } + +# Script-scoped: both elevated host setup and the signed-in owner path download files. +function Fetch-File { + param([string]$Url, [string]$Destination) + $curl = "curl.exe" + if (Get-Command $curl -ErrorAction SilentlyContinue) { + & $curl -fsSLo $Destination $Url + if ($LASTEXITCODE -ne 0) { throw "curl failed to download $Url" } + } else { + Invoke-WebRequest -UseBasicParsing -Uri $Url -OutFile $Destination + } +} + +# wsl.exe emits UTF-16LE. Captured as a .NET string that still contains NULs, so +# version/list matches fail unless the NULs are stripped first. +function Get-WslText { + param([Parameter(Mandatory = $true)][string[]]$ArgumentList) + $raw = & $wsl @ArgumentList 2>&1 | Out-String + $code = $LASTEXITCODE + $text = ($raw -replace [char]0, "").Trim() + return [pscustomobject]@{ ExitCode = $code; Text = $text } +} + function Test-PinnedWslRuntime { - $output = (& $wsl --version 2>&1 | Out-String) - return $LASTEXITCODE -eq 0 -and $output -match [regex]::Escape($wslVersion) + $result = Get-WslText -ArgumentList @("--version") + return $result.ExitCode -eq 0 -and $result.Text -match [regex]::Escape($wslVersion) +} + +function Test-RestartRequired { + param($Feature) + if ($null -eq $Feature) { return $false } + $value = [string]$Feature.RestartRequired + return $value -eq "Required" -or $value -eq "1" -or $value -eq "True" +} + +function Get-WslDistributionNames { + $result = Get-WslText -ArgumentList @("--list", "--quiet") + if ($result.ExitCode -ne 0) { return @() } + return @( + $result.Text -split "(\r?\n)+" | + ForEach-Object { $_.Trim() } | + Where-Object { $_ } + ) +} + +function Write-SetupStatus { + param( + [string]$Status, + [string]$Step, + [int]$Progress, + [string]$ErrorMessage = "" + ) + Write-Host $Step + $path = $env:HELM_WSL_SETUP_STATUS + if (-not $path) { return } + try { + $dir = Split-Path -Parent $path + if ($dir -and -not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + $payload = @{ + status = $Status + step = $Step + progress = $Progress + error = $ErrorMessage + updated = (Get-Date).ToUniversalTime().ToString("o") + } | ConvertTo-Json -Compress + # Windows PowerShell's "utf8" encoding writes a BOM that turns status text into + # mojibake (e.g. "base...") in the Electron UI. Write plain UTF-8 instead. + [System.IO.File]::WriteAllText($path, $payload, [System.Text.UTF8Encoding]::new($false)) + } catch { + # Status reporting must never abort setup. + } +} + +function Fail-Setup { + param([string]$Message, [int]$Code = 1) + Write-SetupStatus -Status "failed" -Step $Message -Progress 0 -ErrorMessage $Message + Write-Host "ERROR: $Message" -ForegroundColor Red + exit $Code } if ($HostSetup) { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]::new($identity) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { - throw "The WSL host setup phase requires administrator approval." + Fail-Setup "The WSL host setup phase requires administrator approval." } + Write-SetupStatus -Status "running" -Step "Enabling Windows WSL features..." -Progress 8 $wslFeature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux $vmFeature = Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform if ($wslFeature.State -ne "Enabled") { Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux -All -NoRestart | Out-Null } if ($vmFeature.State -ne "Enabled") { Enable-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform -All -NoRestart | Out-Null } - $restartRequired = (Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux).RestartRequired -or - (Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform).RestartRequired + $wslFeature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux + $vmFeature = Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform + $restartRequired = (Test-RestartRequired $wslFeature) -or (Test-RestartRequired $vmFeature) $hostTemporary = Join-Path ([System.IO.Path]::GetTempPath()) ("1helm-wsl-host-" + [Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $hostTemporary | Out-Null try { - if (-not (Test-PinnedWslRuntime)) { - $msi = Join-Path $hostTemporary "wsl.candidate.msi" - Invoke-WebRequest -UseBasicParsing -Uri $wslInstallerUrl -OutFile $msi - if ((Get-FileHash -LiteralPath $msi -Algorithm SHA256).Hash.ToLowerInvariant() -ne $wslInstallerSha256) { - throw "Microsoft WSL installer did not match 1Helm's pinned SHA-256." + if (-not (Test-PinnedWslRuntime)) { + $msi = Join-Path $hostTemporary "wsl.candidate.msi" + Write-SetupStatus -Status "running" -Step "Downloading Microsoft WSL installer..." -Progress 12 + Fetch-File -Url $wslInstallerUrl -Destination $msi + if ((Get-FileHash -LiteralPath $msi -Algorithm SHA256).Hash.ToLowerInvariant() -ne $wslInstallerSha256) { + Fail-Setup "Microsoft WSL installer did not match 1Helm's pinned SHA-256." + } + $signature = Get-AuthenticodeSignature -LiteralPath $msi + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or $null -eq $signature.SignerCertificate -or + $signature.SignerCertificate.Subject -notmatch '(^|,\s*)CN=Microsoft Corporation(,|$)') { + Fail-Setup "Microsoft WSL installer did not have a valid Microsoft Corporation signature." + } + Write-SetupStatus -Status "running" -Step "Installing Microsoft WSL $wslVersion..." -Progress 18 + $installer = Start-Process -FilePath "$env:SystemRoot\System32\msiexec.exe" -ArgumentList @("/i", $msi, "/qn", "/norestart") -Wait -PassThru + # 0 success; 1641/3010 success+reboot; 1638 already installed (same/newer). + # 1603 is a hard fail from msiexec, but on machines that already have the + # pinned WSL build (or UTF-16 made detection fail earlier) the package may + # still leave a working runtime - re-verify before failing closed. + if ($installer.ExitCode -in @(1641, 3010)) { $restartRequired = $true } + elseif ($installer.ExitCode -notin @(0, 1638)) { + if (Test-PinnedWslRuntime) { + Write-Host "Microsoft WSL installer returned $($installer.ExitCode), but pinned WSL $wslVersion is already present; continuing." + } else { + Fail-Setup "Microsoft WSL installer failed with exit code $($installer.ExitCode)." + } + } + } else { + Write-SetupStatus -Status "running" -Step "Microsoft WSL $wslVersion is already installed." -Progress 18 } - $signature = Get-AuthenticodeSignature -LiteralPath $msi - if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or $null -eq $signature.SignerCertificate -or - $signature.SignerCertificate.Subject -notmatch '(^|,\s*)CN=Microsoft Corporation(,|$)') { - throw "Microsoft WSL installer did not have a valid Microsoft Corporation signature." + if ($restartRequired) { + Write-SetupStatus -Status "restart_required" -Step "WSL 2 features are enabled. Restart Windows once, then retry 1Helm computer setup." -Progress 20 + exit 10 } - $installer = Start-Process -FilePath "$env:SystemRoot\System32\msiexec.exe" -ArgumentList @("/i", $msi, "/qn", "/norestart") -Wait -PassThru - if ($installer.ExitCode -notin @(0, 1641, 3010)) { throw "Microsoft WSL installer failed with exit code $($installer.ExitCode)." } - if ($installer.ExitCode -in @(1641, 3010)) { $restartRequired = $true } - } - if ($restartRequired) { - exit 10 - } - if (-not (Test-PinnedWslRuntime)) { throw "Microsoft WSL $wslVersion was installed but could not be verified." } - & $wsl --set-default-version 2 - if ($LASTEXITCODE -ne 0) { throw "WSL could not set version 2 as the default." } + if (-not (Test-PinnedWslRuntime)) { Fail-Setup "Microsoft WSL $wslVersion was installed but could not be verified." } + Write-SetupStatus -Status "running" -Step "Setting WSL 2 as the default..." -Progress 22 + $defaultVersion = Get-WslText -ArgumentList @("--set-default-version", "2") + if ($defaultVersion.ExitCode -ne 0) { Fail-Setup "WSL could not set version 2 as the default. $($defaultVersion.Text)" } } finally { if (Test-Path -LiteralPath $hostTemporary) { Remove-Item -LiteralPath $hostTemporary -Recurse -Force } } @@ -68,72 +166,122 @@ if ($HostSetup) { # features and Microsoft's signed WSL package cross the UAC boundary; importing # the distribution in that child would attach it to a different administrator # when over-the-shoulder credentials are used. -$hostArguments = @( - "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", ('"{0}"' -f $PSCommandPath), - "-RuntimeName", $RuntimeName, "-AppRoot", ('"{0}"' -f $AppRoot), "-HostSetup" -) -join " " -$hostProcess = Start-Process -FilePath "powershell.exe" -ArgumentList $hostArguments -Verb RunAs -Wait -PassThru -if ($hostProcess.ExitCode -eq 10) { - Write-Host "WSL 2 features are enabled. Restart Windows once, then retry 1Helm computer setup." - exit 10 -} -if ($hostProcess.ExitCode -ne 0) { throw "The administrator-approved WSL host setup failed with exit code $($hostProcess.ExitCode)." } -if (-not (Test-PinnedWslRuntime)) { throw "Microsoft WSL $wslVersion is not ready in the signed-in user's session." } - -$AppRoot = [System.IO.Path]::GetFullPath($AppRoot) -$required = @( - (Join-Path $AppRoot "scripts\1helm-oci-runtime"), - (Join-Path $AppRoot "deploy\1helm-oci-runtime-v1.conf"), - (Join-Path $AppRoot "container\Containerfile.oci") -) -foreach ($source in $required) { - if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { throw "The packaged OCI runtime contract is incomplete." } -} - -$temporary = Join-Path ([System.IO.Path]::GetTempPath()) ("1helm-oci-runtime-" + [Guid]::NewGuid().ToString("N")) -New-Item -ItemType Directory -Path $temporary | Out-Null try { + Write-SetupStatus -Status "running" -Step "Requesting administrator approval for WSL host setup..." -Progress 5 + $statusArg = if ($env:HELM_WSL_SETUP_STATUS) { $env:HELM_WSL_SETUP_STATUS } else { "" } + $hostArguments = @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", ('"{0}"' -f $PSCommandPath), + "-RuntimeName", $RuntimeName, "-AppRoot", ('"{0}"' -f $AppRoot), "-HostSetup" + ) + if ($statusArg) { + $hostArguments += @("-StatusPath", ('"{0}"' -f $statusArg)) + } + $hostProcess = Start-Process -FilePath "powershell.exe" -ArgumentList ($hostArguments -join " ") -Verb RunAs -Wait -PassThru + if ($null -eq $hostProcess -or $null -eq $hostProcess.ExitCode) { + Fail-Setup "Administrator approval was cancelled or Windows did not start the elevated WSL host setup." + } + if ($hostProcess.ExitCode -eq 10) { + Write-SetupStatus -Status "restart_required" -Step "WSL 2 features are enabled. Restart Windows once, then retry 1Helm computer setup." -Progress 20 + Write-Host "WSL 2 features are enabled. Restart Windows once, then retry 1Helm computer setup." + exit 10 + } + if ($hostProcess.ExitCode -ne 0) { + $detail = "The administrator-approved WSL host setup failed with exit code $($hostProcess.ExitCode)." + if ($env:HELM_WSL_SETUP_STATUS -and (Test-Path -LiteralPath $env:HELM_WSL_SETUP_STATUS)) { + try { + $reported = Get-Content -LiteralPath $env:HELM_WSL_SETUP_STATUS -Raw | ConvertFrom-Json + if ($reported.error) { $detail = [string]$reported.error } + elseif ($reported.step -and $reported.status -eq "failed") { $detail = [string]$reported.step } + } catch { } + } + Fail-Setup $detail + } + if (-not (Test-PinnedWslRuntime)) { + Fail-Setup "Microsoft WSL $wslVersion is not ready in the signed-in user's session." + } - $names = @(& $wsl --list --quiet | ForEach-Object { $_.Trim() } | Where-Object { $_ }) - $runtimeRoot = Join-Path $env:LOCALAPPDATA "1Helm-Runtime" - $installDirectory = Join-Path $runtimeRoot $RuntimeName - if ($names -notcontains $RuntimeName) { - if (Test-Path -LiteralPath $installDirectory) { throw "The shared runtime disk directory already exists without a registered runtime." } - New-Item -ItemType Directory -Path $installDirectory -Force | Out-Null - $rootfs = Join-Path $temporary "ubuntu-noble-wsl.rootfs.tar.gz" - Invoke-WebRequest -UseBasicParsing -Uri $rootfsUrl -OutFile $rootfs - if ((Get-FileHash -LiteralPath $rootfs -Algorithm SHA256).Hash.ToLowerInvariant() -ne $rootfsSha256) { - throw "Ubuntu's pinned WSL rootfs failed SHA-256 verification." + $AppRoot = [System.IO.Path]::GetFullPath($AppRoot) + $required = @( + (Join-Path $AppRoot "scripts\1helm-oci-runtime"), + (Join-Path $AppRoot "deploy\1helm-oci-runtime-v1.conf"), + (Join-Path $AppRoot "container\Containerfile.oci"), + (Join-Path $AppRoot "container\channel-machine.oci.tar"), + (Join-Path $AppRoot "container\channel-machine.oci.sha256") + ) + foreach ($source in $required) { + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + Fail-Setup "The packaged OCI runtime contract is incomplete: missing $(Split-Path -Leaf $source)." } - & $wsl --import $RuntimeName $installDirectory $rootfs --version 2 - if ($LASTEXITCODE -ne 0) { throw "The shared 1Helm WSL runtime could not be imported." } } + $expectedImageSha = (Get-Content -LiteralPath $required[4] -Raw).Trim().ToLowerInvariant() + if ($expectedImageSha -notmatch '^[a-f0-9]{64}$') { Fail-Setup "The sealed channel image digest file is invalid." } + $actualImageSha = (Get-FileHash -LiteralPath $required[3] -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualImageSha -ne $expectedImageSha) { Fail-Setup "The sealed channel image digest does not match." } + + $temporary = Join-Path ([System.IO.Path]::GetTempPath()) ("1helm-oci-runtime-" + [Guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $temporary | Out-Null + try { + Write-SetupStatus -Status "running" -Step "Checking for the shared 1Helm WSL runtime..." -Progress 28 + $names = @(Get-WslDistributionNames) + $runtimeRoot = Join-Path $env:LOCALAPPDATA "1Helm-Runtime" + $installDirectory = Join-Path $runtimeRoot $RuntimeName + if ($names -notcontains $RuntimeName) { + if (Test-Path -LiteralPath $installDirectory) { + Fail-Setup "The shared runtime disk directory already exists without a registered runtime. Remove `"$installDirectory`" or unregister the partial distro, then retry." + } + New-Item -ItemType Directory -Path $installDirectory -Force | Out-Null + $rootfs = Join-Path $temporary "ubuntu-noble-wsl.rootfs.tar.gz" + Write-SetupStatus -Status "running" -Step "Downloading shared Linux runtime base..." -Progress 35 + Fetch-File -Url $rootfsUrl -Destination $rootfs + if ((Get-FileHash -LiteralPath $rootfs -Algorithm SHA256).Hash.ToLowerInvariant() -ne $rootfsSha256) { + Fail-Setup "Ubuntu's pinned WSL rootfs failed SHA-256 verification." + } + Write-SetupStatus -Status "running" -Step "Importing shared Linux runtime..." -Progress 48 + $imported = Get-WslText -ArgumentList @("--import", $RuntimeName, $installDirectory, $rootfs, "--version", "2") + if ($imported.ExitCode -ne 0) { Fail-Setup "The shared 1Helm WSL runtime could not be imported. $($imported.Text)" } + } - $bootstrap = @' + Write-SetupStatus -Status "running" -Step "Installing shared runtime packages (podman, crun, ...)..." -Progress 58 + $bootstrap = @' set -euo pipefail export DEBIAN_FRONTEND=noninteractive apt-get update -apt-get install -y --no-install-recommends acl ca-certificates crun fuse-overlayfs podman python3 sudo uidmap util-linux +apt-get install -y --no-install-recommends acl ca-certificates crun fuse-overlayfs iptables podman python3 sudo uidmap util-linux apt-get clean rm -rf /var/lib/apt/lists/* id 1helm >/dev/null 2>&1 || useradd --system --home-dir /var/lib/1helm-oci-v1 --no-create-home --shell /usr/sbin/nologin 1helm install -d -m 0755 /etc/1helm /usr/libexec /usr/lib/1helm-oci printf '[automount]\nenabled=false\nmountFsTab=false\n\n[interop]\nenabled=false\nappendWindowsPath=false\n\n[user]\ndefault=root\n\n[boot]\nsystemd=true\n' >/etc/wsl.conf '@ - & $wsl --distribution $RuntimeName --user root --exec /bin/bash -lc $bootstrap - if ($LASTEXITCODE -ne 0) { throw "The shared 1Helm runtime prerequisites could not be installed." } - - $unc = "\\wsl.localhost\$RuntimeName" - Copy-Item -LiteralPath $required[0] -Destination "$unc\usr\libexec\1helm-oci-runtime" -Force - Copy-Item -LiteralPath $required[1] -Destination "$unc\etc\1helm\oci-runtime-v1.conf" -Force - Copy-Item -LiteralPath $required[2] -Destination "$unc\usr\lib\1helm-oci\Containerfile.oci" -Force - & $wsl --distribution $RuntimeName --user root --exec /bin/chmod 0755 /usr/libexec/1helm-oci-runtime - if ($LASTEXITCODE -ne 0) { throw "The OCI runtime helper permissions could not be applied." } - & $wsl --terminate $RuntimeName - if ($LASTEXITCODE -ne 0) { throw "The shared runtime could not restart into its isolation policy." } - & $wsl --distribution $RuntimeName --user root --exec /usr/libexec/1helm-oci-runtime ready - if ($LASTEXITCODE -ne 0) { throw "The shared OCI runtime did not pass readiness verification." } - Write-Host "1Helm's shared OCI runtime is installed and ready." -} finally { - if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Recurse -Force } + $bootstrapped = Get-WslText -ArgumentList @("--distribution", $RuntimeName, "--user", "root", "--exec", "/bin/bash", "-lc", $bootstrap) + if ($bootstrapped.ExitCode -ne 0) { Fail-Setup "The shared 1Helm runtime prerequisites could not be installed. $($bootstrapped.Text)" } + + Write-SetupStatus -Status "running" -Step "Installing the sealed OCI helper and channel image..." -Progress 78 + $unc = "\\wsl.localhost\$RuntimeName" + Copy-Item -LiteralPath $required[0] -Destination "$unc\usr\libexec\1helm-oci-runtime" -Force + Copy-Item -LiteralPath $required[1] -Destination "$unc\etc\1helm\oci-runtime-v1.conf" -Force + Copy-Item -LiteralPath $required[2] -Destination "$unc\usr\lib\1helm-oci\Containerfile.oci" -Force + Copy-Item -LiteralPath $required[3] -Destination "$unc\usr\lib\1helm-oci\channel-machine.oci.tar" -Force + Copy-Item -LiteralPath $required[4] -Destination "$unc\usr\lib\1helm-oci\channel-machine.oci.sha256" -Force + $meta = Join-Path $AppRoot "container\channel-machine.oci.json" + if (Test-Path -LiteralPath $meta -PathType Leaf) { + Copy-Item -LiteralPath $meta -Destination "$unc\usr\lib\1helm-oci\channel-machine.oci.json" -Force + } + $chmod = Get-WslText -ArgumentList @("--distribution", $RuntimeName, "--user", "root", "--exec", "/bin/chmod", "0755", "/usr/libexec/1helm-oci-runtime") + if ($chmod.ExitCode -ne 0) { Fail-Setup "The OCI runtime helper permissions could not be applied. $($chmod.Text)" } + Write-SetupStatus -Status "running" -Step "Restarting the shared runtime into its isolation policy..." -Progress 88 + $terminated = Get-WslText -ArgumentList @("--terminate", $RuntimeName) + if ($terminated.ExitCode -ne 0) { Fail-Setup "The shared runtime could not restart into its isolation policy. $($terminated.Text)" } + Write-SetupStatus -Status "running" -Step "Verifying the shared OCI runtime..." -Progress 94 + $ready = Get-WslText -ArgumentList @("--distribution", $RuntimeName, "--user", "root", "--exec", "/usr/libexec/1helm-oci-runtime", "ready") + if ($ready.ExitCode -ne 0) { Fail-Setup "The shared OCI runtime did not pass readiness verification. $($ready.Text)" } + Write-SetupStatus -Status "complete" -Step "1Helm's shared OCI runtime is installed and ready." -Progress 100 + Write-Host "1Helm's shared OCI runtime is installed and ready." + } finally { + if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Recurse -Force } + } +} catch { + $message = $_.Exception.Message + if (-not $message) { $message = "$_" } + Fail-Setup $message } diff --git a/scripts/package-linux-host.mjs b/scripts/package-linux-host.mjs index eb6d208..5a1504a 100755 --- a/scripts/package-linux-host.mjs +++ b/scripts/package-linux-host.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node -import { mkdirSync, readFileSync, rmSync } from "node:fs"; -import { resolve } from "node:path"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; const root = resolve(import.meta.dirname, ".."); @@ -8,11 +9,43 @@ const version = String(JSON.parse(readFileSync(resolve(root, "package.json"), "u if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error("package.json must contain a release version"); const dist = resolve(root, "dist"); const output = resolve(dist, `1Helm-${version}-linux-node.tgz`); +const sealed = [ + "container/channel-machine.oci.tar", + "container/channel-machine.oci.sha256", + "container/channel-machine.oci.json", +]; +for (const rel of sealed.slice(0, 2)) { + if (!existsSync(resolve(root, rel))) { + throw new Error(`Linux packaging requires the sealed channel image at ${rel} (run scripts/build-oci-channel-image.sh on a builder host).`); + } +} + mkdirSync(dist, { recursive: true }); rmSync(output, { force: true }); -const result = spawnSync("git", ["archive", "--format=tar.gz", `--prefix=1Helm-${version}/`, "-o", output, "HEAD"], { - cwd: root, - stdio: "inherit", -}); -if (result.status !== 0) throw new Error("Could not package the exact Git release source"); + +const stage = mkdtempSync(join(tmpdir(), "1helm-linux-pkg-")); +try { + const prefix = `1Helm-${version}`; + const archive = spawnSync("git", ["archive", "--format=tar", `--prefix=${prefix}/`, "HEAD"], { + cwd: root, + encoding: "buffer", + maxBuffer: 512 * 1024 * 1024, + }); + if (archive.status !== 0) throw new Error("Could not package the exact Git release source"); + const extract = spawnSync("tar", ["-xf", "-", "-C", stage], { input: archive.stdout, stdio: ["pipe", "inherit", "inherit"] }); + if (extract.status !== 0) throw new Error("Could not extract the Git release source for packaging"); + + const containerDir = join(stage, prefix, "container"); + mkdirSync(containerDir, { recursive: true }); + for (const rel of sealed) { + const src = resolve(root, rel); + if (!existsSync(src)) continue; + copyFileSync(src, join(stage, prefix, rel)); + } + + const pack = spawnSync("tar", ["-czf", output, "-C", stage, prefix], { stdio: "inherit" }); + if (pack.status !== 0) throw new Error("Could not write the Linux host archive"); +} finally { + rmSync(stage, { recursive: true, force: true }); +} console.log(output); diff --git a/scripts/package-windows.cjs b/scripts/package-windows.cjs index a3a6511..da17c47 100644 --- a/scripts/package-windows.cjs +++ b/scripts/package-windows.cjs @@ -13,10 +13,14 @@ const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")) const VERSION = String(pkg.version || "").trim(); const REQUIRE_SIGNATURE = process.env.HELM_REQUIRE_WINDOWS_SIGNATURE === "1"; const CERT_SHA1 = String(process.env.WINDOWS_SIGN_CERT_SHA1 || "").replace(/\s+/g, "").toUpperCase(); +// Keep the Windows connector pin in lockstep with package-mac-dmg.cjs. +const CLOUDFLARED_VERSION = "2026.3.0"; +const CLOUDFLARED_URL = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-windows-amd64.exe`; +const CLOUDFLARED_SHA256 = "59b12880b24af581cf5b1013db601c7d843b9b097e9c78aa5957c7f39f741885"; // Electron Packager evaluates directories before their children. Keep the // scripts directory itself traversable, then retain only the three runtime // files below; otherwise the exact-file exceptions can never be reached. -const IGNORE_NON_RUNTIME_ROOTS = /^\/(?!package\.json$|LICENSE$|NOTICE$|desktop(?:$|\/)|container(?:$|\/Containerfile\.oci$)|deploy(?:$|\/1helm-oci-runtime-v1\.conf$)|src(?:$|\/)|public(?:$|\/)|scripts(?:$|\/(?:1helm-oci-runtime|mnemosyne-bridge\.py|install-wsl-runtime\.ps1|windows-removal\.cjs)$)|node_modules(?:$|\/))/; +const IGNORE_NON_RUNTIME_ROOTS = /^\/(?!package\.json$|LICENSE$|NOTICE$|desktop(?:$|\/)|container(?:$|\/(?:Containerfile\.oci|channel-machine\.oci\.(?:tar|sha256|json))$)|deploy(?:$|\/1helm-oci-runtime-v1\.conf$)|src(?:$|\/)|public(?:$|\/)|scripts(?:$|\/(?:1helm-oci-runtime|mnemosyne-bridge\.py|install-wsl-runtime\.ps1|windows-removal\.cjs)$)|node_modules(?:$|\/))/; // Excalidraw is compiled into public/bundle.js. Shipping its source package as // well adds deeply nested Radix paths that legacy Squirrel/NuGet cannot // releasify under Windows' 260-character path limit. @@ -65,6 +69,16 @@ function signPackagedExecutables(appDir) { for (const target of targets.sort()) sign(target); } +function prepareCloudflared(destination) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "1helm-cloudflared-win-")); + const binary = path.join(root, "cloudflared.exe"); + run("curl.exe", ["-fsSL", CLOUDFLARED_URL, "-o", binary]); + const digest = capture("certutil.exe", ["-hashfile", binary, "SHA256"]).split(/\r?\n/).find((line) => /^[a-f0-9 ]{64,}$/i.test(line.trim()))?.replaceAll(" ", "").toLowerCase() || ""; + if (digest !== CLOUDFLARED_SHA256) throw new Error(`Bundled cloudflared digest mismatch (got ${digest || "unavailable"}).`); + fs.copyFileSync(binary, destination); + fs.rmSync(root, { recursive: true, force: true }); +} + async function main() { fs.mkdirSync(DIST, { recursive: true }); const iconRoot = fs.mkdtempSync(path.join(os.tmpdir(), "1helm-win-icon-")); @@ -91,6 +105,9 @@ async function main() { }); const appExe = path.join(appDir, "1Helm.exe"); if (!fs.existsSync(appExe)) throw new Error("Packaged Windows application is missing 1Helm.exe."); + const cloudflared = path.join(appDir, "resources", "cloudflared.exe"); + prepareCloudflared(cloudflared); + if (!fs.existsSync(cloudflared)) throw new Error("Packaged Windows app is missing cloudflared.exe."); signPackagedExecutables(appDir); const pty = path.join(appDir, "resources", "app", "node_modules", "node-pty", "prebuilds", "win32-x64", "pty.node"); if (!fs.existsSync(pty)) throw new Error("Packaged Windows app is missing the x64 terminal module."); @@ -105,6 +122,8 @@ async function main() { path.join(appDir, "resources", "app", "scripts", "1helm-oci-runtime"), path.join(appDir, "resources", "app", "deploy", "1helm-oci-runtime-v1.conf"), path.join(appDir, "resources", "app", "container", "Containerfile.oci"), + path.join(appDir, "resources", "app", "container", "channel-machine.oci.tar"), + path.join(appDir, "resources", "app", "container", "channel-machine.oci.sha256"), ]) if (!fs.existsSync(required)) throw new Error(`Packaged Windows app is missing ${path.basename(required)}.`); } diff --git a/site/public/apply-linux-release.sh b/site/public/apply-linux-release.sh index 95de681..5017760 100755 --- a/site/public/apply-linux-release.sh +++ b/site/public/apply-linux-release.sh @@ -22,6 +22,8 @@ HOST_CONTRACT_PATHS=( /etc/sudoers.d/1helm-oci-runtime /etc/tmpfiles.d/1helm-oci.conf /usr/lib/1helm-oci/Containerfile.oci + /usr/lib/1helm-oci/channel-machine.oci.tar + /usr/lib/1helm-oci/channel-machine.oci.sha256 /etc/systemd/system/1helm.service /etc/systemd/system/1helm-update.service /etc/systemd/system/1helm-update.path diff --git a/site/public/install-oci-runtime.sh b/site/public/install-oci-runtime.sh index ad62737..6270599 100755 --- a/site/public/install-oci-runtime.sh +++ b/site/public/install-oci-runtime.sh @@ -12,16 +12,22 @@ SUDOERS_PATH="/etc/sudoers.d/1helm-oci-runtime" [[ "${EUID}" -eq 0 ]] || { echo "The OCI runtime installer must run as root." >&2; exit 1; } [[ -x "$APP_SOURCE/scripts/1helm-oci-runtime" && -r "$APP_SOURCE/deploy/1helm-oci-runtime-v1.conf" && -r "$APP_SOURCE/container/Containerfile.oci" ]] \ || { echo "The verified 1Helm release is missing its OCI runtime contract." >&2; exit 1; } +[[ -f "$APP_SOURCE/container/channel-machine.oci.tar" && -f "$APP_SOURCE/container/channel-machine.oci.sha256" ]] \ + || { echo "The verified 1Helm release is missing its sealed channel computer image (container/channel-machine.oci.tar)." >&2; exit 1; } +expected_image_sha="$(tr -d '[:space:]' <"$APP_SOURCE/container/channel-machine.oci.sha256")" +[[ "$expected_image_sha" =~ ^[a-f0-9]{64}$ ]] || { echo "The sealed channel image digest file is invalid." >&2; exit 1; } +actual_image_sha="$(sha256sum "$APP_SOURCE/container/channel-machine.oci.tar" | awk '{print $1}')" +[[ "$actual_image_sha" == "$expected_image_sha" ]] || { echo "The sealed channel image digest does not match." >&2; exit 1; } id "$SERVICE_USER" >/dev/null 2>&1 || { echo "The 1Helm service account does not exist." >&2; exit 1; } command -v apt-get >/dev/null || { echo "The OCI Linux runtime currently requires Ubuntu or Debian with apt." >&2; exit 1; } missing=() -for command in crun find flock getfacl podman python3 setfacl sha256sum stat sudo tar visudo; do command -v "$command" >/dev/null || missing+=("$command"); done +for command in crun find flock getfacl iptables podman python3 setfacl sha256sum stat sudo tar visudo; do command -v "$command" >/dev/null || missing+=("$command"); done if ((${#missing[@]})); then apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y acl ca-certificates crun fuse-overlayfs podman python3 sudo uidmap util-linux + DEBIAN_FRONTEND=noninteractive apt-get install -y acl ca-certificates crun fuse-overlayfs iptables podman python3 sudo uidmap util-linux fi -for command in crun find flock getfacl podman python3 setfacl sha256sum stat sudo tar visudo; do command -v "$command" >/dev/null || { echo "Missing OCI prerequisite after setup: $command" >&2; exit 1; }; done +for command in crun find flock getfacl iptables podman python3 setfacl sha256sum stat sudo tar visudo; do command -v "$command" >/dev/null || { echo "Missing OCI prerequisite after setup: $command" >&2; exit 1; }; done [[ "$(stat -fc %T /sys/fs/cgroup)" == cgroup2fs ]] || { echo "1Helm OCI resource controls require cgroup v2." >&2; exit 1; } install -d -o root -g root -m 0755 /etc/1helm "$RECIPE_ROOT" /usr/libexec @@ -29,6 +35,11 @@ install -d -o root -g root -m 0711 "$STATE_ROOT/runtime/oci" "$STATE_ROOT/runtim install -d -o root -g root -m 0700 "$STATE_ROOT/runtime/oci/storage" "$STATE_ROOT/runtime/oci/backups" "$STATE_ROOT/runtime/oci/networks" install -o root -g root -m 0644 "$APP_SOURCE/deploy/1helm-oci-runtime-v1.conf" "$MANIFEST_PATH" install -o root -g root -m 0644 "$APP_SOURCE/container/Containerfile.oci" "$RECIPE_ROOT/Containerfile.oci" +install -o root -g root -m 0644 "$APP_SOURCE/container/channel-machine.oci.tar" "$RECIPE_ROOT/channel-machine.oci.tar" +install -o root -g root -m 0644 "$APP_SOURCE/container/channel-machine.oci.sha256" "$RECIPE_ROOT/channel-machine.oci.sha256" +if [[ -f "$APP_SOURCE/container/channel-machine.oci.json" ]]; then + install -o root -g root -m 0644 "$APP_SOURCE/container/channel-machine.oci.json" "$RECIPE_ROOT/channel-machine.oci.json" +fi install -o root -g root -m 0755 "$APP_SOURCE/scripts/1helm-oci-runtime" "$HELPER_PATH" TEMP_ROOT="$(mktemp -d)" diff --git a/site/public/install.sh b/site/public/install.sh index 84a03d7..ce3a2d2 100644 --- a/site/public/install.sh +++ b/site/public/install.sh @@ -17,6 +17,8 @@ HOST_CONTRACT_PATHS=( /etc/sudoers.d/1helm-oci-runtime /etc/tmpfiles.d/1helm-oci.conf /usr/lib/1helm-oci/Containerfile.oci + /usr/lib/1helm-oci/channel-machine.oci.tar + /usr/lib/1helm-oci/channel-machine.oci.sha256 /etc/systemd/system/1helm.service /etc/systemd/system/1helm-update.service /etc/systemd/system/1helm-update.path diff --git a/site/public/uninstall-host.sh b/site/public/uninstall-host.sh index d2c3693..9171171 100755 --- a/site/public/uninstall-host.sh +++ b/site/public/uninstall-host.sh @@ -31,7 +31,7 @@ done [[ "$($HELPER list "1helm-$INSTALLATION_ID-channel-")" == "[]" ]] || { echo "Some owned channel containers remained; application files were not removed." >&2; exit 1; } systemctl disable --now 1helm-update.path 2>/dev/null || true rm -f -- /etc/systemd/system/1helm.service /etc/systemd/system/1helm-update.service /etc/systemd/system/1helm-update.path -rm -f -- /etc/sudoers.d/1helm-oci-runtime /etc/1helm/oci-runtime-v1.conf /usr/libexec/1helm-oci-runtime /usr/lib/1helm-oci/Containerfile.oci +rm -f -- /etc/sudoers.d/1helm-oci-runtime /etc/1helm/oci-runtime-v1.conf /usr/libexec/1helm-oci-runtime /usr/lib/1helm-oci/Containerfile.oci /usr/lib/1helm-oci/channel-machine.oci.tar /usr/lib/1helm-oci/channel-machine.oci.sha256 /usr/lib/1helm-oci/channel-machine.oci.json rm -f -- "$INSTALL_ROOT/update-host.sh" "$INSTALL_ROOT/uninstall-host.sh" systemctl daemon-reload printf 'Removed the 1Helm services and %s owned channel container(s). Preserved %s and versioned release files for recovery.\n' "${#MACHINES[@]}" "$STATE_ROOT" diff --git a/site/public/update-host.sh b/site/public/update-host.sh index ee8237f..51b752c 100755 --- a/site/public/update-host.sh +++ b/site/public/update-host.sh @@ -19,6 +19,8 @@ HOST_CONTRACT_PATHS=( /etc/sudoers.d/1helm-oci-runtime /etc/tmpfiles.d/1helm-oci.conf /usr/lib/1helm-oci/Containerfile.oci + /usr/lib/1helm-oci/channel-machine.oci.tar + /usr/lib/1helm-oci/channel-machine.oci.sha256 /etc/systemd/system/1helm.service /etc/systemd/system/1helm-update.service /etc/systemd/system/1helm-update.path diff --git a/src/client/api.ts b/src/client/api.ts index 555c7ad..fc47717 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -71,8 +71,29 @@ export type ActivityItem = { action_tool?: string | null; action_input?: string | null; action_result?: string | null; }; export type Computer = { id: number; name: string; base_url: string; has_key: boolean }; +export type ChannelComputerPrepare = { + status: "idle" | "running" | "complete" | "failed"; + step: string; + progress: number; + error: string; + image: string; + started_at: number; + updated_at: number; +}; +export type WindowsWslSetup = { + status: "idle" | "running" | "complete" | "failed" | "restart_required"; + step: string; + progress: number; + error: string; + log?: string; + started_at: number; + updated_at: number; +}; export type ChannelRuntime = { backend: "apple" | "oci" | "native" | "mock"; supported: boolean; ready: boolean; + engine_ready?: boolean; image_ready?: boolean; image?: string | null; + prepare?: ChannelComputerPrepare | null; + windows_setup?: WindowsWslSetup | null; platform?: string; architecture?: string; darwin?: boolean; arm64?: boolean; macos_version?: string | null; cli?: string | null; version?: unknown; system?: unknown; runtime_version?: string | null; installer_url?: string; installer_sha256?: string; shared_runtime?: string | null; storage_authority?: string | null; diff --git a/src/client/app.ts b/src/client/app.ts index 3c7fb11..532c051 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -3110,7 +3110,10 @@ function composerAutocomplete(input: HTMLTextAreaElement, box: HTMLElement): voi const channel = S.channels.find((item) => item.id === S.channelId); const resident = channel?.agent?.bot_id ? S.bots.find((bot) => bot.id === channel.agent!.bot_id) : undefined; const skipper = S.bots.find((bot) => bot.agent_kind === "skipper"); - const agentCandidates = channel?.kind === "channel" ? [resident, skipper] : []; + // In #main the resident IS Skipper — dedupe by bot id or the list shows Skipper twice. + const agentCandidates = channel?.kind === "channel" + ? [resident, skipper].filter((bot, index, list) => !!bot && list.findIndex((other) => other?.id === bot.id) === index) + : []; const agentMatches: ComposerSuggest[] = agentCandidates .filter((bot): bot is Bot => !!bot && bot.name.toLowerCase().startsWith(at)) .map((bot) => ({ kind: "agent", token: bot.name, label: bot.name, detail: bot.agent_kind === "skipper" ? "Workspace Skipper" : bot.model || "Resident agent" })); diff --git a/src/client/cowork.ts b/src/client/cowork.ts index fb41551..992810c 100644 --- a/src/client/cowork.ts +++ b/src/client/cowork.ts @@ -21,6 +21,7 @@ type SectionSession = { loaded: boolean; preview: boolean; activeSlide: number; + slideStripScroll?: number; collaboration: CoworkDocument | null; mounted: MountedEditor | null; view: HTMLElement | null; @@ -108,20 +109,100 @@ function normalizeDeck(content: string): Deck { return { type: "1helm-slides", version: 3, printableArea: printableArea(parsed.printableArea), slides: parsed.slides.length ? parsed.slides.map((slide: Partial, index: number) => ({ id: slide.id || crypto.randomUUID(), name: slide.name || `Slide ${index + 1}`, scene: slide.scene?.elements ? sceneWithoutPrintableBoundary(slide.scene) : blankScene() })) : blankDeck().slides }; } if (Array.isArray(parsed?.slides)) { - return { - type: "1helm-slides", version: 3, printableArea: { ...DEFAULT_PRINTABLE_AREA }, - slides: parsed.slides.length ? parsed.slides.map((slide: { title?: string; body?: string }, index: number) => ({ - id: crypto.randomUUID(), name: slide.title || `Slide ${index + 1}`, - scene: { ...blankScene(), elements: [ - ...(slide.title ? [{ type: "text", id: crypto.randomUUID(), x: 120, y: 120, width: 720, height: 70, text: slide.title, originalText: slide.title, fontSize: 42, fontFamily: 1, textAlign: "center", verticalAlign: "top", strokeColor: "#1b1b1f", backgroundColor: "transparent", fillStyle: "solid", strokeWidth: 1, roughness: 1, opacity: 100, angle: 0, seed: 1, version: 1, versionNonce: 1, index: "a0", isDeleted: false, groupIds: [], frameId: null, roundness: null, boundElements: null, link: null, locked: false, containerId: null, autoResize: true, lineHeight: 1.25 }] : []), - ...(slide.body ? [{ type: "text", id: crypto.randomUUID(), x: 160, y: 240, width: 640, height: 160, text: slide.body, originalText: slide.body, fontSize: 24, fontFamily: 1, textAlign: "left", verticalAlign: "top", strokeColor: "#495057", backgroundColor: "transparent", fillStyle: "solid", strokeWidth: 1, roughness: 1, opacity: 100, angle: 0, seed: 2, version: 1, versionNonce: 2, index: "a1", isDeleted: false, groupIds: [], frameId: null, roundness: null, boundElements: null, link: null, locked: false, containerId: null, autoResize: true, lineHeight: 1.25 }] : []), - ] }, - })) : blankDeck().slides, - }; + return simpleDeckToScenes(parsed as SimpleDeck); } throw new Error("Unsupported presentation file"); } +/** Simple-format decks ({slides:[{title,body}]}) get a real layout: themed + * background and title band, content-sized text boxes with word wrap and + * font step-down, and per-line bullet rendering. */ +type SimpleDeck = { slides: Array<{ title?: string; body?: string }>; theme?: { primary?: string; secondary?: string; background?: string; text?: string; accent?: string } }; + +function simpleDeckToScenes(parsed: SimpleDeck): Deck { + const area = { ...DEFAULT_PRINTABLE_AREA }; + const safeColor = (value: unknown, fallback: string): string => typeof value === "string" && /^#[0-9a-fA-F]{3,8}$/.test(value.trim()) ? value.trim() : fallback; + const theme = { + primary: safeColor(parsed.theme?.primary, "#4c6ef5"), + background: safeColor(parsed.theme?.background, "#ffffff"), + text: safeColor(parsed.theme?.text, "#1b1b1f"), + muted: safeColor(parsed.theme?.secondary, "#495057"), + accent: safeColor(parsed.theme?.accent, safeColor(parsed.theme?.primary, "#4c6ef5")), + }; + const margin = 110; + const contentWidth = area.width - margin * 2; + // Excalidraw's default font is close to 0.6em average glyph width; measure + // wrapped lines conservatively so boxes are sized to their contents. + const wrapCount = (text: string, fontSize: number): number => { + const perLine = Math.max(8, Math.floor(contentWidth / (fontSize * 0.65))); + return text.split("\n").reduce((count, line) => count + Math.max(1, Math.ceil(line.length / perLine)), 0); + }; + let seed = 1; + const textElement = (text: string, x: number, y: number, width: number, fontSize: number, color: string, align: string, bold = false): Record => ({ + type: "text", id: crypto.randomUUID(), x, y, width, + height: Math.ceil(wrapCount(text, fontSize) * fontSize * 1.6), + text, originalText: text, fontSize, fontFamily: bold ? 2 : 1, textAlign: align, verticalAlign: "top", + strokeColor: color, backgroundColor: "transparent", fillStyle: "solid", strokeWidth: 1, roughness: 0, opacity: 100, + angle: 0, seed: seed++, version: 1, versionNonce: seed, index: `a${seed}`, isDeleted: false, groupIds: [], frameId: null, + roundness: null, boundElements: null, link: null, locked: false, containerId: null, autoResize: true, lineHeight: 1.25, + }); + const bar = (x: number, y: number, width: number, height: number, color: string): Record => ({ + type: "rectangle", id: crypto.randomUUID(), x, y, width, height, angle: 0, + strokeColor: "transparent", backgroundColor: color, fillStyle: "solid", strokeWidth: 1, strokeStyle: "solid", + roughness: 0, opacity: 100, groupIds: [], frameId: null, roundness: { type: 3 }, seed: seed++, version: 1, + versionNonce: seed, index: `a${seed}`, isDeleted: false, boundElements: null, updated: 1, link: null, locked: false, + }); + const slides = (parsed.slides.length ? parsed.slides : [{ title: "Slide 1" }]).map((slide, index) => { + const title = String(slide.title || "").trim(); + const body = String(slide.body || "").trim(); + const elements: Record[] = []; + const titleOnly = title && !body; + if (titleOnly) { + // Section divider: centered title over an accent underline. + elements.push(textElement(title, margin, area.height / 2 - 60, contentWidth, 64, theme.text, "center", true)); + elements.push(bar(area.width / 2 - 120, area.height / 2 + 40, 240, 10, theme.accent)); + } else { + elements.push(bar(0, 0, area.width, 14, theme.accent)); + if (title) { + elements.push(textElement(title, margin, 70, contentWidth, 52, theme.text, "left", true)); + elements.push(bar(margin, 150, 150, 7, theme.accent)); + } + if (body) { + // Render bullets as separate, spaced rows; prose as one sized block. + const lines = body.split("\n").map((line) => line.trim()).filter(Boolean); + const bulletish = lines.length > 1 && lines.filter((line) => /^([-*•]|\d+[.)])\s+/.test(line)).length >= Math.ceil(lines.length / 2); + const top = title ? 200 : margin; + const available = area.height - top - 70; + if (bulletish) { + let fontSize = 30; + const totalRows = (size: number): number => lines.reduce((rows, line) => rows + wrapCount(line.replace(/^([-*•]|\d+[.)])\s+/, ""), size), 0); + while (fontSize > 16 && totalRows(fontSize) * fontSize * 1.45 > available) fontSize -= 2; + let y = top; + for (const line of lines) { + const isBullet = /^([-*•]|\d+[.)])\s+/.test(line); + const label = line.replace(/^[-*•]\s+/, ""); + if (isBullet && !/^\d+[.)]/.test(line)) elements.push(bar(margin + 4, y + fontSize * 0.42, fontSize * 0.42, fontSize * 0.42, theme.accent)); + const x = isBullet ? margin + fontSize * 1.3 : margin; + const element = textElement(label, x, y, contentWidth - (x - margin), fontSize, theme.muted, "left"); + elements.push(element); + y += Number(element.height) + fontSize * 0.45; + } + } else { + let fontSize = 30; + while (fontSize > 16 && wrapCount(body, fontSize) * fontSize * 1.3 > available) fontSize -= 2; + elements.push(textElement(body, margin, top, contentWidth, fontSize, theme.muted, "left")); + } + } + elements.push(textElement(String(index + 1), area.width - 70, area.height - 56, 40, 18, theme.muted, "right")); + } + return { + id: crypto.randomUUID(), name: title || `Slide ${index + 1}`, + scene: { elements: elements as never[], appState: { viewBackgroundColor: theme.background }, files: {} }, + }; + }); + return { type: "1helm-slides", version: 3, printableArea: area, slides }; +} + async function presentationPdf(deck: Deck, filename: string): Promise { const pdf = await PDFDocument.create(); const { width, height } = deck.printableArea; @@ -308,12 +389,71 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: typeof left === "string" ? h("span", { class: "min-w-0 flex-1 truncate font-mono text-[11px] text-muted", title: left }, left) : left, ...tools, presence(session), h("button", { class: "btn-primary text-xs", type: "button", onclick: () => { void saveFile(); } }, "Save")); + /** Resolve same-project relative CSS/JS so a static page previews like a site. */ + const inlineHtmlAssets = async (html: string, htmlPath: string): Promise => { + const baseDir = htmlPath.split("/").slice(0, -1).join("/"); + const resolve = (ref: string): string | null => { + if (/^(?:[a-z]+:)?\/\//i.test(ref) || ref.startsWith("data:") || ref.startsWith("#")) return null; + const parts = (ref.startsWith("/") ? ref.slice(1) : `${baseDir}/${ref}`).split("/"); + const out: string[] = []; + for (const part of parts) { + if (!part || part === ".") continue; + if (part === "..") { out.pop(); continue; } + out.push(part); + } + return out.join("/"); + }; + const fetchText = async (path: string): Promise => { + try { return (await api<{ file: EditableFile }>(`/api/channels/${channelId}/files/text?path=${encodeURIComponent(path)}`)).file.content; } + catch { return null; } + }; + let output = html; + const linkTags = [...output.matchAll(/]*?href=["']([^"']+)["'][^>]*>/gi)] + .filter((match) => /rel=["']?stylesheet/i.test(match[0])); + for (const match of linkTags) { + const target = resolve(match[1]); + if (!target) continue; + const css = await fetchText(target); + if (css != null) output = output.replace(match[0], ``); + } + const scriptTags = [...output.matchAll(/]*?src=["']([^"']+)["'][^>]*>\s*<\/script>/gi)]; + for (const match of scriptTags) { + const target = resolve(match[1]); + if (!target) continue; + const js = await fetchText(target); + if (js != null) output = output.replace(match[0], `", "<\\/script>")}\n`); + } + return output; + }; + const textEditor = (session: SectionSession, mode: "notes" | "code" | "docs"): HTMLElement => { const mounted = mountCodeMirror(session.collaboration!, session.path, mode, (content) => markChanged(session, content), () => { void saveFile(); }); session.mounted = mounted; const preview = h("div", { class: "md cowork-markdown-preview hidden" }); const editStage = h("div", { class: mode === "docs" ? "cowork-doc-page" : "cowork-notes-editor-frame min-h-0 flex-1 overflow-hidden" }, mounted.node); const format = (label: string, prefix: string, suffix = prefix, placeholder = "text") => h("button", { class: "btn-ghost text-xs", type: "button", title: label, onclick: () => mounted.format?.(prefix, suffix, placeholder) }, label); + const htmlFile = mode === "code" && /\.html?$/i.test(session.path); + const htmlStage = htmlFile ? h("div", { class: "hidden min-h-0 flex-1 overflow-hidden bg-white" }) : null; + const htmlPreviewButton = htmlFile ? h("button", { class: "btn-subtle text-xs", type: "button", onclick: () => { + void (async () => { + session.preview = !session.preview; + const button = htmlPreviewButton as HTMLButtonElement; + if (session.preview) { + button.disabled = true; button.textContent = "Loading…"; + const raw = session.mounted?.getContent?.() || session.content || ""; + let rendered = raw; + try { rendered = await inlineHtmlAssets(raw, session.path); } catch { /* preview raw on failure */ } + clear(htmlStage!); + const frame = h("iframe", { class: "h-full w-full border-0", sandbox: "allow-scripts", title: `Preview of ${visibleName(session.path)}` }) as HTMLIFrameElement; + frame.srcdoc = rendered; + htmlStage!.append(frame); + button.disabled = false; + } + htmlStage!.classList.toggle("hidden", !session.preview); + editStage.classList.toggle("hidden", session.preview); + button.textContent = session.preview ? "Return to code" : "Preview"; + })(); + } }, "Preview") : null; const previewButton = mode !== "code" ? h("button", { class: "btn-subtle text-xs", type: "button", onclick: () => { session.preview = !session.preview; preview.classList.toggle("hidden", !session.preview); editStage.classList.toggle("hidden", session.preview); preview.innerHTML = md(session.mounted?.getContent?.() || session.content || "_This file is empty._"); @@ -331,10 +471,10 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: mode !== "code" ? format("Bold", "**", "**") : null, mode !== "code" ? format("Italic", "_", "_") : null, mode === "docs" ? format("List", "- ", "", "List item") : null, - dictation, previewButton); + dictation, htmlPreviewButton, previewButton); const stage = mode === "notes" ? h("div", { class: "cowork-notes-edit-stage flex min-h-0 flex-1 flex-col overflow-hidden" }, editStage, preview) - : h("div", { class: `cowork-text-stage flex min-h-0 flex-1 flex-col ${mode === "code" ? "overflow-hidden" : "overflow-auto"}` }, editStage, preview); + : h("div", { class: `cowork-text-stage flex min-h-0 flex-1 flex-col ${mode === "code" ? "overflow-hidden" : "overflow-auto"}` }, editStage, ...(htmlStage ? [htmlStage] : []), preview); return h("div", { class: `flex min-h-0 flex-1 flex-col ${mode === "docs" ? "cowork-doc-canvas" : ""}` }, toolbar, stage); }; @@ -385,6 +525,18 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: mounted.destroy = () => { session.collaboration?.scene.unobserve(remoteStructure); destroyMounted(); }; session.mounted = mounted; const slides = h("aside", { class: "cowork-slide-strip", "aria-label": "Slides" }); + // Slide clicks rebuild this whole editor; restore the strip's scroll + // position afterwards (and keep the active thumb visible) instead of + // snapping back to the top on every selection. + slides.addEventListener("scroll", () => { session.slideStripScroll = slides.scrollTop; }); + requestAnimationFrame(() => { + if (typeof session.slideStripScroll === "number") slides.scrollTop = session.slideStripScroll; + const active = slides.querySelector(".cowork-slide-thumb.is-active"); + if (active) { + const over = active.offsetTop < slides.scrollTop || active.offsetTop + active.offsetHeight > slides.scrollTop + slides.clientHeight; + if (over) active.scrollIntoView({ block: "nearest" }); + } + }); deck.slides.forEach((slide, index) => { const open = h("button", { class: "min-w-0 flex-1 text-left", type: "button", onclick: () => { if (session.activeSlide === index) return; session.activeSlide = index; reopen(); } }, h("span", { class: "text-[10px] text-faint" }, String(index + 1)), @@ -519,9 +671,10 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: onclick: () => { selectedEntry = file; drawFileActions(); // Opening a file already redraws the rail and workspace together. - // A second concurrent loadFiles() raced that draw, repeatedly - // replacing the clicked node and sometimes leaving the editor unopened. - if (file.kind === "file") void openPath(file.path); else void loadFiles(); + // Folders must also navigate on single click: rebuilding the list + // here replaced the clicked row before a double-click could land, + // which made directories look unopenable. + if (file.kind === "file") void openPath(file.path); else openFolder(file.path); }, }, h("span", { class: file.kind === "directory" ? "text-muted" : "text-accent" }, fileIcon(file)), @@ -533,15 +686,61 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: } catch (error) { fileList.replaceChildren(h("p", { class: "p-3 text-xs text-danger" }, (error as Error).message)); } }; + let agentWasWorking = false; + let historyOpen = false; + + /** Past Cowork sessions for this file/folder: root messages stamped with the + * working-context marker at send time. Client-side filter over the channel's + * recent roots — no separate index required. */ + const loadContextHistory = async (context: string, folder: boolean): Promise => { + const marker = `Working ${folder ? "folder" : "file"}: /workspace/${context}`; + try { + const result = await api<{ messages: Message[] }>(`/api/channels/${channelId}/messages`); + return (result.messages || []).filter((message) => String(message.body || "").includes(marker)).reverse(); + } catch { return []; } + }; + const renderChatMessages = async (): Promise => { const stream = agentPanel.querySelector("[data-cowork-chat-stream]"); if (!stream || !chatRootId) return; const stick = stream.scrollHeight - stream.scrollTop - stream.clientHeight < 60; const top = stream.scrollTop; try { const result = await api<{ root: Message; replies: Message[] }>(`/api/messages/${chatRootId}/thread`); - const signature = [result.root, ...result.replies].map((message) => `${message.id}:${message.body}`).join("|"); + const rows = [result.root, ...result.replies]; + const signature = rows.map((message) => `${message.id}:${message.body}:${(message.progress || []).map((step) => `${step.id}${step.status}`).join(",")}`).join("|"); if (stream.dataset.signature === signature) return; stream.dataset.signature = signature; clear(stream); - for (const message of [result.root, ...result.replies]) stream.append(h("article", { class: `cowork-chat-message ${message.author.kind === "user" ? "is-user" : ""}` }, h("div", { class: "mb-1 text-[10px] font-semibold text-muted" }, message.author.kind === "user" ? "You" : message.author.name), h("div", { class: "md text-sm text-fg", html: md(message.body) }))); + // One live status at a time: the latest progress step replaces the + // previous. The full chain of thought stays in the main channel thread. + let currentStep = ""; + let anyWorking = false; + for (const message of rows) { + const working = message.body === "_Working…_" || Boolean(message.progress?.some((step) => step.status === "running")); + if (working) { + anyWorking = true; + const steps = message.progress || []; + const live = [...steps].reverse().find((step) => step.status === "running") || steps.at(-1); + if (live?.body) currentStep = live.body; + } + const body = message.body === "_Working…_" ? "" : message.body; + if (!body && message.author.kind !== "user") continue; + stream.append(h("article", { class: `cowork-chat-message ${message.author.kind === "user" ? "is-user" : ""}` }, + h("div", { class: "mb-1 text-[10px] font-semibold text-muted" }, message.author.kind === "user" ? "You" : message.author.name), + h("div", { class: "md text-sm text-fg", html: md(body) }))); + } + if (anyWorking) { + stream.append(h("div", { class: "flex items-start gap-2 px-1 py-1.5", dataset: { coworkWorking: "" } }, + h("span", { class: "cowork-orb mt-0.5", "aria-hidden": "true" }), + h("div", { class: "min-w-0 flex-1" }, + h("div", { class: "text-[11px] font-medium text-fg" }, `@${channel.agent?.name || "agent"} is working…`), + currentStep ? h("div", { class: "cowork-live-step mt-0.5 truncate text-[10px] text-muted", title: currentStep }, currentStep) : null))); + } + // Agent turns create/rename files in this workspace. Refresh the rail + // while a turn runs and once more when it settles so new artifacts + // appear without switching sections or reloading the page. + if (anyWorking !== agentWasWorking) { + agentWasWorking = anyWorking; + void loadFiles(); + } else if (anyWorking) void loadFiles(); requestAnimationFrame(() => { stream.scrollTop = stick ? stream.scrollHeight : top; }); } catch { /* a deleted/archived thread stays quiet until the user starts another */ } }; @@ -551,7 +750,10 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: if (!agentOpen) { if (chatTimer != null) window.clearInterval(chatTimer); chatTimer = null; return; } const session = activeSession(); const context = contextPath(session); chatRootId = Number(localStorage.getItem(threadKey(context)) || 0); const stream = h("div", { class: "min-h-0 flex-1 space-y-3 overflow-y-auto p-3", dataset: { coworkChatStream: "" } }); - const input = h("textarea", { class: "min-h-20 w-full resize-none bg-transparent p-2 text-sm text-fg outline-none placeholder:text-faint", rows: 3, placeholder: `Ask @${channel.agent?.name || "agent"} about this ${session.path ? "file" : "folder"}…`, value: agentDrafts.get(context) || "" }) as HTMLTextAreaElement; + // The rounded wrapper below owns focus indication (focus-within border). + // Without the explicit focus-visible suppression, the global a11y outline + // draws a square rectangle around this inner textarea and clashes with it. + const input = h("textarea", { class: "min-h-20 w-full resize-none bg-transparent p-2 text-sm text-fg outline-none focus-visible:outline-none placeholder:text-faint", rows: 3, placeholder: `Ask @${channel.agent?.name || "agent"} about this ${session.path ? "file" : "folder"}…`, value: agentDrafts.get(context) || "" }) as HTMLTextAreaElement; const dictate = mountSpeechToTextControl(input, "Dictate Cowork agent request"); input.oninput = () => agentDrafts.set(context, input.value); input.onfocus = () => setFocusedSpeechTarget(input, dictate); @@ -567,9 +769,51 @@ export function renderCowork(container: HTMLElement, channelId: number, channel: finally { input.disabled = false; input.focus(); } }; input.onkeydown = (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); void send(); } }; + const historyPane = h("div", { class: "hidden min-h-0 flex-1 overflow-y-auto p-3", dataset: { coworkHistory: "" } }); + const historyButton = h("button", { + class: "grid h-8 w-8 place-items-center rounded text-muted hover:bg-hover", + title: "Past sessions with this file", "aria-label": "Past sessions with this file", "aria-pressed": "false", + onclick: async () => { + historyOpen = !historyOpen; + (historyButton as HTMLButtonElement).setAttribute("aria-pressed", String(historyOpen)); + historyButton.classList.toggle("text-accent", historyOpen); + historyPane.classList.toggle("hidden", !historyOpen); + stream.classList.toggle("hidden", historyOpen); + if (!historyOpen) return; + historyPane.replaceChildren(h("p", { class: "py-6 text-center text-xs text-muted" }, "Loading past sessions…")); + const roots = await loadContextHistory(context, !session.path); + if (!roots.length) { + historyPane.replaceChildren(h("p", { class: "py-6 text-center text-xs leading-5 text-muted" }, `No past sessions with this ${session.path ? "file" : "folder"} yet.`)); + return; + } + historyPane.replaceChildren( + h("p", { class: "mb-2 px-1 text-[10px] font-semibold uppercase tracking-wide text-faint" }, `Past sessions · ${roots.length}`), + ...roots.map((root) => { + const preview = String(root.body || "").split("\n")[0].replace(/^@\S+\s*/, "").slice(0, 120) || "(untitled session)"; + const active = root.id === chatRootId; + return h("button", { + class: `mb-1 block w-full rounded-md border px-2.5 py-2 text-left hover:bg-hover ${active ? "border-accent/50 bg-accent-soft" : "border-line"}`, + type: "button", + onclick: () => { + chatRootId = root.id; + localStorage.setItem(threadKey(context), String(chatRootId)); + coworkContextPending = false; + historyOpen = false; + drawAgent(); + }, + }, + h("div", { class: "truncate text-xs text-fg" }, preview), + h("div", { class: "mt-0.5 flex items-center gap-2 text-[10px] text-faint" }, + h("span", {}, new Date(root.created).toLocaleString()), + root.reply_count ? h("span", {}, `${root.reply_count} repl${root.reply_count === 1 ? "y" : "ies"}`) : null, + active ? h("span", { class: "text-accent" }, "current") : null)); + })); + }, + }, icon("history", 15)); clear(agentPanel); - agentPanel.append(h("header", { class: "flex min-h-14 items-center gap-2 border-b border-line px-3" }, agentAvatar(), h("div", { class: "min-w-0 flex-1" }, h("div", { class: "truncate text-sm font-semibold text-fg" }, channel.agent?.display_name || channel.agent?.name || "Channel agent"), h("div", { class: "truncate text-[10px] text-muted" }, `/workspace/${context}`)), chatRootId ? h("button", { class: "btn-ghost text-xs", onclick: async () => { try { const result = await api<{ root: Message }>(`/api/messages/${chatRootId}/thread`); openThreadCallback(result.root); } catch (error) { void appAlert((error as Error).message); } } }, "Open in Chat") : null, h("button", { class: "grid h-8 w-8 place-items-center rounded text-muted hover:bg-hover", "aria-label": "Close agent panel", onclick: () => { agentOpen = false; drawAgent(); } }, icon("x", 15))), stream, + agentPanel.append(h("header", { class: "flex min-h-14 items-center gap-2 border-b border-line px-3" }, agentAvatar(), h("div", { class: "min-w-0 flex-1" }, h("div", { class: "truncate text-sm font-semibold text-fg" }, channel.agent?.display_name || channel.agent?.name || "Channel agent"), h("div", { class: "truncate text-[10px] text-muted" }, `/workspace/${context}`)), historyButton, chatRootId ? h("button", { class: "btn-ghost text-xs", onclick: async () => { try { const result = await api<{ root: Message }>(`/api/messages/${chatRootId}/thread`); openThreadCallback(result.root); } catch (error) { void appAlert((error as Error).message); } } }, "Open in Chat") : null, h("button", { class: "grid h-8 w-8 place-items-center rounded text-muted hover:bg-hover", "aria-label": "Close agent panel", onclick: () => { agentOpen = false; drawAgent(); } }, icon("x", 15))), stream, historyPane, h("div", { class: "border-t border-line p-2" }, chatRootId ? h("button", { class: "btn-ghost mb-1 text-[11px]", onclick: () => { chatRootId = 0; coworkContextPending = true; localStorage.removeItem(threadKey(context)); drawAgent(); } }, "New session") : h("p", { class: "px-2 pb-1 text-[11px] leading-4 text-muted" }, `Your first message starts a normal channel session with this ${session.path ? "file and its current collaborators" : "folder"}.`), h("div", { class: "rounded-lg border border-line bg-raised/40 focus-within:border-accent" }, input, h("div", { class: "flex justify-end gap-1 p-1.5" }, dictate, h("button", { class: "btn-primary text-xs", onclick: () => { void send(); } }, icon("send", 13), "Send"))))); + historyOpen = false; void renderChatMessages(); if (chatTimer != null) window.clearInterval(chatTimer); chatTimer = window.setInterval(() => { if (!shell.isConnected || !agentOpen) { if (chatTimer != null) window.clearInterval(chatTimer); chatTimer = null; return; } void renderChatMessages(); }, 1600); if (focusAgentOnDraw) requestAnimationFrame(() => input.focus({ preventScroll: true })); focusAgentOnDraw = false; diff --git a/src/client/dom.ts b/src/client/dom.ts index 5d31381..f276919 100644 --- a/src/client/dom.ts +++ b/src/client/dom.ts @@ -198,6 +198,7 @@ const ICONS: Record = { api: '', hash: '', more: '', + history: '', }; export function icon(name: keyof typeof ICONS | string, size = 16): SVGElement { const s = document.createElementNS("http://www.w3.org/2000/svg", "svg"); diff --git a/src/client/mobile.ts b/src/client/mobile.ts index 797b6e4..d80ff21 100644 --- a/src/client/mobile.ts +++ b/src/client/mobile.ts @@ -52,20 +52,53 @@ export function installMobileViewportBehavior(): void { document.documentElement.classList.toggle("keyboard-visible", keyboard > 120); window.dispatchEvent(new CustomEvent("1helm:viewport", { detail: { height, keyboard } })); }; - window.visualViewport?.addEventListener("resize", update, { passive: true }); + // Opening the keyboard resizes the visual viewport, which reflows tall + // conversations and fires scroll events that are NOT user gestures. Blurring + // on those made the keyboard close itself immediately after opening. Only a + // deliberate upward finger drag may dismiss composition, and never during + // the settle window right after focus/keyboard-resize. + let settleUntil = 0; + const markSettle = (): void => { settleUntil = Date.now() + 700; }; + window.visualViewport?.addEventListener("resize", () => { markSettle(); update(); }, { passive: true }); window.visualViewport?.addEventListener("scroll", update, { passive: true }); - window.addEventListener("resize", update, { passive: true }); + window.addEventListener("resize", () => { markSettle(); update(); }, { passive: true }); + document.addEventListener("focusin", (event) => { + if ((event.target as HTMLElement | null)?.closest?.(".composer-wrap")) markSettle(); + }, true); update(); + let touchActive = false; + let touchStartY = 0; + let touchDraggedUp = false; + document.addEventListener("touchstart", (event) => { + touchActive = true; + touchDraggedUp = false; + touchStartY = event.touches[0]?.clientY ?? 0; + }, { passive: true, capture: true }); + document.addEventListener("touchmove", (event) => { + // Finger moving down the screen = content scrolling up (toward history). + const y = event.touches[0]?.clientY ?? 0; + if (y - touchStartY > 12) touchDraggedUp = true; + }, { passive: true, capture: true }); + document.addEventListener("touchend", () => { + // Momentum scrolling continues after touchend; keep the gesture flag + // briefly so an intentional fling still dismisses, then reset. + setTimeout(() => { touchActive = false; touchDraggedUp = false; }, 400); + }, { passive: true, capture: true }); const lastTop = new WeakMap(); document.addEventListener("scroll", (event) => { if (!matchMedia("(max-width: 767px)").matches) return; const target = event.target as HTMLElement | null; if (!target?.matches?.("#msgs,#threadmsgs,#channelview,[data-cowork-chat-stream]")) return; - const previous = lastTop.get(target) ?? target.scrollHeight; - const awayFromComposer = target.scrollTop < previous - 2 || target.scrollHeight - target.scrollTop - target.clientHeight > 48; + const previous = lastTop.get(target) ?? target.scrollTop; + const scrolledUp = target.scrollTop < previous - 2; lastTop.set(target, target.scrollTop); + // Layout/keyboard-induced scrolls and programmatic stick-to-bottom must + // never steal the keyboard: require a real upward touch drag, outside the + // focus/resize settle window. + if (Date.now() < settleUntil) return; + if (!touchActive || !touchDraggedUp || !scrolledUp) return; const active = document.activeElement as HTMLElement | null; - if (!awayFromComposer || !active?.closest?.(".composer-wrap")) return; + if (!active?.closest?.(".composer-wrap")) return; active.blur(); if (native) void Keyboard.hide().catch(() => undefined); }, true); diff --git a/src/client/onboarding.ts b/src/client/onboarding.ts index e0558bb..f8aafa2 100644 --- a/src/client/onboarding.ts +++ b/src/client/onboarding.ts @@ -1,4 +1,4 @@ -import { api, getToken, setToken, type ChannelRuntime, type RoutingState } from "./api.ts"; +import { api, getToken, setToken, type ChannelComputerPrepare, type ChannelRuntime, type RoutingState, type WindowsWslSetup } from "./api.ts"; import { clear, h } from "./dom.ts"; import { onboardingProviderPicker } from "./routing.ts"; @@ -137,6 +137,102 @@ export function openOnboarding(root: HTMLElement, opts: WizardOptions): void { } }; + const paintPrepareProgress = (prepare: ChannelComputerPrepare): void => { + const width = Math.max(4, Math.min(100, Number(prepare.progress) || 0)); + status.replaceChildren(h("div", { class: "card border-accent/30 p-4" }, + h("div", { class: "font-semibold text-fg" }, "Preparing private computers"), + h("p", { class: "mt-2 text-sm leading-6 text-muted" }, "One-time setup for this host. 1Helm loads the sealed Linux computer image now so every later channel starts quickly."), + h("div", { class: "wizard-progress mt-4" }, h("span", { style: `width:${width}%` })), + h("p", { class: "mt-3 text-sm leading-6 text-fg" }, prepare.step || "Working…"), + prepare.error ? h("p", { class: "mt-2 text-sm text-danger" }, prepare.error) : null, + h("p", { class: "mt-2 text-xs text-muted" }, "This is local image import, not a live package download. Channel creation will not pay this cost again."))); + }; + + const paintWindowsSetupProgress = (setup: WindowsWslSetup, opts?: { retry?: () => void }): void => { + const width = Math.max(4, Math.min(100, Number(setup.progress) || 0)); + const failed = setup.status === "failed"; + const restart = setup.status === "restart_required"; + const children: Array = [ + h("div", { class: "font-semibold text-fg" }, restart ? "Windows restart required" : failed ? "Shared runtime setup failed" : "Setting up shared Windows runtime"), + h("p", { class: "mt-2 text-sm leading-6 text-muted" }, + restart + ? "Windows enabled WSL 2 components that need a reboot. Restart the PC, reopen 1Helm, then create the workspace again." + : "One-time administrator setup. Keep the PowerShell window open until it finishes; 1Helm tracks progress here."), + h("div", { class: "wizard-progress mt-4" }, h("span", { style: `width:${width}%` })), + h("p", { class: "mt-3 text-sm leading-6 text-fg" }, setup.step || "Working…"), + setup.error ? h("p", { class: "mt-2 text-sm text-danger" }, setup.error) : null, + h("p", { class: "mt-2 text-xs text-muted" }, failed || restart + ? "After a successful setup, 1Helm will prepare the sealed channel image automatically." + : "This downloads Microsoft's pinned WSL package and the shared Linux runtime, then installs Podman inside it."), + ]; + if ((failed || restart) && opts?.retry) { + children.push(h("button", { + class: "btn-primary mt-3 w-full py-2 sm:w-auto", + onclick: () => { opts.retry?.(); }, + }, restart ? "I restarted — retry setup" : "Retry shared runtime setup")); + } + status.replaceChildren(h("div", { class: `card p-4 ${failed || restart ? "border-danger/40" : "border-accent/30"}` }, ...children)); + }; + + const runWindowsRuntimeSetup = async (button: HTMLButtonElement): Promise => { + setBusy(button, true, "Setting up shared runtime…"); + status.textContent = ""; + try { + const started = await api<{ installer?: { opened?: boolean; setup?: WindowsWslSetup }; runtime: ChannelRuntime }>("/api/channel-computers/runtime/install", { body: {} }); + const initial = started.installer?.setup || started.runtime.windows_setup; + if (initial) paintWindowsSetupProgress(initial, { retry: () => { void runWindowsRuntimeSetup(button); } }); + const deadline = Date.now() + 60 * 60_000; + while (Date.now() < deadline) { + const snapshot = await api<{ runtime: ChannelRuntime }>("/api/channel-computers/runtime"); + const setup = snapshot.runtime.windows_setup; + if (snapshot.runtime.engine_ready) { + status.replaceChildren(h("div", { class: "wizard-status-ok" }, "Shared Windows runtime is ready. Preparing private computers…")); + await prepareOciComputers(button); + return; + } + if (setup) { + paintWindowsSetupProgress(setup, { retry: () => { void runWindowsRuntimeSetup(button); } }); + if (setup.status === "failed" || setup.status === "restart_required") { + setBusy(button, false); + return; + } + } + await new Promise((resolveWait) => setTimeout(resolveWait, 1_500)); + } + throw new Error("Shared Windows runtime setup timed out. Check the PowerShell window for errors, then retry."); + } catch (error) { + status.replaceChildren(h("div", { class: "wizard-status-err" }, (error as Error).message)); + setBusy(button, false); + } + }; + + const prepareOciComputers = async (button: HTMLButtonElement): Promise => { + setBusy(button, true, "Preparing private computers…"); + status.textContent = ""; + try { + const started = await api<{ prepare: ChannelComputerPrepare; runtime: ChannelRuntime }>("/api/channel-computers/runtime/prepare", { body: {} }); + paintPrepareProgress(started.prepare); + const deadline = Date.now() + 35 * 60_000; + while (Date.now() < deadline) { + const snapshot = await api<{ prepare: ChannelComputerPrepare; runtime: ChannelRuntime }>("/api/channel-computers/runtime/prepare"); + paintPrepareProgress(snapshot.prepare); + if (snapshot.prepare.status === "complete" || snapshot.runtime.ready) { + clear(runtimeMount); + await completeWorkspace(button); + return; + } + if (snapshot.prepare.status === "failed") { + throw new Error(snapshot.prepare.error || snapshot.runtime.error || "Private computer setup failed."); + } + await new Promise((resolveWait) => setTimeout(resolveWait, 1_500)); + } + throw new Error("Private computer setup timed out. Check host DNS and package mirrors, then retry."); + } catch (error) { + status.replaceChildren(h("div", { class: "wizard-status-err" }, (error as Error).message)); + setBusy(button, false); + } + }; + const showRuntimeApproval = (runtime: ChannelRuntime): void => { clear(runtimeMount); const runtimeStatus = h("div"); @@ -172,30 +268,71 @@ export function openOnboarding(root: HTMLElement, opts: WizardOptions): void { status.textContent = ""; try { const { runtime } = await api<{ runtime: ChannelRuntime }>("/api/channel-computers/runtime"); + if (runtime.backend === "oci") { + if (!runtime.supported) { + setBusy(button, false); + status.replaceChildren(h("div", { class: "wizard-status-err" }, "Private channel computers require a supported Linux or Windows x64 host.")); + return; + } + if (!runtime.engine_ready) { + const windows = Boolean(runtime.shared_runtime); + if (windows) { + const setup = runtime.windows_setup; + if (setup?.status === "running") { + paintWindowsSetupProgress(setup, { retry: () => { void runWindowsRuntimeSetup(button); } }); + // Reattach to an in-flight setup instead of launching a second installer. + void (async () => { + setBusy(button, true, "Setting up shared runtime…"); + const deadline = Date.now() + 60 * 60_000; + while (Date.now() < deadline) { + const snapshot = await api<{ runtime: ChannelRuntime }>("/api/channel-computers/runtime"); + if (snapshot.runtime.engine_ready) { + status.replaceChildren(h("div", { class: "wizard-status-ok" }, "Shared Windows runtime is ready. Preparing private computers…")); + await prepareOciComputers(button); + return; + } + const current = snapshot.runtime.windows_setup; + if (current) { + paintWindowsSetupProgress(current, { retry: () => { void runWindowsRuntimeSetup(button); } }); + if (current.status === "failed" || current.status === "restart_required") { + setBusy(button, false); + return; + } + } + await new Promise((resolveWait) => setTimeout(resolveWait, 1_500)); + } + status.replaceChildren(h("div", { class: "wizard-status-err" }, "Shared Windows runtime setup timed out. Retry setup.")); + setBusy(button, false); + })(); + return; + } + if (setup?.status === "failed" || setup?.status === "restart_required") { + setBusy(button, false); + paintWindowsSetupProgress(setup, { retry: () => { void runWindowsRuntimeSetup(button); } }); + return; + } + await runWindowsRuntimeSetup(button); + return; + } + setBusy(button, false); + const instruction = "The OCI runtime is not ready. Rerun the verified 1Helm Linux host installer, then retry."; + status.replaceChildren(h("div", { class: "wizard-status-err" }, runtime.error ? `${instruction} ${runtime.error}` : instruction)); + return; + } + if (!runtime.image_ready || !runtime.ready) { + await prepareOciComputers(button); + return; + } + await completeWorkspace(button); + return; + } if (!runtime.ready) { setBusy(button, false); if (runtime.backend === "apple") { showRuntimeApproval(runtime); runtimeMount.scrollIntoView({ behavior: "smooth", block: "nearest" }); } else { - const windows = Boolean(runtime.shared_runtime); - const instruction = windows - ? "The shared Windows OCI runtime is not ready. Complete 1Helm's one-time administrator setup, then retry." - : runtime.backend === "oci" - ? "The OCI runtime is not ready. Rerun the verified 1Helm Linux host installer, then retry." - : "The development channel-computer backend is not ready."; - const message = h("div", { class: "wizard-status-err" }, runtime.error ? `${instruction} ${runtime.error}` : instruction); - if (windows) { - const setupWsl = h("button", { class: "btn-primary mt-3 w-full py-2 sm:w-auto", onclick: async () => { - setBusy(setupWsl as HTMLButtonElement, true, "Opening Windows setup…"); - try { - await api("/api/channel-computers/runtime/install", { body: {} }); - status.replaceChildren(h("div", { class: "wizard-status-warn" }, "Finish the Windows prompt. Restart once if requested, then reopen 1Helm.")); - } catch (error) { status.replaceChildren(h("div", { class: "wizard-status-err" }, (error as Error).message)); } - finally { setBusy(setupWsl as HTMLButtonElement, false); } - } }, "Set up shared runtime"); - status.replaceChildren(message, setupWsl); - } else status.replaceChildren(message); + status.replaceChildren(h("div", { class: "wizard-status-err" }, "The development channel-computer backend is not ready.")); } return; } diff --git a/src/client/settings.ts b/src/client/settings.ts index 491d69c..bb22369 100644 --- a/src/client/settings.ts +++ b/src/client/settings.ts @@ -670,23 +670,69 @@ function computersPanel(): HTMLElement { if (runtime.backend === "oci") { const windows = Boolean(runtime.shared_runtime); const label = windows ? "Shared Windows OCI runtime" : "Native OCI runtime"; + const engineReady = Boolean(runtime.engine_ready); + const imageReady = Boolean(runtime.image_ready); + const fullyReady = Boolean(runtime.ready); const readyCopy = windows - ? "The installation-scoped WSL runtime is healthy. Skipper manages one persistent OCI container per ordinary channel." - : "The root-owned OCI runtime is healthy. Skipper manages one persistent OCI container per ordinary channel."; - const setupCopy = windows - ? "Complete 1Helm's one-time Windows administrator setup for the shared OCI runtime." - : "Rerun the verified 1Helm Linux host installer to repair Podman, cgroups, or the root-owned helper."; + ? "The installation-scoped WSL runtime and shared channel image are ready. Skipper manages one persistent OCI container per ordinary channel." + : "The root-owned OCI runtime and shared channel image are ready. Skipper manages one persistent OCI container per ordinary channel."; + const setupCopy = !engineReady + ? (windows + ? "Complete 1Helm's one-time Windows administrator setup for the shared OCI runtime." + : "Rerun the verified 1Helm Linux host installer to repair Podman, cgroups, or the root-owned helper.") + : "Load the sealed channel computer image once. Later channels start from that local image instead of touching live package mirrors."; const actionStatus = h("p", { class: "mt-2 text-sm text-muted" }); - const windowsSetup = windows && !runtime.ready ? h("button", { class: "btn-primary mt-3 text-sm", onclick: async () => { - actionStatus.textContent = "Opening Windows' shared runtime administrator setup…"; - try { await api("/api/channel-computers/runtime/install", { body: {} }); actionStatus.textContent = "Finish the Windows prompt. Restart once if Windows requests it, then reopen 1Helm."; } - catch (error) { actionStatus.textContent = (error as Error).message; } + const chip = fullyReady ? `${label} ready` : !engineReady ? "Setup required" : "Image setup"; + const windowsSetup = windows && !engineReady ? h("button", { class: "btn-primary mt-3 text-sm", onclick: async () => { + actionStatus.textContent = "Starting shared Windows runtime setup… Approve the administrator prompt when Windows asks."; + try { + await api("/api/channel-computers/runtime/install", { body: {} }); + const deadline = Date.now() + 60 * 60_000; + while (Date.now() < deadline) { + const snapshot = await api<{ runtime: ChannelRuntime }>("/api/channel-computers/runtime"); + const setup = snapshot.runtime.windows_setup; + if (snapshot.runtime.engine_ready) { + paintRuntime(snapshot.runtime); + return; + } + if (setup?.status === "restart_required") { + actionStatus.textContent = setup.step || "Restart Windows once, reopen 1Helm, then retry setup."; + return; + } + if (setup?.status === "failed") { + actionStatus.textContent = setup.error || setup.step || "Shared Windows runtime setup failed."; + return; + } + actionStatus.textContent = setup?.step + ? `${setup.step} (${Math.max(0, Math.min(100, Number(setup.progress) || 0))}%)` + : "Shared Windows runtime setup is running…"; + await new Promise((resolveWait) => setTimeout(resolveWait, 1_500)); + } + actionStatus.textContent = "Shared Windows runtime setup timed out. Retry setup."; + } catch (error) { actionStatus.textContent = (error as Error).message; } } }, "Set up shared runtime") : null; + const prepareImage = engineReady && !imageReady ? h("button", { class: "btn-primary mt-3 text-sm", onclick: async () => { + actionStatus.textContent = "Loading the sealed channel computer image…"; + try { + await api("/api/channel-computers/runtime/prepare", { body: {} }); + const deadline = Date.now() + 35 * 60_000; + while (Date.now() < deadline) { + const snapshot = await api<{ prepare: { status: string; step: string; progress: number; error: string }; runtime: ChannelRuntime }>("/api/channel-computers/runtime/prepare"); + actionStatus.textContent = `${snapshot.prepare.step} (${Math.max(0, Math.min(100, snapshot.prepare.progress))}%)`; + if (snapshot.prepare.status === "complete" || snapshot.runtime.ready) { paintRuntime(snapshot.runtime); return; } + if (snapshot.prepare.status === "failed") throw new Error(snapshot.prepare.error || "Image preparation failed."); + await new Promise((resolveWait) => setTimeout(resolveWait, 1_500)); + } + throw new Error("Image preparation timed out."); + } catch (error) { actionStatus.textContent = (error as Error).message; } + } }, "Load channel image") : null; runtimeBox.append( - h("div", { class: "flex flex-wrap items-center gap-2" }, h("h3", { class: "font-semibold text-fg" }, "Channel computers"), h("span", { class: "chip border-accent/25" }, runtime.ready ? `${label} ready` : "Setup required")), - h("p", { class: "mt-1 text-sm leading-6 text-muted" }, runtime.ready ? readyCopy : setupCopy), + h("div", { class: "flex flex-wrap items-center gap-2" }, h("h3", { class: "font-semibold text-fg" }, "Channel computers"), h("span", { class: "chip border-accent/25" }, chip)), + h("p", { class: "mt-1 text-sm leading-6 text-muted" }, fullyReady ? readyCopy : setupCopy), ...(runtime.error ? [h("p", { class: "mt-2 text-sm text-danger" }, runtime.error)] : []), - ...(windowsSetup ? [windowsSetup] : []), actionStatus, + ...(windowsSetup ? [windowsSetup] : []), + ...(prepareImage ? [prepareImage] : []), + actionStatus, ); return; } diff --git a/src/client/styles.css b/src/client/styles.css index b6bd1bd..099ada0 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -153,6 +153,20 @@ body { @apply w-full rounded-md border border-line bg-surface px-3 py-2 text-sm text-fg outline-none transition placeholder:text-faint; box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.04); } + +/* iOS Safari force-zooms the whole page when a focused form control's computed + * font-size is below 16px, throwing buttons off-screen. Enforce a 16px floor on + * every focusable text surface on small screens so focus never triggers zoom. + * Keep pinch-zoom available for accessibility instead of maximum-scale=1. */ +@media (max-width: 767px) { + input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), + select, + textarea, + [contenteditable="true"], + .cm-editor .cm-content { + font-size: 16px !important; + } +} .field:focus { border-color: color-mix(in srgb, var(--c-accent) 70%, var(--c-line)); box-shadow: 0 0 0 3px var(--c-accent-soft); @@ -948,7 +962,8 @@ html, body { height: 100%; width: 100%; overflow: hidden; overscroll-behavior: n .dark .cowork-codemirror-code { background: color-mix(in srgb, #071019 72%, var(--c-surface)); } .cowork-markdown-preview { min-height: 100%; overflow-y: auto; background: var(--c-surface); padding: 1.5rem 2rem; } .cowork-doc-canvas { background: color-mix(in srgb, var(--c-raised) 75%, var(--c-bg)); } -.cowork-doc-page { width: min(48rem, calc(100% - 2rem)); min-height: min(62rem, calc(100% - 2rem)); margin: 1rem auto; border: 1px solid var(--c-line); border-radius: .2rem; background: var(--c-surface); box-shadow: 0 20px 70px -45px #000; overflow: hidden; } +.cowork-doc-page { width: min(48rem, calc(100% - 2rem)); min-height: min(62rem, calc(100% - 2rem)); margin: 1rem auto; border: 1px solid var(--c-line); border-radius: .2rem; background: var(--c-surface); box-shadow: 0 20px 70px -45px #000; overflow: auto; } +.cowork-doc-page > .cm-editor { height: 100%; min-height: 62rem; } .cowork-doc-page .cm-content { padding: clamp(1.5rem, 6vw, 4rem) !important; font-family: var(--font-sans); font-size: 15px; line-height: 1.75; } .cowork-doc-page .cm-gutters { display: none; } .cowork-excalidraw { position: relative; min-width: 0; min-height: 0; flex: 1 1 auto; overflow: hidden; background: var(--c-surface); } @@ -976,6 +991,22 @@ html, body { height: 100%; width: 100%; overflow: hidden; overscroll-behavior: n .cowork-agent-toggle:hover { transform: translateY(-2px); box-shadow: 0 16px 34px -15px #000, 0 0 0 2px var(--c-accent-soft); } .cowork-chat-message { margin-right: 1.5rem; border: 1px solid var(--c-line); border-radius: .7rem; background: var(--c-raised); padding: .65rem .75rem; } .cowork-chat-message.is-user { margin-right: 0; margin-left: 1.5rem; border-color: color-mix(in srgb, var(--c-accent) 30%, var(--c-line)); background: var(--c-accent-soft); } + +/* The Cowork composer's rounded wrapper is the focus indicator + (focus-within border). Browsers apply :focus-visible to text fields even on + mouse click, which drew a square accent rectangle around the inner textarea + on top of the rounded border. Suppress every focus outline inside it. */ +.cowork-agent textarea:focus, .cowork-agent textarea:focus-visible { outline: none !important; box-shadow: none !important; } + +/* Cowork working orb: layered breathing sphere (thinking-orbs style), plus a + live pulse dot for in-flight progress steps. */ +.cowork-orb { position: relative; display: block; width: 16px; height: 16px; flex-shrink: 0; border-radius: 50%; background: radial-gradient(circle at 32% 30%, color-mix(in srgb, var(--c-accent) 85%, #fff), color-mix(in srgb, var(--c-accent) 55%, transparent) 58%, transparent 72%); animation: cowork-orb-breathe 1.6s ease-in-out infinite; } +.cowork-orb::before, .cowork-orb::after { content: ""; position: absolute; inset: 0; border-radius: inherit; border: 1px solid color-mix(in srgb, var(--c-accent) 60%, transparent); animation: cowork-orb-ring 1.6s ease-out infinite; } +.cowork-orb::after { animation-delay: -.8s; } +@keyframes cowork-orb-breathe { 0%, 100% { transform: scale(.86); opacity: .75; } 50% { transform: scale(1); opacity: 1; } } +@keyframes cowork-orb-ring { from { transform: scale(.7); opacity: .7; } to { transform: scale(1.9); opacity: 0; } } +.cowork-live-step { animation: cowork-step-swap .3s ease-out; } +@keyframes cowork-step-swap { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: none; } } .quick-note-panel { max-height: min(34rem, calc(var(--app-viewport-height, 100dvh) - 5rem)); } .quick-note-content { height: 14rem; max-height: 14rem; overflow-y: auto; } @@ -1190,7 +1221,7 @@ html, body { height: 100%; width: 100%; overflow: hidden; overscroll-behavior: n } @media (prefers-reduced-motion: reduce) { - .mobile-drawer, .listening-orb i, .agent-state-orb > span, .channel-working-dots > span { animation: none !important; } + .mobile-drawer, .listening-orb i, .agent-state-orb > span, .channel-working-dots > span, .cowork-orb, .cowork-orb::before, .cowork-orb::after, .cowork-live-step { animation: none !important; } } /* xterm */ diff --git a/src/server/agents.ts b/src/server/agents.ts index f450b99..70544f4 100644 --- a/src/server/agents.ts +++ b/src/server/agents.ts @@ -5,7 +5,24 @@ import { DATA_DIR, UPLOAD_DIR, now, q, q1, run, tx, type Row } from "./db.ts"; import { botView, resolveModel } from "./store.ts"; import { ensureAgentMemory, rememberForAgent } from "./memory.ts"; import { listSkills, provisionInitialSkills, provisionSkill, skillsForAgent, templateForSlug } from "./skills.ts"; -import { archiveChannelComputer, deleteChannelComputer, ensureChannelComputerRecord, markWorkspaceDirty, provisionChannelComputer, restoreChannelComputer } from "./channel-computers.ts"; +import { + archiveChannelComputer, + deleteChannelComputer, + ensureChannelComputerRecord, + ensureOciChannelWorkspaceDirs, + markWorkspaceDirty, + provisionChannelComputer, + restoreChannelComputer, + windowsOciStorageCopy, + windowsOciStorageList, + windowsOciStorageMkdir, + windowsOciStorageRead, + windowsOciStorageRename, + windowsOciStorageRequired, + windowsOciStorageRm, + windowsOciStorageStat, + windowsOciStorageWrite, +} from "./channel-computers.ts"; import { channelFilesPath, channelUsesRuntimeStorage, channelWorkspacePath, hostChannelRoot } from "./channel-storage.ts"; const WORLD_DIRS = ["workspace", "files", "state", "memory", "profile"]; @@ -49,8 +66,14 @@ export function ensureChannelWorkspace(channelId: number): string { const channel = q1("SELECT id,name,personal_main_owner_id FROM channels WHERE id=? AND status<>'deleted'", channelId); if (!channel) throw new Error("Channel workspace not found."); const root = channelRoot(channelId); + // Host data-tree metadata (profile/state/memory) stays under DATA_DIR even for OCI channels. for (const dir of WORLD_DIRS.filter((entry) => entry !== "workspace" && entry !== "files")) mkdirSync(join(root, dir), { recursive: true }); - if (!channelUsesRuntimeStorage(channelId) || existsSync(channelWorkspace(channelId))) { + if (channelUsesRuntimeStorage(channelId)) { + // OCI workspace/files live in runtime storage. On Windows that path is + // \\wsl.localhost\\... and host mkdir there fails; create via the WSL runtime. + try { ensureOciChannelWorkspaceDirs(channelId); } + catch { /* provision path creates these once the computer exists */ } + } else { mkdirSync(channelWorkspace(channelId), { recursive: true }); mkdirSync(channelFiles(channelId), { recursive: true }); for (const dir of COWORK_DIRS) { @@ -448,6 +471,15 @@ function checkedNoteContent(input: string): string { } function channelNotesDirectory(channelId: number): string { + if (windowsOciStorageRequired(channelId)) { + ensureChannelWorkspace(channelId); + try { windowsOciStorageStat(channelId, "workspace", "notes"); } + catch { + windowsOciStorageMkdir(channelId, "workspace", "notes"); + markWorkspaceDirty(channelId, "workspace/notes", "upsert"); + } + return "notes"; // logical path only; never a host path on Windows OCI + } const workspace = channelWorkspace(channelId); ensureChannelWorkspace(channelId); const notes = join(workspace, "notes"); @@ -462,8 +494,17 @@ function channelNotesDirectory(channelId: number): string { return notes; } -function notePath(channelId: number, input: string, requireExisting = true): { name: string; path: string } { +function notePath(channelId: number, input: string, requireExisting = true): { name: string; path: string; relative?: string } { const name = validateNoteFilename(input); + if (windowsOciStorageRequired(channelId)) { + channelNotesDirectory(channelId); + const relative = `notes/${name}`; + if (requireExisting) { + const st = windowsOciStorageStat(channelId, "workspace", relative); + if (st.kind !== "file") throw new Error("Note not found."); + } + return { name, path: relative, relative }; + } const notes = channelNotesDirectory(channelId); const path = join(notes, name); if (requireExisting && (!existsSync(path) || lstatSync(path).isSymbolicLink() || !lstatSync(path).isFile())) throw new Error("Note not found."); @@ -475,12 +516,20 @@ function notePath(channelId: number, input: string, requireExisting = true): { n return { name, path }; } -function noteView(name: string, path: string, content?: string): ChannelNote { +function noteView(name: string, path: string, content?: string, meta?: { size: number; modified: number }): ChannelNote { + if (meta) return { name, size: meta.size, modified: meta.modified, ...(content === undefined ? {} : { content }) }; const info = statSync(path); return { name, size: info.size, modified: info.mtimeMs, ...(content === undefined ? {} : { content }) }; } export function listChannelNotes(channelId: number): ChannelNote[] { + if (windowsOciStorageRequired(channelId)) { + channelNotesDirectory(channelId); + return windowsOciStorageList(channelId, "workspace", "notes") + .filter((entry) => entry.kind === "file" && entry.name.toLowerCase().endsWith(".md")) + .map((entry) => noteView(entry.name, entry.name, undefined, { size: entry.size, modified: entry.modified })) + .sort((a, b) => b.modified - a.modified || a.name.localeCompare(b.name)); + } const notes = channelNotesDirectory(channelId); return readdirSync(notes, { withFileTypes: true }) .filter((entry) => entry.isFile() && !entry.isSymbolicLink() && entry.name.toLowerCase().endsWith(".md")) @@ -490,13 +539,23 @@ export function listChannelNotes(channelId: number): ChannelNote[] { export function readChannelNote(channelId: number, input: string): ChannelNote { const note = notePath(channelId, input); + if (windowsOciStorageRequired(channelId) && note.relative) { + const body = windowsOciStorageRead(channelId, "workspace", note.relative).toString("utf8"); + const st = windowsOciStorageStat(channelId, "workspace", note.relative); + return noteView(note.name, note.path, body, { size: st.size, modified: st.modified }); + } return noteView(note.name, note.path, readFileSync(note.path, "utf8")); } export function createChannelNote(channelId: number, input: string, initialContent = ""): ChannelNote { const note = notePath(channelId, input, false); - if (existsSync(note.path)) throw new Error("A note with that name already exists."); const content = checkedNoteContent(initialContent); + if (windowsOciStorageRequired(channelId) && note.relative) { + const st = windowsOciStorageWrite(channelId, "workspace", note.relative, content, "create"); + markWorkspaceDirty(channelId, `workspace/notes/${note.name}`, "upsert"); + return noteView(note.name, note.path, content, { size: st.size, modified: st.modified }); + } + if (existsSync(note.path)) throw new Error("A note with that name already exists."); writeFileSync(note.path, content, { encoding: "utf8", flag: "wx" }); markWorkspaceDirty(channelId, `workspace/notes/${note.name}`, "upsert"); return noteView(note.name, note.path, content); @@ -505,6 +564,11 @@ export function createChannelNote(channelId: number, input: string, initialConte export function saveChannelNote(channelId: number, input: string, nextContent: string): ChannelNote { const note = notePath(channelId, input); const content = checkedNoteContent(nextContent); + if (windowsOciStorageRequired(channelId) && note.relative) { + const st = windowsOciStorageWrite(channelId, "workspace", note.relative, content, "overwrite"); + markWorkspaceDirty(channelId, `workspace/notes/${note.name}`, "upsert"); + return noteView(note.name, note.path, content, { size: st.size, modified: st.modified }); + } writeFileSync(note.path, content, "utf8"); markWorkspaceDirty(channelId, `workspace/notes/${note.name}`, "upsert"); return noteView(note.name, note.path, content); @@ -545,9 +609,29 @@ export function validateWorkspaceEntryName(input: string): string { return name; } +function workspaceRealpath(path: string): string { + try { return realpathSync(path); } + catch { throw new Error("Folder not found."); } +} + +/** Split a Files API path into storage area + relative path inside that area. */ +function workspaceAreaPath(input: string): { area: "workspace" | "files"; relative: string; path: string } { + const path = normalizeWorkspaceDirectoryPath(input); + if (path === "files" || path.startsWith("files/")) { + return { area: "files", relative: path === "files" ? "" : path.slice("files/".length), path }; + } + return { area: "workspace", relative: path, path }; +} + function workspaceApiPathToHost(channelId: number, input: string): { path: string; host: string; base: string } { const path = normalizeWorkspaceDirectoryPath(input); ensureChannelWorkspace(channelId); + // Windows OCI storage is NOT host-reachable (interop=false kills 9p). Callers + // that still need a host path (Linux/mac/native) use this; Windows routes + // through windowsOciStorage* instead and must not call this for IO. + if (windowsOciStorageRequired(channelId)) { + throw new Error("Windows OCI channel storage is accessed through the runtime helper, not a host path."); + } const uploads = path === "files" || path.startsWith("files/"); const base = resolve(uploads ? channelFiles(channelId) : channelWorkspace(channelId)); const inside = uploads ? path.slice("files".length).replace(/^\/+/, "") : path; @@ -557,6 +641,14 @@ function workspaceApiPathToHost(channelId: number, input: string): { path: strin } function existingWorkspaceDirectory(channelId: number, input: string): { path: string; host: string; base: string } { + if (windowsOciStorageRequired(channelId)) { + const { area, relative, path } = workspaceAreaPath(input); + ensureChannelWorkspace(channelId); + const st = windowsOciStorageStat(channelId, area, relative); + if (st.kind !== "directory") throw new Error("Folder not found."); + // host/base are unused on Windows; keep shape for call sites that only need .path. + return { path, host: "", base: "" }; + } const resolved = workspaceApiPathToHost(channelId, input); if (!existsSync(resolved.host) || lstatSync(resolved.host).isSymbolicLink() || !lstatSync(resolved.host).isDirectory()) throw new Error("Folder not found."); const relativeParts = relative(resolved.base, resolved.host).split(sep).filter(Boolean); @@ -565,8 +657,8 @@ function existingWorkspaceDirectory(channelId: number, input: string): { path: s cursor = join(cursor, part); if (lstatSync(cursor).isSymbolicLink()) throw new Error("Folder not found."); } - const realBase = realpathSync(resolved.base); - const realHost = realpathSync(resolved.host); + const realBase = workspaceRealpath(resolved.base); + const realHost = workspaceRealpath(resolved.host); if (realHost !== realBase && !realHost.startsWith(realBase + sep)) throw new Error("Folder not found."); return resolved; } @@ -577,6 +669,27 @@ function workspaceWorldPath(path: string): string { /** Direct children for a navigable /workspace file browser. */ export function listWorkspaceDirectory(channelId: number, input = ""): { path: string; files: WorkspaceFile[] } { + if (windowsOciStorageRequired(channelId)) { + const { area, relative, path } = workspaceAreaPath(input); + ensureChannelWorkspace(channelId); + const entries = windowsOciStorageList(channelId, area, relative) + .filter((entry) => !(!path && entry.name === "files")) + .map((entry) => ({ + path: path ? `${path}/${entry.name}` : entry.name, + name: entry.name, + size: entry.size, + modified: entry.modified, + kind: entry.kind as "file" | "directory", + })); + if (!path) { + try { + const uploads = windowsOciStorageStat(channelId, "files", ""); + entries.push({ path: "files", name: "files", size: 0, modified: uploads.modified, kind: "directory" }); + } catch { /* uploads tree may not exist yet */ } + } + entries.sort((a, b) => Number(a.kind !== "directory") - Number(b.kind !== "directory") || a.name.localeCompare(b.name)); + return { path, files: entries }; + } const directory = existingWorkspaceDirectory(channelId, input); const files = readdirSync(directory.host, { withFileTypes: true }).flatMap((entry): WorkspaceFile[] => { // /workspace/files is the guest view of the separate host-owned upload mirror. @@ -598,8 +711,17 @@ export function listWorkspaceDirectory(channelId: number, input = ""): { path: s } export function createWorkspaceDirectory(channelId: number, parentInput: string, nameInput: string): WorkspaceFile { - const parent = existingWorkspaceDirectory(channelId, parentInput); const name = validateWorkspaceEntryName(nameInput); + if (windowsOciStorageRequired(channelId)) { + const parent = workspaceAreaPath(parentInput); + if (!parent.path && name === "files") throw new Error("The /workspace/files folder is reserved for channel uploads."); + const path = parent.path ? `${parent.path}/${name}` : name; + const relative = parent.relative ? `${parent.relative}/${name}` : name; + const st = windowsOciStorageMkdir(channelId, parent.area, relative); + markWorkspaceDirty(channelId, workspaceWorldPath(path), "upsert"); + return { path, name, size: 0, modified: st.modified, kind: "directory" }; + } + const parent = existingWorkspaceDirectory(channelId, parentInput); if (!parent.path && name === "files") throw new Error("The /workspace/files folder is reserved for channel uploads."); const path = parent.path ? `${parent.path}/${name}` : name; const target = resolve(parent.host, name); @@ -610,9 +732,22 @@ export function createWorkspaceDirectory(channelId: number, parentInput: string, return { path, name, size: 0, modified: info.mtimeMs, kind: "directory" }; } -function workspaceEntry(channelId: number, input: string): { path: string; host: string; base: string; info: Stats } { +function workspaceEntry(channelId: number, input: string): { path: string; host: string; base: string; info: Stats; area?: "workspace" | "files"; relative?: string } { const path = normalizeWorkspaceDirectoryPath(input); if (!path) throw new Error("Choose a file or folder inside /workspace."); + if (windowsOciStorageRequired(channelId)) { + const { area, relative } = workspaceAreaPath(path); + const st = windowsOciStorageStat(channelId, area, relative); + // Synthetic Stats-compatible shape for callers that only check isFile/isDirectory/size. + const info = { + isFile: () => st.kind === "file", + isDirectory: () => st.kind === "directory", + isSymbolicLink: () => false, + size: st.size, + mtimeMs: st.modified, + } as Stats; + return { path, host: "", base: "", info, area, relative }; + } const resolved = workspaceApiPathToHost(channelId, path); if (!existsSync(resolved.host) || lstatSync(resolved.host).isSymbolicLink()) throw new Error("File or folder not found."); const relativeParts = relative(resolved.base, resolved.host).split(sep).filter(Boolean); @@ -621,8 +756,8 @@ function workspaceEntry(channelId: number, input: string): { path: string; host: cursor = join(cursor, part); if (lstatSync(cursor).isSymbolicLink()) throw new Error("File or folder not found."); } - const realBase = realpathSync(resolved.base); - const realHost = realpathSync(resolved.host); + const realBase = workspaceRealpath(resolved.base); + const realHost = workspaceRealpath(resolved.host); if (realHost !== realBase && !realHost.startsWith(realBase + sep)) throw new Error("File or folder not found."); return { ...resolved, info: lstatSync(resolved.host) }; } @@ -633,7 +768,8 @@ function assertMutableWorkspaceEntry(path: string): void { } } -function workspaceFileView(path: string, host: string): WorkspaceFile { +function workspaceFileView(path: string, host: string, meta?: { size: number; modified: number; kind: "file" | "directory" }): WorkspaceFile { + if (meta) return { path, name: basename(path) || path, size: meta.size, modified: meta.modified, kind: meta.kind }; const info = statSync(host); return { path, name: basename(host), size: info.isFile() ? info.size : 0, modified: info.mtimeMs, kind: info.isDirectory() ? "directory" : "file" }; } @@ -647,8 +783,17 @@ function checkedWorkspaceText(input: string): string { /** Create a text asset in the same /workspace tree used by agents and Terminal. */ export function createWorkspaceFile(channelId: number, parentInput: string, nameInput: string, initialContent = ""): WorkspaceFile { - const parent = existingWorkspaceDirectory(channelId, parentInput); const name = validateWorkspaceEntryName(nameInput); + if (windowsOciStorageRequired(channelId)) { + const parent = workspaceAreaPath(parentInput); + const path = parent.path ? `${parent.path}/${name}` : name; + const relative = parent.relative ? `${parent.relative}/${name}` : name; + const content = checkedWorkspaceText(initialContent); + const st = windowsOciStorageWrite(channelId, parent.area, relative, content, "create"); + markWorkspaceDirty(channelId, workspaceWorldPath(path), "upsert"); + return { path, name, size: st.size, modified: st.modified, kind: "file" }; + } + const parent = existingWorkspaceDirectory(channelId, parentInput); const path = parent.path ? `${parent.path}/${name}` : name; const target = resolve(parent.host, name); if (dirname(target) !== parent.host || existsSync(target)) throw new Error("A file or folder with that name already exists."); @@ -661,6 +806,11 @@ export function readWorkspaceTextFile(channelId: number, input: string): Workspa const entry = workspaceEntry(channelId, input); if (!entry.info.isFile()) throw new Error("Choose a file to edit."); if (entry.info.size > MAX_EDITABLE_FILE_BYTES) throw new Error("This file is too large to edit in 1Helm."); + if (windowsOciStorageRequired(channelId) && entry.area && entry.relative != null) { + const content = windowsOciStorageRead(channelId, entry.area, entry.relative).toString("utf8"); + if (content.includes("\0")) throw new Error("This file type is not supported to view."); + return { path: entry.path, name: basename(entry.path), size: entry.info.size, modified: entry.info.mtimeMs, kind: "file", content }; + } const content = readFileSync(entry.host, "utf8"); if (content.includes("\0")) throw new Error("This file type is not supported to view."); return { ...workspaceFileView(entry.path, entry.host), content }; @@ -670,6 +820,11 @@ export function saveWorkspaceTextFile(channelId: number, input: string, nextCont const entry = workspaceEntry(channelId, input); if (!entry.info.isFile()) throw new Error("Choose a file to edit."); const content = checkedWorkspaceText(nextContent); + if (windowsOciStorageRequired(channelId) && entry.area && entry.relative != null) { + const st = windowsOciStorageWrite(channelId, entry.area, entry.relative, content, "overwrite"); + markWorkspaceDirty(channelId, workspaceWorldPath(entry.path), "upsert"); + return { path: entry.path, name: basename(entry.path), size: st.size, modified: st.modified, kind: "file", content }; + } writeFileSync(entry.host, content, "utf8"); markWorkspaceDirty(channelId, workspaceWorldPath(entry.path), "upsert"); return { ...workspaceFileView(entry.path, entry.host), content }; @@ -680,11 +835,25 @@ export function moveWorkspaceEntry(channelId: number, input: string, parentInput const entry = workspaceEntry(channelId, input); assertMutableWorkspaceEntry(entry.path); const parentPath = parentInput == null ? entry.path.split("/").slice(0, -1).join("/") : normalizeWorkspaceDirectoryPath(parentInput); - const parent = existingWorkspaceDirectory(channelId, parentPath); const name = nameInput == null ? basename(entry.path) : validateWorkspaceEntryName(nameInput); - const nextPath = parent.path ? `${parent.path}/${name}` : name; - if (nextPath === entry.path) return workspaceFileView(entry.path, entry.host); + const nextPath = parentPath ? `${parentPath}/${name}` : name; + if (nextPath === entry.path) { + if (windowsOciStorageRequired(channelId)) { + return { path: entry.path, name: basename(entry.path), size: entry.info.size, modified: entry.info.mtimeMs, kind: entry.info.isDirectory() ? "directory" : "file" }; + } + return workspaceFileView(entry.path, entry.host); + } if (entry.info.isDirectory() && (nextPath === entry.path || nextPath.startsWith(entry.path + "/"))) throw new Error("A folder cannot be moved inside itself."); + if (windowsOciStorageRequired(channelId) && entry.area) { + const from = workspaceAreaPath(entry.path); + const to = workspaceAreaPath(nextPath); + if (from.area !== to.area) throw new Error("Cannot move entries between workspace and files areas."); + const st = windowsOciStorageRename(channelId, from.area, from.relative, to.relative); + markWorkspaceDirty(channelId, workspaceWorldPath(entry.path), "delete"); + markWorkspaceDirty(channelId, workspaceWorldPath(nextPath), "upsert"); + return { path: nextPath, name, size: st.size, modified: st.modified, kind: st.kind }; + } + const parent = existingWorkspaceDirectory(channelId, parentPath); const target = resolve(parent.host, name); if (dirname(target) !== parent.host || existsSync(target)) throw new Error("A file or folder with that name already exists."); renameSync(entry.host, target); @@ -697,11 +866,30 @@ export function duplicateWorkspaceEntry(channelId: number, input: string): Works const entry = workspaceEntry(channelId, input); assertMutableWorkspaceEntry(entry.path); const parentPath = entry.path.split("/").slice(0, -1).join("/"); - const parent = existingWorkspaceDirectory(channelId, parentPath); const original = basename(entry.path); const dot = entry.info.isFile() ? original.lastIndexOf(".") : -1; const stem = dot > 0 ? original.slice(0, dot) : original; const ext = dot > 0 ? original.slice(dot) : ""; + if (windowsOciStorageRequired(channelId) && entry.area) { + const parent = workspaceAreaPath(parentPath); + let name = `${stem} copy${ext}`; + for (let suffix = 2; ; suffix += 1) { + const relative = parent.relative ? `${parent.relative}/${name}` : name; + try { + windowsOciStorageStat(channelId, parent.area, relative); + name = `${stem} copy ${suffix}${ext}`; + } catch { + break; + } + } + const path = parent.path ? `${parent.path}/${name}` : name; + const from = workspaceAreaPath(entry.path); + const to = workspaceAreaPath(path); + const st = windowsOciStorageCopy(channelId, from.area, from.relative, to.relative); + markWorkspaceDirty(channelId, workspaceWorldPath(path), "upsert"); + return { path, name, size: st.size, modified: st.modified, kind: st.kind }; + } + const parent = existingWorkspaceDirectory(channelId, parentPath); let name = `${stem} copy${ext}`; for (let suffix = 2; existsSync(resolve(parent.host, name)); suffix += 1) name = `${stem} copy ${suffix}${ext}`; const path = parent.path ? `${parent.path}/${name}` : name; @@ -714,12 +902,30 @@ export function duplicateWorkspaceEntry(channelId: number, input: string): Works export function deleteWorkspaceEntry(channelId: number, input: string): void { const entry = workspaceEntry(channelId, input); assertMutableWorkspaceEntry(entry.path); + if (windowsOciStorageRequired(channelId) && entry.area && entry.relative != null) { + windowsOciStorageRm(channelId, entry.area, entry.relative, entry.info.isDirectory()); + markWorkspaceDirty(channelId, workspaceWorldPath(entry.path), "delete"); + return; + } rmSync(entry.host, { recursive: entry.info.isDirectory(), force: false }); markWorkspaceDirty(channelId, workspaceWorldPath(entry.path), "delete"); } /** A safe folder tree for the Files and Cowork navigation rails. */ export function listWorkspaceDirectories(channelId: number): WorkspaceFile[] { + if (windowsOciStorageRequired(channelId)) { + const result: WorkspaceFile[] = []; + const walk = (path: string): void => { + const listed = listWorkspaceDirectory(channelId, path); + for (const entry of listed.files) { + if (entry.kind !== "directory") continue; + result.push(entry); + walk(entry.path); + } + }; + walk(""); + return result.sort((a, b) => a.path.localeCompare(b.path)); + } const result: WorkspaceFile[] = []; const walk = (path: string): void => { const directory = existingWorkspaceDirectory(channelId, path); @@ -764,9 +970,29 @@ export function importWorkspaceUpload(channelId: number, threadId: number | null if (!/^[a-f0-9]{32,}$/.test(String(token || ""))) return null; const source = join(UPLOAD_DIR, token); if (!existsSync(source) || !lstatSync(source).isFile()) return null; - const directory = existingWorkspaceDirectory(channelId, directoryInput); const basenameOnly = basename(String(nameInput || "file")); const safe = basenameOnly.replace(/[^a-zA-Z0-9._ -]+/g, "-").replace(/^\.+$/, "") || "file"; + if (windowsOciStorageRequired(channelId)) { + const parent = workspaceAreaPath(directoryInput); + let name = safe; + for (let suffix = 2; ; suffix++) { + const relative = parent.relative ? `${parent.relative}/${name}` : name; + try { + windowsOciStorageStat(channelId, parent.area, relative); + const dot = safe.lastIndexOf("."); + name = dot > 0 ? `${safe.slice(0, dot)}-${suffix}${safe.slice(dot)}` : `${safe}-${suffix}`; + } catch { + const body = readFileSync(source); + const st = windowsOciStorageWrite(channelId, parent.area, relative, body, "create"); + const path = parent.path ? `${parent.path}/${name}` : name; + const worldPath = workspaceWorldPath(path); + run("INSERT INTO artifacts (channel_id, thread_id, path, kind, created_by, size, modified, created) VALUES (?,?,?,'upload',?,?,?,?)", channelId, threadId, worldPath, createdBy, st.size, st.modified, now()); + markWorkspaceDirty(channelId, worldPath, "upsert"); + return worldPath; + } + } + } + const directory = existingWorkspaceDirectory(channelId, directoryInput); let target = join(directory.host, safe); for (let suffix = 2; existsSync(target); suffix++) { const dot = safe.lastIndexOf("."); @@ -787,6 +1013,19 @@ export function listWorkspaceFiles(channelId: number): WorkspaceFile[] { // workspace/... (its absolute path in the agent world, and unambiguous next // to the sibling uploads tree, listed as files/...). ensureChannelWorkspace(channelId); + if (windowsOciStorageRequired(channelId)) { + const files: WorkspaceFile[] = []; + const walk = (area: "workspace" | "files", relative: string, prefix: string): void => { + for (const entry of windowsOciStorageList(channelId, area, relative)) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + files.push({ path: rel, name: entry.name, size: entry.size, modified: entry.modified, kind: entry.kind }); + if (entry.kind === "directory") walk(area, relative ? `${relative}/${entry.name}` : entry.name, rel); + } + }; + walk("workspace", "", "workspace"); + walk("files", "", "files"); + return files.sort((a, b) => a.path.localeCompare(b.path)); + } const files: WorkspaceFile[] = []; const walk = (dir: string, prefix: string): void => { if (!existsSync(dir)) return; @@ -808,12 +1047,29 @@ export function listWorkspaceFiles(channelId: number): WorkspaceFile[] { return files.sort((a, b) => a.path.localeCompare(b.path)); } +/** + * Resolve a Files path to either a host absolute path (Linux/mac/native) or, + * on Windows OCI, a temporary host file filled from the runtime helper so + * callers that stream via readFile still work without UNC. + */ export function resolveWorldFile(channelId: number, requested: string): string { ensureChannelWorkspace(channelId); - const root = realpathSync(resolve(channelWorkspace(channelId))); - // UI paths are relative to /workspace (agent shell). Map bare paths into workspace/. let rel = String(requested || "").replace(/^\/+/, ""); if (rel.startsWith("workspace/")) rel = rel.slice("workspace/".length); + if (windowsOciStorageRequired(channelId)) { + const { area, relative } = workspaceAreaPath(rel); + if (!relative) throw new Error("File not found."); + const body = windowsOciStorageRead(channelId, area, relative); + const tmpRoot = join(DATA_DIR, "tmp-world-files"); + mkdirSync(tmpRoot, { recursive: true }); + const token = randomBytes(16).toString("hex"); + const safeName = basename(relative).replace(/[^a-zA-Z0-9._-]+/g, "_") || "file"; + const target = join(tmpRoot, `${token}-${safeName}`); + writeFileSync(target, body); + return target; + } + const root = realpathSync(resolve(channelWorkspace(channelId))); + // UI paths are relative to /workspace (agent shell). Map bare paths into workspace/. const filesRoot = realpathSync(resolve(channelFiles(channelId))); const candidates = rel.startsWith("files/") ? [resolve(filesRoot, rel.slice("files/".length))] diff --git a/src/server/bots.ts b/src/server/bots.ts index 585b719..664f17b 100644 --- a/src/server/bots.ts +++ b/src/server/bots.ts @@ -217,7 +217,7 @@ function systemPromptTiers(bot: Row, agent: RuntimeAgent | undefined, channelId: visiting ? "You are a temporary thread guest. No shell, workspace, or durable-memory capability is attached to this invitation." : "You own an isolated persistent Linux computer for this channel. Its durable workspace is /workspace. The run_command tool executes there; files, memory, and other listed tools belong to this channel.", - visiting ? "" : "Where practical, use Markdown for notes and documents, plain text source files for code, .whiteboard.json for whiteboards, and .slides.json for presentations.", + visiting ? "" : "Workspace file contracts: Markdown for notes and documents; plain text source files for code. /workspace/whiteboards holds `.whiteboard.json` Excalidraw scenes and /workspace/presentations holds `.slides.json` decks — these two folders are rendered by exact schemas, so never place .html decks or invented JSON formats there. When a request supplies a format contract for a folder, follow it exactly.", "The callable tools below are your current capabilities. Their implementations enforce authority and isolation boundaries.", ].filter(Boolean).join("\n\n"); const context = [ @@ -749,7 +749,7 @@ export async function generateAndAttachImage( return attachWorkspaceFileToMessage(channelId, messageId, threadId, relativePath, actor, fileName); } -export function buildContext(bot: Row, agent: RuntimeAgent | undefined, channelId: number, triggerId: number, threadRootId: number, fresh: boolean, hostAuthorized: boolean): ChatMsg[] { +export function buildContext(bot: Row, agent: RuntimeAgent | undefined, channelId: number, triggerId: number, threadRootId: number, fresh: boolean, hostAuthorized: boolean, hiddenContext?: string): ChatMsg[] { const currentTask = String(q1("SELECT body FROM messages WHERE id=?", triggerId)?.body || ""); const prompt = systemPromptTiers(bot, agent, channelId, hostAuthorized, currentTask); const messages: ChatMsg[] = [ @@ -757,6 +757,7 @@ export function buildContext(bot: Row, agent: RuntimeAgent | undefined, channelI { role: "system", content: `\n${prompt.operating}\n` }, ]; if (prompt.context) messages.push({ role: "system", content: `\n${prompt.context}\n` }); + if (hiddenContext) messages.push({ role: "system", content: `\n${hiddenContext}\n` }); const threadId = threadIdForRoot(threadRootId, channelId) ?? ensureThread(threadRootId, channelId); const thread = q1("SELECT status, summary FROM threads WHERE id=?", threadId); const memories = relevantMemory(channelId, threadId).filter((memory) => Number(memory.thread_id || 0) !== threadId || String(memory.kind) !== "summary"); @@ -1261,7 +1262,7 @@ function requestUserForTurn(triggerId: number, threadRootId: number): number { )?.user_id || 0); } -export function runBot(bot: Row, channelId: number, triggerId: number, threadRootId: number, fresh: boolean, escalationId?: number, hostAuthorized = false): Promise { +export function runBot(bot: Row, channelId: number, triggerId: number, threadRootId: number, fresh: boolean, escalationId?: number, hostAuthorized = false, hiddenContext?: string): Promise { const botId = Number(bot.id); const key = turnLane(botId, channelId, threadRootId); const duplicate = q1("SELECT state FROM agent_turns WHERE bot_id=? AND channel_id=? AND thread_root_id=? AND trigger_id=?", botId, channelId, threadRootId, triggerId); @@ -1294,7 +1295,7 @@ export function runBot(bot: Row, channelId: number, triggerId: number, threadRoo agentQueueState.set(key, queue); const current = previous.catch(() => undefined).then(() => executeBot( bot, channelId, triggerId, threadRootId, fresh, escalationId, hostAuthorized, - queuedTurn.messageId, queuedTurn.progressId, turnId, + queuedTurn.messageId, queuedTurn.progressId, turnId, hiddenContext, )); agentQueues.set(key, current); const release = (): void => { @@ -1343,7 +1344,7 @@ export function resumeQueuedAgentTurns(): void { } /** Run a resident agent or Skipper in response to a mention/tool escalation. */ -async function executeBot(bot: Row, channelId: number, triggerId: number, threadRootId: number, fresh: boolean, escalationId?: number, hostAuthorized = false, preparedMessageId?: number, preparedProgressId?: number, turnId?: number): Promise { +async function executeBot(bot: Row, channelId: number, triggerId: number, threadRootId: number, fresh: boolean, escalationId?: number, hostAuthorized = false, preparedMessageId?: number, preparedProgressId?: number, turnId?: number, hiddenContext?: string): Promise { const discardPrepared = (): void => { if (!preparedMessageId || !q1("SELECT 1 FROM messages WHERE id=?", preparedMessageId)) return; run("DELETE FROM messages WHERE id=?", preparedMessageId); @@ -1511,7 +1512,7 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread emitNow(); if (!preparedMessageId) broadcastToChannel(channelId, { type: "message", message: serializeMessage(msgId), parent: serializeMessage(threadRootId) }); - const messages = buildContext(bot, agent, channelId, triggerId, threadRootId, fresh, hostAuthorized); + const messages = buildContext(bot, agent, channelId, triggerId, threadRootId, fresh, hostAuthorized, hiddenContext); const tools = toolsFor(bot, agent, hostAuthorized, channelId); const actor = agent?.kind === "skipper" ? "skipper" : "agent"; try { diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index a2a4fbc..2d137e9 100644 --- a/src/server/channel-computers.ts +++ b/src/server/channel-computers.ts @@ -1,5 +1,5 @@ import { createHash, randomBytes } from "node:crypto"; -import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { spawn, spawnSync, type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; import { createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; import { cpus as hostCpus, freemem, platform, totalmem } from "node:os"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; @@ -8,7 +8,7 @@ import { pipeline } from "node:stream/promises"; import { spawn as spawnPty, type IPty } from "node-pty"; import { WebSocket } from "ws"; import { DATA_DIR, now, q, q1, run, type Row } from "./db.ts"; -import { channelFilesPath, channelFilesystemRoot, channelWorkspacePath, installationScopedRuntimeName, ociHostStateRoot } from "./channel-storage.ts"; +import { channelFilesPath, channelFilesystemRoot, channelUsesRuntimeStorage, channelWorkspacePath, installationScopedRuntimeName, ociHostStateRoot } from "./channel-storage.ts"; export type ChannelComputerBackend = "apple" | "oci" | "native" | "mock"; export type ChannelComputer = { @@ -68,7 +68,7 @@ const APPLE_RUNTIME_VERSION = "1.1.0"; export const APPLE_RUNTIME_PACKAGE = `container-${APPLE_RUNTIME_VERSION}-installer-signed.pkg`; export const APPLE_RUNTIME_URL = `https://github.com/apple/container/releases/download/${APPLE_RUNTIME_VERSION}/${APPLE_RUNTIME_PACKAGE}`; export const APPLE_RUNTIME_SHA256 = "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"; -export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.29"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.30"; const CONTAINER_CANDIDATES = [process.env.HELM_CONTAINER_CLI, "/usr/local/bin/container", "/opt/homebrew/bin/container", "container"].filter(Boolean) as string[]; const OCI_RUNTIME_VERSION = "1helm-oci-runtime-v1"; const OCI_HELPER_CANDIDATES = [ @@ -325,8 +325,201 @@ async function apple(args: string[], opts: Parameters[2] return spawnCollected(resolveContainerCli(), args, opts); } +/** Keep the in-WSL OCI helper synchronized with the packaged app so hotfixes and updates apply. */ +let windowsOciHelperDigest = ""; +function ensureWindowsOciHelperInstalled(): void { + if (platform() !== "win32") return; + const appRoot = process.env.HELM_APP_ROOT || process.cwd(); + const source = join(appRoot, "scripts", "1helm-oci-runtime"); + if (!existsSync(source)) return; + const encoded = readFileSync(source); + const digest = createHash("sha256").update(encoded).digest("hex"); + if (windowsOciHelperDigest === digest) return; + const runtime = installationScopedRuntimeName(); + const wsl = resolveWslCli(); + // Wake the distro first; cold start can exceed a short copy timeout. + spawnSync(wsl, ["--distribution", runtime, "--user", "root", "--exec", "/bin/true"], { + encoding: "buffer", timeout: 120_000, windowsHide: true, + }); + // Copy via wsl so we never depend on a ready \\wsl.localhost mount race. + const result = spawnSync(wsl, [ + "--distribution", runtime, "--user", "root", "--exec", "/bin/bash", "-lc", + "cat > /usr/libexec/1helm-oci-runtime && chmod 0755 /usr/libexec/1helm-oci-runtime", + ], { input: encoded, encoding: "buffer", timeout: 60_000, windowsHide: true }); + if (result.status !== 0) { + const detail = Buffer.concat([ + Buffer.isBuffer(result.stderr) ? result.stderr : Buffer.alloc(0), + Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.alloc(0), + ]).toString("utf8").replaceAll("\0", " ").trim(); + throw new Error(detail || "Could not install the shared OCI runtime helper into the Windows WSL runtime."); + } + windowsOciHelperDigest = digest; +} + +/** + * Create OCI channel workspace dirs inside the WSL runtime as root. + * Host-side mkdir on \\wsl.localhost\\... fails on Windows (permissions / 9p / distro-not-ready). + * The live failure was: UNKNOWN mkdir '...\\workspace\\notes' after a successful container provision. + */ +export function ensureOciChannelWorkspaceDirs(channelId: number): void { + const machineId = String(channelComputer(channelId)?.machine_id || "").trim() + || `1helm-${String(q1("SELECT installation_id FROM workspace WHERE id=1")?.installation_id || "")}-channel-${channelId}`; + if (!/^1helm-[a-f0-9]{16}-channel-\d+$/.test(machineId)) { + throw new Error("Channel computer identity is not ready for workspace layout."); + } + if (platform() !== "win32") { + const root = channelFilesystemRoot(channelId); + mkdirSync(join(root, "workspace"), { recursive: true }); + mkdirSync(join(root, "files"), { recursive: true }); + for (const directory of ["notes", "whiteboards", "code", "docs", "presentations"]) { + mkdirSync(join(root, "workspace", directory), { recursive: true }); + } + return; + } + ensureWindowsOciHelperInstalled(); + const runtime = installationScopedRuntimeName(); + const wsl = resolveWslCli(); + // Workspace/files must be reachable from the Windows host Files/Cowork UI via + // \\wsl.localhost\... Mode 0700 agent-only makes Node existsSync/readdir look + // like "Folder not found." The distro has no Windows interop/automount, so + // opening these trees for the distro owner is the intended host-access path. + const script = [ + "set -euo pipefail", + `root="/var/lib/1helm-oci-v1/runtime/oci/channels/${machineId}"`, + // 0711 root matches the helper's verify_storage contract. workspace/files + // stay agent-owned 0700; Windows Files/Cowork use storage-* helper ops, + // never the disabled-interop 9p share. + 'install -d -o root -g root -m 0711 "$root"', + 'chmod 0711 "$root" || true', + 'chown root:root "$root" || true', + 'install -d -o 1000 -g 1000 -m 0700 "$root/workspace" "$root/files" "$root/home"', + "for d in notes whiteboards code docs presentations; do", + ' install -d -o 1000 -g 1000 -m 0700 "$root/workspace/$d"', + "done", + ].join("\n"); + const result = spawnSync(wsl, [ + "--distribution", runtime, "--user", "root", "--exec", "/bin/bash", "-lc", script, + ], { encoding: "buffer", timeout: 60_000, windowsHide: true }); + if (result.status !== 0) { + const detail = Buffer.concat([ + Buffer.isBuffer(result.stderr) ? result.stderr : Buffer.alloc(0), + Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.alloc(0), + ]).toString("utf8").replaceAll("\0", " ").trim(); + throw new Error(detail || "Could not create the channel workspace directories inside the Windows WSL runtime."); + } +} + +/** + * Windows OCI host access for Files/Cowork. + * + * Isolation keeps WSL interop disabled, which also kills the 9p file server + * behind \\wsl.localhost. Direct Node fs against that UNC path can never work. + * All host-side Files/Cowork IO therefore goes through the OCI helper's + * storage-* operations (wsl.exe --exec → 1helm-oci-runtime). + */ +export function windowsOciStorageRequired(channelId: number): boolean { + return platform() === "win32" && channelUsesRuntimeStorage(channelId); +} + +/** @deprecated No longer probes UNC; kept as a no-op for call-site stability. */ +export function ensureWindowsOciHostAccess(channelId: number): void { + if (!windowsOciStorageRequired(channelId)) return; + ensureOciChannelWorkspaceDirs(channelId); +} + +type StorageArea = "workspace" | "files"; +type StorageEntry = { name: string; kind: "file" | "directory"; size: number; modified: number }; + +function storageMachineAndOwner(channelId: number): { name: string; owner: string } { + const computer = ensureChannelComputerRecord(channelId); + return { name: computer.machine_id, owner: ownerMarker(computer) }; +} + +function ociStorageJson(args: string[], opts: { input?: Buffer; timeoutMs?: number } = {}): unknown { + const invocation = ociInvocation(args); + const result = spawnSync(invocation.command, invocation.args, { + encoding: "buffer", + timeout: opts.timeoutMs || 60_000, + env: invocation.env, + windowsHide: true, + input: opts.input, + }); + const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.alloc(0); + const stderr = Buffer.isBuffer(result.stderr) ? result.stderr : Buffer.alloc(0); + if (result.status !== 0) { + const detail = windowsLines(Buffer.concat([stderr, stdout])).join(" ").replace(/^1Helm OCI runtime:\s*/i, "").trim(); + throw new Error(detail || `storage operation failed (exit ${result.status ?? "timeout"})`); + } + const text = windowsLines(stdout).join("\n").trim() || Buffer.from(stdout).toString("utf8").replaceAll("\0", "").trim(); + if (!text) return null; + try { return JSON.parse(text); } + catch { throw new Error("storage operation returned unreadable JSON"); } +} + +function ociStorageBytes(args: string[], opts: { input?: Buffer; timeoutMs?: number } = {}): Buffer { + const invocation = ociInvocation(args); + const result = spawnSync(invocation.command, invocation.args, { + encoding: "buffer", + timeout: opts.timeoutMs || 60_000, + env: invocation.env, + windowsHide: true, + input: opts.input, + }); + const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.alloc(0); + const stderr = Buffer.isBuffer(result.stderr) ? result.stderr : Buffer.alloc(0); + if (result.status !== 0) { + const detail = windowsLines(Buffer.concat([stderr, stdout])).join(" ").replace(/^1Helm OCI runtime:\s*/i, "").trim(); + throw new Error(detail || `storage operation failed (exit ${result.status ?? "timeout"})`); + } + // Binary body: do not strip NULs via windowsLines. + return Buffer.from(stdout); +} + +export function windowsOciStorageList(channelId: number, area: StorageArea, relative = ""): StorageEntry[] { + const { name, owner } = storageMachineAndOwner(channelId); + const payload = ociStorageJson(["storage-list", name, owner, area, ...(relative ? [relative] : [])]) as { entries?: StorageEntry[] }; + return Array.isArray(payload?.entries) ? payload.entries : []; +} + +export function windowsOciStorageStat(channelId: number, area: StorageArea, relative = ""): StorageEntry & { exists: boolean } { + const { name, owner } = storageMachineAndOwner(channelId); + return ociStorageJson(["storage-stat", name, owner, area, ...(relative ? [relative] : [])]) as StorageEntry & { exists: boolean }; +} + +export function windowsOciStorageMkdir(channelId: number, area: StorageArea, relative: string): StorageEntry { + const { name, owner } = storageMachineAndOwner(channelId); + return ociStorageJson(["storage-mkdir", name, owner, area, relative]) as StorageEntry; +} + +export function windowsOciStorageWrite(channelId: number, area: StorageArea, relative: string, body: Buffer | string, mode: "create" | "overwrite" = "overwrite"): StorageEntry { + const { name, owner } = storageMachineAndOwner(channelId); + const input = Buffer.isBuffer(body) ? body : Buffer.from(body, "utf8"); + return ociStorageJson(["storage-write", name, owner, area, relative, mode], { input }) as StorageEntry; +} + +export function windowsOciStorageRead(channelId: number, area: StorageArea, relative: string): Buffer { + const { name, owner } = storageMachineAndOwner(channelId); + return ociStorageBytes(["storage-read", name, owner, area, relative]); +} + +export function windowsOciStorageRm(channelId: number, area: StorageArea, relative: string, recursive = false): void { + const { name, owner } = storageMachineAndOwner(channelId); + ociStorageJson(["storage-rm", name, owner, area, relative, recursive ? "1" : "0"]); +} + +export function windowsOciStorageRename(channelId: number, area: StorageArea, from: string, to: string): StorageEntry { + const { name, owner } = storageMachineAndOwner(channelId); + return ociStorageJson(["storage-rename", name, owner, area, from, to]) as StorageEntry; +} + +export function windowsOciStorageCopy(channelId: number, area: StorageArea, from: string, to: string): StorageEntry { + const { name, owner } = storageMachineAndOwner(channelId); + return ociStorageJson(["storage-copy", name, owner, area, from, to]) as StorageEntry; +} + function ociInvocation(args: string[]): { command: string; args: string[]; env?: NodeJS.ProcessEnv } { if (platform() === "win32") { + ensureWindowsOciHelperInstalled(); return { command: resolveWslCli(), args: ["--distribution", installationScopedRuntimeName(), "--user", "root", "--exec", "/usr/libexec/1helm-oci-runtime", ...args] }; } const helper = resolveOciHelper(); @@ -341,6 +534,10 @@ function ociInvocation(args: string[]): { command: string; args: string[]; env?: HELM_OCI_RUNTIME_MANIFEST: process.env.HELM_OCI_RUNTIME_MANIFEST || join(appRoot, "deploy", "1helm-oci-runtime-v1.conf"), HELM_OCI_STATE_ROOT_OVERRIDE: process.env.HELM_OCI_STATE_ROOT_OVERRIDE || ociHostStateRoot(), HELM_OCI_CONTAINERFILE_OVERRIDE: process.env.HELM_OCI_CONTAINERFILE_OVERRIDE || join(appRoot, "container", "Containerfile.oci"), + HELM_OCI_IMAGE_ARCHIVE_OVERRIDE: process.env.HELM_OCI_IMAGE_ARCHIVE_OVERRIDE || join(appRoot, "container", "channel-machine.oci.tar"), + HELM_OCI_IMAGE_SHA256_FILE_OVERRIDE: process.env.HELM_OCI_IMAGE_SHA256_FILE_OVERRIDE || join(appRoot, "container", "channel-machine.oci.sha256"), + // Development checkouts without a sealed archive may still live-build. + HELM_OCI_ALLOW_LIVE_BUILD: process.env.HELM_OCI_ALLOW_LIVE_BUILD || (existsSync(join(appRoot, "container", "channel-machine.oci.tar")) ? "0" : "1"), } : {}), }, }; @@ -496,7 +693,9 @@ async function ensureAppleProvisioned(computer: ChannelComputer): Promise } async function inspectOci(computer: ChannelComputer): Promise { - const result = await oci(["inspect", computer.machine_id, ownerMarker(computer)], { timeoutMs: 30_000 }); + // Cold WSL start + helper sync routinely exceeds 30s on Windows; use a longer floor. + const inspectTimeout = platform() === "win32" ? 120_000 : 30_000; + const result = await oci(["inspect", computer.machine_id, ownerMarker(computer)], { timeoutMs: inspectTimeout }); if (result.code !== 0) { const detail = Buffer.concat([result.stderr, result.stdout]).toString("utf8").trim(); if (/does not exist/i.test(detail)) return null; @@ -556,7 +755,8 @@ async function ensureOciProvisioned(computer: ChannelComputer): Promise { inspection = await inspectOci(computer); } recordObserved(computer, inspection); - for (const directory of ["notes", "whiteboards", "code", "docs", "presentations"]) mkdirSync(join(hostWorkspace(computer.channel_id), directory), { recursive: true }); + // Never mkdir through \\wsl.localhost on Windows — that is the post-provision failure mode. + ensureOciChannelWorkspaceDirs(computer.channel_id); run("DELETE FROM channel_workspace_changes WHERE channel_id=?", computer.channel_id); run("UPDATE channel_computers SET provision_status='ready',desired_state='auto',synced_host_revision=host_revision,last_update=?,last_update_attempt=?,last_error='',updated=? WHERE channel_id=?", now(), now(), now(), computer.channel_id); } @@ -1329,6 +1529,211 @@ export async function shutdownChannelComputers(): Promise { await Promise.all([...channelLocks.values()].map((pending) => pending.catch(() => undefined))); } +export type ChannelComputerPrepareStatus = { + status: "idle" | "running" | "complete" | "failed"; + step: string; + progress: number; + error: string; + image: string; + started_at: number; + updated_at: number; +}; + +export type WindowsWslSetupStatus = { + status: "idle" | "running" | "complete" | "failed" | "restart_required"; + step: string; + progress: number; + error: string; + log: string; + started_at: number; + updated_at: number; +}; + +let ociPrepareState: ChannelComputerPrepareStatus = { + status: "idle", + step: "Private channel computers have not been prepared yet.", + progress: 0, + error: "", + image: DEFAULT_CHANNEL_IMAGE, + started_at: 0, + updated_at: 0, +}; +let ociPreparePass: Promise | null = null; + +let windowsWslSetupState: WindowsWslSetupStatus = { + status: "idle", + step: "Shared Windows WSL runtime setup has not started.", + progress: 0, + error: "", + log: "", + started_at: 0, + updated_at: 0, +}; +let windowsWslSetupChild: ChildProcess | null = null; +let windowsWslSetupLogPath = ""; +let windowsWslSetupStatusPath = ""; + +function setWindowsWslSetupState(patch: Partial): WindowsWslSetupStatus { + windowsWslSetupState = { + ...windowsWslSetupState, + ...patch, + updated_at: now(), + }; + return windowsWslSetupState; +} + +export function windowsWslSetupStatus(): WindowsWslSetupStatus { + if (windowsWslSetupStatusPath && existsSync(windowsWslSetupStatusPath)) { + try { + const parsed = JSON.parse(readFileSync(windowsWslSetupStatusPath, "utf8")) as { + status?: string; step?: string; progress?: number; error?: string; + }; + if (windowsWslSetupState.status === "running" && parsed.step) { + const status = parsed.status === "restart_required" || parsed.status === "failed" || parsed.status === "complete" + ? parsed.status as WindowsWslSetupStatus["status"] + : "running"; + setWindowsWslSetupState({ + status: windowsWslSetupChild ? "running" : status, + step: String(parsed.step), + progress: Math.max(0, Math.min(100, Number(parsed.progress) || windowsWslSetupState.progress)), + error: String(parsed.error || windowsWslSetupState.error || ""), + }); + } + } catch { /* status file is best-effort */ } + } + if (windowsWslSetupLogPath && existsSync(windowsWslSetupLogPath)) { + try { + const log = readFileSync(windowsWslSetupLogPath, "utf8").slice(-8_000); + if (log !== windowsWslSetupState.log) setWindowsWslSetupState({ log }); + } catch { /* log is best-effort */ } + } + return { ...windowsWslSetupState }; +} + +function setOciPrepareState(patch: Partial): ChannelComputerPrepareStatus { + ociPrepareState = { + ...ociPrepareState, + ...patch, + image: DEFAULT_CHANNEL_IMAGE, + updated_at: now(), + }; + return ociPrepareState; +} + +export function channelComputerPrepareStatus(): ChannelComputerPrepareStatus { + return { ...ociPrepareState, image: DEFAULT_CHANNEL_IMAGE }; +} + +async function ociChannelImageExists(image = DEFAULT_CHANNEL_IMAGE): Promise { + const result = await oci(["image-status", image], { timeoutMs: 30_000 }); + if (result.code !== 0) return false; + try { + const parsed = JSON.parse(result.stdout.toString("utf8")) as { exists?: unknown }; + return Boolean(parsed.exists); + } catch { + return false; + } +} + +function ociChannelImageExistsSync(image = DEFAULT_CHANNEL_IMAGE): boolean { + try { + const invocation = ociInvocation(["image-status", image]); + if (platform() === "win32") { + const result = spawnSync(invocation.command, invocation.args, { encoding: "buffer", timeout: 30_000, env: invocation.env }); + if (result.status !== 0) return false; + const parsed = JSON.parse(windowsLines(result.stdout as Buffer).join("") || "{}") as { exists?: unknown }; + return Boolean(parsed.exists); + } + const result = spawnSync(invocation.command, invocation.args, { encoding: "utf8", timeout: 30_000, env: invocation.env }); + if (result.status !== 0) return false; + const parsed = JSON.parse(String(result.stdout || "{}")) as { exists?: unknown }; + return Boolean(parsed.exists); + } catch { + return false; + } +} + +/** + * One-time host prepare: materialize the shared channel-computer image before + * the Captain creates channels. Later create_channel calls only clone/start. + */ +export function beginOciChannelComputerPrepare(): ChannelComputerPrepareStatus { + if (configuredChannelBackend() !== "oci") { + return setOciPrepareState({ + status: "failed", + step: "Channel-computer image prepare is only used on the OCI backend.", + progress: 0, + error: "Channel-computer image prepare is only used on the OCI backend.", + started_at: now(), + }); + } + if (ociPrepareState.status === "running" && ociPreparePass) return channelComputerPrepareStatus(); + if (ociChannelImageExistsSync()) { + return setOciPrepareState({ + status: "complete", + step: "Channel computer image is ready. New channels can start immediately.", + progress: 100, + error: "", + started_at: ociPrepareState.started_at || now(), + }); + } + const startedAt = now(); + setOciPrepareState({ + status: "running", + step: "Verifying the OCI runtime…", + progress: 5, + error: "", + started_at: startedAt, + }); + const passToken = { current: null as Promise | null }; + const pass = (async () => { + try { + const engine = runtimeReadiness(); + if (!engine.engine_ready) { + throw new Error(String(engine.error || "The OCI runtime is not ready. Finish the host installer or Windows shared-runtime setup first.")); + } + setOciPrepareState({ + status: "running", + step: "Loading the sealed channel computer image. This one-time step does not use live package mirrors…", + progress: 20, + error: "", + }); + const built = await oci(["image", DEFAULT_CHANNEL_IMAGE], { timeoutMs: 30 * 60_000 }); + if (built.code !== 0) { + throw new Error(built.stderr.toString("utf8").trim() || built.stdout.toString("utf8").trim() || "Channel computer image preparation failed."); + } + setOciPrepareState({ + status: "running", + step: "Verifying the sealed channel computer image…", + progress: 90, + error: "", + }); + if (!(await ociChannelImageExists(DEFAULT_CHANNEL_IMAGE))) { + throw new Error("The channel computer image was not present after preparation completed."); + } + setOciPrepareState({ + status: "complete", + step: "Channel computer image is ready. New channels can start immediately.", + progress: 100, + error: "", + }); + } catch (error) { + setOciPrepareState({ + status: "failed", + step: "Private computer setup failed.", + progress: Math.max(5, Number(ociPrepareState.progress) || 5), + error: ((error as Error).message || "Private computer setup failed.").slice(0, 1000), + }); + } finally { + if (ociPreparePass === passToken.current) ociPreparePass = null; + } + })(); + passToken.current = pass; + ociPreparePass = pass; + void pass; + return channelComputerPrepareStatus(); +} + export function runtimeReadiness(): Record { const backend = configuredChannelBackend(); const darwin = platform() === "darwin"; @@ -1338,7 +1743,8 @@ export function runtimeReadiness(): Record { const supportedArchitecture = arm64 || process.arch === "x64"; if (backend === "native" || backend === "mock") { return { - backend, supported: true, ready: true, platform: platform(), architecture: process.arch, + backend, supported: true, ready: true, engine_ready: true, image_ready: true, + image: null, prepare: null, platform: platform(), architecture: process.arch, development_only: true, runtime_version: null, status: "development", }; } @@ -1347,19 +1753,43 @@ export function runtimeReadiness(): Record { try { if (platform() === "win32") { helper = `${installationScopedRuntimeName()}:/usr/libexec/1helm-oci-runtime`; + // Cold WSL start after setup routinely exceeds 15–30s. Wake first, then use long floors. + try { + spawnSync(resolveWslCli(), ["--distribution", installationScopedRuntimeName(), "--user", "root", "--exec", "/bin/true"], { + encoding: "buffer", timeout: 180_000, windowsHide: true, + }); + } catch { /* version/ready errors surface below */ } const invocation = ociInvocation(["version"]); - const versionResult = spawnSync(invocation.command, invocation.args, { encoding: "buffer", timeout: 15_000, env: invocation.env }); - version = versionResult.status === 0 ? windowsLines(versionResult.stdout as Buffer).join("").trim() : ""; + const versionResult = spawnSync(invocation.command, invocation.args, { encoding: "buffer", timeout: 120_000, env: invocation.env, windowsHide: true }); + const versionLines = versionResult.status === 0 ? windowsLines(versionResult.stdout as Buffer) : []; + version = (versionLines[0] || "").trim(); + if (versionResult.status !== 0 && !version) { + error = windowsLines(Buffer.concat([ + Buffer.isBuffer(versionResult.stderr) ? versionResult.stderr : Buffer.alloc(0), + Buffer.isBuffer(versionResult.stdout) ? versionResult.stdout : Buffer.alloc(0), + ])).join(" ") || `WSL OCI runtime version check failed (exit ${versionResult.status ?? "timeout"}).`; + } const readyInvocation = ociInvocation(["ready"]); - const readyResult = spawnSync(readyInvocation.command, readyInvocation.args, { encoding: "buffer", timeout: 30_000, env: readyInvocation.env }); + const readyResult = spawnSync(readyInvocation.command, readyInvocation.args, { encoding: "buffer", timeout: 180_000, env: readyInvocation.env, windowsHide: true }); if (readyResult.status === 0) { - try { system = JSON.parse(Buffer.from(readyResult.stdout as Buffer).toString("utf8")); } catch { system = windowsLines(readyResult.stdout as Buffer); } - } else error = windowsLines(Buffer.concat([readyResult.stderr as Buffer || Buffer.alloc(0), readyResult.stdout as Buffer || Buffer.alloc(0)])).join(" ") || "OCI runtime readiness check failed."; + const readyText = windowsLines(readyResult.stdout as Buffer).join("\n").trim(); + try { system = JSON.parse(readyText || "{}"); } + catch { + try { system = JSON.parse(Buffer.from(readyResult.stdout as Buffer).toString("utf8").replaceAll("\0", "").trim() || "{}"); } + catch { system = readyText || windowsLines(readyResult.stdout as Buffer); } + } + } else { + const readyErr = windowsLines(Buffer.concat([ + Buffer.isBuffer(readyResult.stderr) ? readyResult.stderr : Buffer.alloc(0), + Buffer.isBuffer(readyResult.stdout) ? readyResult.stdout : Buffer.alloc(0), + ])).join(" ") || `OCI runtime readiness check failed (exit ${readyResult.status ?? "timeout"}).`; + error = error ? `${error} ${readyErr}` : readyErr; + } } else { const versionInvocation = ociInvocation(["version"]); helper = versionInvocation.command; const versionResult = spawnSync(versionInvocation.command, versionInvocation.args, { encoding: "utf8", timeout: 15_000, env: versionInvocation.env }); - version = versionResult.status === 0 ? String(versionResult.stdout || "").trim() : ""; + version = versionResult.status === 0 ? String(versionResult.stdout || "").trim().split(/\r?\n/)[0] || "" : ""; const readyInvocation = ociInvocation(["ready"]); const readyResult = spawnSync(readyInvocation.command, readyInvocation.args, { encoding: "utf8", timeout: 30_000, env: readyInvocation.env }); if (readyResult.status === 0) { @@ -1368,12 +1798,46 @@ export function runtimeReadiness(): Record { } } catch (failure) { error = (failure as Error).message; } const supported = (linux || windows) && supportedArchitecture; + const engineReady = Boolean(supported && helper && version === OCI_RUNTIME_VERSION && system && !error); + // While a prepare pass is running, avoid re-probing podman on every UI poll. + // Otherwise always trust the runtime helper's image-status, not a stale flag. + let imageReady = false; + if (engineReady && ociPrepareState.status !== "running") { + imageReady = ociChannelImageExistsSync(); + if (imageReady && ociPrepareState.status !== "complete") { + setOciPrepareState({ + status: "complete", + step: "Channel computer image is ready. New channels can start immediately.", + progress: 100, + error: "", + started_at: ociPrepareState.started_at || now(), + }); + } else if (!imageReady && ociPrepareState.status === "complete") { + setOciPrepareState({ + status: "idle", + step: "Private channel computers have not been prepared yet.", + progress: 0, + error: "", + started_at: 0, + }); + } + } + const windowsSetup = windows ? windowsWslSetupStatus() : null; return { - backend, supported, ready: Boolean(supported && helper && version === OCI_RUNTIME_VERSION && system && !error), + backend, supported, + engine_ready: engineReady, + image_ready: imageReady, + image: DEFAULT_CHANNEL_IMAGE, + prepare: channelComputerPrepareStatus(), + windows_setup: windowsSetup, + ready: Boolean(engineReady && imageReady), platform: platform(), architecture: process.arch, cli: helper || null, version: version || null, system, runtime_version: OCI_RUNTIME_VERSION, shared_runtime: windows ? installationScopedRuntimeName() : null, storage_authority: windows ? `\\\\wsl.localhost\\${installationScopedRuntimeName()}\\var\\lib\\1helm-oci-v1\\runtime\\oci` : ociHostStateRoot(), - status: error ? "error" : system ? "running" : "missing", error: error || null, + status: error ? "error" : !engineReady ? (system ? "running" : "missing") : imageReady ? "running" : "image_pending", + error: error + || (windowsSetup && (windowsSetup.status === "failed" || windowsSetup.status === "restart_required") ? windowsSetup.error || windowsSetup.step : null) + || (ociPrepareState.status === "failed" ? ociPrepareState.error : null), }; } let cli = ""; @@ -1401,11 +1865,13 @@ export function runtimeReadiness(): Record { && systemStatus === "running"; const macosVersion = darwin ? spawnSync("/usr/bin/sw_vers", ["-productVersion"], { encoding: "utf8" }).stdout?.trim() || "" : ""; const supportedMac = darwin && arm64 && Number(macosVersion.split(".")[0] || 0) >= 26; + const ready = Boolean(supportedMac && cli && exactRuntime); return { backend, supported: supportedMac, darwin, arm64, platform: platform(), architecture: process.arch, macos_version: macosVersion || null, cli: cli || null, version, system, runtime_version: APPLE_RUNTIME_VERSION, installer_url: APPLE_RUNTIME_URL, installer_sha256: APPLE_RUNTIME_SHA256, status: exactRuntime ? "running" : cli ? "stopped" : "missing", error: null, - ready: Boolean(supportedMac && cli && exactRuntime), + engine_ready: ready, image_ready: ready, image: DEFAULT_CHANNEL_IMAGE, prepare: null, + ready, }; } @@ -1447,20 +1913,156 @@ export async function prepareAppleRuntimeInstaller(): Promise<{ path: string; sh return { path: destination, sha256: digest, opened }; } -/** Open Microsoft's one-time elevated WSL 2 feature setup on Windows. */ -export async function prepareWindowsWslRuntime(): Promise<{ opened: boolean }> { +/** + * Start (or reattach to) the one-time shared WSL/OCI runtime install on Windows. + * The PowerShell script elevates only for host features/MSI; the signed-in user + * owns the distribution import. Progress is tracked for the onboarding UI. + */ +export async function prepareWindowsWslRuntime(): Promise<{ opened: boolean; setup: WindowsWslSetupStatus }> { if (platform() !== "win32") throw new Error("WSL 2 setup is available only on Windows."); + if (windowsWslSetupChild && windowsWslSetupState.status === "running") { + return { opened: true, setup: windowsWslSetupStatus() }; + } + const current = runtimeReadiness(); + if (current.engine_ready) { + return { + opened: false, + setup: setWindowsWslSetupState({ + status: "complete", + step: "The shared Windows OCI runtime is already ready.", + progress: 100, + error: "", + started_at: windowsWslSetupState.started_at || now(), + }), + }; + } const script = join(process.env.HELM_APP_ROOT || process.cwd(), "scripts", "install-wsl-runtime.ps1"); if (!existsSync(script)) throw new Error("1Helm's signed WSL 2 setup script is missing."); - const escaped = script.replaceAll("'", "''"); - const runtime = installationScopedRuntimeName().replaceAll("'", "''"); - const appRoot = (process.env.HELM_APP_ROOT || process.cwd()).replaceAll("'", "''"); - const launched = spawnSync("powershell.exe", [ - "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", - `Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','${escaped}','-RuntimeName','${runtime}','-AppRoot','${appRoot}')`, - ], { encoding: "utf8", timeout: 30_000, windowsHide: true }); - if (launched.status !== 0) throw new Error(String(launched.stderr || launched.stdout || "Windows did not open WSL 2 administrator setup.").trim()); - return { opened: true }; + const runtime = installationScopedRuntimeName(); + const appRoot = process.env.HELM_APP_ROOT || process.cwd(); + const runtimeRoot = join(String(process.env.LOCALAPPDATA || DATA_DIR), "1Helm-Runtime"); + mkdirSync(runtimeRoot, { recursive: true }); + mkdirSync(DATA_DIR, { recursive: true }); + windowsWslSetupStatusPath = join(runtimeRoot, "setup-status.json"); + windowsWslSetupLogPath = join(DATA_DIR, "windows-wsl-setup.log"); + try { + writeFileSync(windowsWslSetupStatusPath, `${JSON.stringify({ + status: "running", + step: "Starting Windows shared-runtime setup...", + progress: 3, + error: "", + updated: new Date().toISOString(), + })}\n`, "utf8"); + writeFileSync(windowsWslSetupLogPath, "", "utf8"); + } catch { /* best-effort seed */ } + + setWindowsWslSetupState({ + status: "running", + step: "Starting Windows shared-runtime setup... Approve the administrator prompt when Windows asks.", + progress: 3, + error: "", + log: "", + started_at: now(), + }); + + const child = spawn("powershell.exe", [ + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", script, + "-RuntimeName", runtime, + "-AppRoot", appRoot, + ], { + env: { + ...process.env, + HELM_WSL_SETUP_STATUS: windowsWslSetupStatusPath, + }, + windowsHide: false, + stdio: ["ignore", "pipe", "pipe"], + }); + windowsWslSetupChild = child; + + const appendLog = (chunk: Buffer | string): void => { + const text = String(chunk); + if (!text) return; + try { writeFileSync(windowsWslSetupLogPath, text, { flag: "a", encoding: "utf8" }); } catch { /* ignore */ } + const combined = `${windowsWslSetupState.log}${text}`.slice(-8_000); + const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + let step = windowsWslSetupState.step; + let progress = windowsWslSetupState.progress; + for (const line of lines) { + if (/^ERROR:/i.test(line)) continue; + step = line.replace(/^ERROR:\s*/i, "").slice(0, 400); + if (/administrator approval/i.test(line)) progress = Math.max(progress, 5); + else if (/Enabling Windows WSL/i.test(line)) progress = Math.max(progress, 8); + else if (/Downloading Microsoft WSL/i.test(line)) progress = Math.max(progress, 12); + else if (/Installing Microsoft WSL/i.test(line)) progress = Math.max(progress, 18); + else if (/default/i.test(line) && /WSL/i.test(line)) progress = Math.max(progress, 22); + else if (/Checking for the shared/i.test(line)) progress = Math.max(progress, 28); + else if (/Downloading shared Linux/i.test(line)) progress = Math.max(progress, 35); + else if (/Importing shared/i.test(line)) progress = Math.max(progress, 48); + else if (/packages|podman/i.test(line)) progress = Math.max(progress, 58); + else if (/sealed OCI|channel image/i.test(line)) progress = Math.max(progress, 78); + else if (/Restarting the shared/i.test(line)) progress = Math.max(progress, 88); + else if (/Verifying the shared/i.test(line)) progress = Math.max(progress, 94); + else if (/installed and ready/i.test(line)) progress = 100; + } + setWindowsWslSetupState({ log: combined, step, progress }); + }; + child.stdout?.on("data", appendLog); + child.stderr?.on("data", appendLog); + child.on("error", (error) => { + windowsWslSetupChild = null; + setWindowsWslSetupState({ + status: "failed", + step: "Windows could not start the shared-runtime setup process.", + progress: Math.max(3, windowsWslSetupState.progress), + error: error.message, + }); + }); + child.on("close", (code, signal) => { + windowsWslSetupChild = null; + const setup = windowsWslSetupStatus(); + if (code === 10 || setup.status === "restart_required") { + setWindowsWslSetupState({ + status: "restart_required", + step: "WSL 2 features are enabled. Restart Windows once, reopen 1Helm, then create the workspace again.", + progress: Math.max(20, setup.progress), + error: "Windows restart required to finish enabling WSL 2.", + }); + return; + } + if (code === 0) { + const readiness = runtimeReadiness(); + if (readiness.engine_ready) { + setWindowsWslSetupState({ + status: "complete", + step: "1Helm's shared OCI runtime is installed and ready.", + progress: 100, + error: "", + }); + return; + } + setWindowsWslSetupState({ + status: "failed", + step: "Setup finished but the shared OCI runtime is still not ready.", + progress: Math.max(90, setup.progress), + error: String(readiness.error || "The shared WSL OCI runtime did not pass readiness after setup."), + }); + return; + } + const logTail = setup.log.trim().split(/\r?\n/).filter(Boolean).slice(-8).join(" "); + const detail = setup.error || logTail || (signal ? `terminated by ${signal}` : `exit code ${code ?? "unknown"}`); + setWindowsWslSetupState({ + status: "failed", + step: setup.step && setup.step !== "Starting Windows shared-runtime setup... Approve the administrator prompt when Windows asks." + ? setup.step + : "Shared Windows runtime setup failed.", + progress: Math.max(5, setup.progress), + error: detail.slice(0, 1000), + }); + }); + + return { opened: true, setup: windowsWslSetupStatus() }; } /** Complete runtime activation after the signed package receives approval. */ diff --git a/src/server/collaboration.ts b/src/server/collaboration.ts index d0dec34..86b8101 100644 --- a/src/server/collaboration.ts +++ b/src/server/collaboration.ts @@ -65,7 +65,10 @@ export async function claimWorkspace(slug: string, workspaceName: string, port: }), }); if (result.connector) saveTunnelConnector(CONNECTOR_ID, result.connector, [result.workspace.hostname], port); - if (!connectorConfigured(CONNECTOR_ID)) throw new Error("The workspace address exists, but this Mac does not have its connector credential. Contact 1Helm support to rotate it."); + if (!connectorConfigured(CONNECTOR_ID)) throw new Error("The workspace address exists, but this host does not have its connector credential. Contact 1Helm support to rotate it."); + if (!connectorAvailable()) { + throw new Error("This 1Helm build is missing the Cloudflare tunnel connector (cloudflared). Reinstall a complete desktop package, then re-enable collaboration."); + } run(`UPDATE workspace SET collaboration_enabled=1,collaboration_slug=?,collaboration_hostname=?, collaboration_status='active',collaboration_error='' WHERE id=1`, result.workspace.slug, result.workspace.hostname); startTunnelConnector(CONNECTOR_ID); @@ -86,6 +89,9 @@ export async function setCollaborationEnabled(enabled: boolean): Promise join(directory, "cloudflared")); + const resources = process.env.HELM_RESOURCES_PATH || ""; + const appRoot = process.env.HELM_APP_ROOT || ""; + const pathSep = process.platform === "win32" ? ";" : ":"; + const pathNames = process.platform === "win32" ? ["cloudflared.exe", "cloudflared"] : ["cloudflared"]; + const pathCandidates = String(process.env.PATH || "").split(pathSep).filter(Boolean).flatMap((directory) => pathNames.map((name) => join(directory, name))); const candidates = [ process.env.CLOUDFLARED_BIN || "", - process.env.HELM_RESOURCES_PATH ? join(process.env.HELM_RESOURCES_PATH, "cloudflared") : "", + // Packaged desktop apps (macOS Resources/cloudflared, Windows resources/cloudflared.exe). + resources ? join(resources, "cloudflared.exe") : "", + resources ? join(resources, "cloudflared") : "", + appRoot ? join(appRoot, "cloudflared.exe") : "", + appRoot ? join(appRoot, "cloudflared") : "", "/opt/homebrew/bin/cloudflared", "/usr/local/bin/cloudflared", "/usr/bin/cloudflared", ...pathCandidates, ].filter(Boolean); - return candidates.find(existsSync) || "cloudflared"; + return candidates.find(existsSync) || (process.platform === "win32" ? "cloudflared.exe" : "cloudflared"); } const safeId = (value: string): string => value.replace(/[^a-zA-Z0-9_.-]/g, "-").slice(0, 100); @@ -32,7 +40,8 @@ const connectorDir = (id: string): string => join(ROOT, safeId(id)); export function connectorAvailable(): boolean { const binary = connectorBinary(); - return binary !== "cloudflared" && existsSync(binary); + if (binary === "cloudflared" || binary === "cloudflared.exe") return false; + return existsSync(binary); } export function saveTunnelConnector(id: string, credential: TunnelCredential, hostnames: string[], port: number): void { @@ -90,6 +99,7 @@ export function startTunnelConnector(id: string): void { const child = spawn(connectorBinary(), ["--no-autoupdate", "--config", config, "tunnel", "run"], { stdio: ["ignore", "ignore", "pipe"], env: process.env, + windowsHide: true, }); let stderr = ""; let settled = false; diff --git a/src/server/db.ts b/src/server/db.ts index 8df960d..2f43e26 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -939,7 +939,7 @@ export function migrate(): void { const platformBackend = process.platform === "darwin" ? "apple" : "oci"; const configuredBackend = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || platformBackend); const backend = ["apple", "oci", "native", "mock"].includes(configuredBackend) ? configuredBackend : platformBackend; - const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.29"); + const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.30"); for (const channel of q(`SELECT c.id FROM channels c JOIN agent_channels ac ON ac.channel_id=c.id WHERE c.kind='channel' AND c.status<>'deleted'`)) { const channelId = Number(channel.id); diff --git a/src/server/index.ts b/src/server/index.ts index c9ac566..232678f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -98,11 +98,14 @@ import { stopRoutingEngine, } from "./routing.ts"; import { + beginOciChannelComputerPrepare, + channelComputerPrepareStatus, channelComputerView, runtimeReadiness, refreshChannelWorkspaceMirror, prepareAppleRuntimeInstaller, prepareWindowsWslRuntime, + windowsWslSetupStatus, startAppleRuntime, wakeDueChannelComputers, shutdownChannelComputers, @@ -167,6 +170,35 @@ const SECURITY_HEADERS: Record = { // Keep camera and location denied, but allow this origin to request the mic. "permissions-policy": "camera=(), microphone=(self), geolocation=(), unload=(self)", }; +/** Cowork renderers accept exact file contracts. When a Cowork request targets + * the presentations or whiteboards world, tell the agent the format up front + * so it produces files the tab can actually open. */ +function coworkFormatContract(path: string, folderContext: boolean): string { + const inPresentations = folderContext ? (path === "presentations" || path.startsWith("presentations/")) : path.startsWith("presentations/"); + const inWhiteboards = folderContext ? (path === "whiteboards" || path.startsWith("whiteboards/")) : path.startsWith("whiteboards/"); + if (inPresentations || (!folderContext && path.endsWith(".slides.json"))) { + return [ + "Format contract for /workspace/presentations — follow it exactly:", + "- Create exactly one file per deck, named `.slides.json`. No .html decks, no ad-hoc schemas, no local web servers, no extra files.", + "- The Presentations tab renders 1Helm's Excalidraw-based deck format:", + ' `{ "type": "1helm-slides", "version": 3, "slides": [{ "name": "Slide title", "scene": { "elements": [...], "appState": {}, "files": {} } }] }`', + ' where `elements` are Excalidraw elements (type "text", "rectangle", "ellipse", "arrow", …) positioned on a 1500×1000 canvas.', + '- Simpler and recommended unless you know Excalidraw JSON: `{ "theme": { "primary": "#hex", "background": "#hex", "text": "#hex", "accent": "#hex" }, "slides": [{ "title": "Slide title", "body": "Slide body text" }] }` — 1Helm lays these out with the theme colors, sized text, and bullets (lines starting with "- " render as bullet rows). Supply a theme that fits the subject.', + "- Verify your JSON parses before finishing.", + ].join("\n"); + } + if (inWhiteboards || (!folderContext && path.endsWith(".whiteboard.json"))) { + return [ + "Format contract for /workspace/whiteboards — follow it exactly:", + "- Create exactly one file per board, named `.whiteboard.json`. No .html, no ad-hoc schemas.", + '- The Whiteboard tab renders an Excalidraw scene: `{ "type": "excalidraw", "version": 2, "elements": [...], "appState": {}, "files": {} }`', + ' where `elements` are Excalidraw elements (type "text", "rectangle", "ellipse", "arrow", "line", "freedraw", …).', + "- Verify your JSON parses before finishing.", + ].join("\n"); + } + return ""; +} + const json = (res: ServerResponse, code: number, body: unknown): void => { const s = JSON.stringify(body); res.writeHead(code, { "content-type": "application/json", ...SECURITY_HEADERS }); @@ -377,7 +409,7 @@ function visibleChannels(user: Row): Row[] { } /** Fire the bound resident or workspace-wide Skipper mentioned in a message. */ -function triggerBots(channelId: number, msg: Row, authorId: number): void { +function triggerBots(channelId: number, msg: Row, authorId: number, hiddenContext?: string): void { if (["collab", "human"].includes(String(q1("SELECT kind FROM channels WHERE id=?", channelId)?.kind || ""))) return; const fresh = msg.parent_id == null; const threadRootId = Number(msg.parent_id ?? msg.id); @@ -385,7 +417,7 @@ function triggerBots(channelId: number, msg: Row, authorId: number): void { if (!mentioned.length && msg.parent_id != null) { const participant = conversationalAgent(channelId, threadRootId, Number(msg.id)); if (participant?.automatic) { - void launchBot(participant.bot, channelId, msg, authorId, threadRootId, false); + void launchBot(participant.bot, channelId, msg, authorId, threadRootId, false, hiddenContext); return; } if (participant && !q1("SELECT muted FROM thread_mention_preferences WHERE thread_id=? AND user_id=? AND muted=1", participant.threadId, authorId)) { @@ -400,7 +432,7 @@ function triggerBots(channelId: number, msg: Row, authorId: number): void { // request identify collaborators/context; they must not launch competing // agent turns and duplicate the work. const skipper = mentioned.find((bot) => agentForBot(Number(bot.id))?.kind === "skipper"); - for (const bot of skipper ? [skipper] : mentioned) void launchBot(bot, channelId, msg, authorId, threadRootId, fresh); + for (const bot of skipper ? [skipper] : mentioned) void launchBot(bot, channelId, msg, authorId, threadRootId, fresh, hiddenContext); } function offerHumanMemberships(channelId: number, msg: Row, author: Row): void { @@ -423,7 +455,7 @@ function offerHumanMemberships(channelId: number, msg: Row, author: Row): void { } } -function launchBot(bot: Row, channelId: number, msg: Row, authorId: number, threadRootId: number, fresh: boolean): void { +function launchBot(bot: Row, channelId: number, msg: Row, authorId: number, threadRootId: number, fresh: boolean, hiddenContext?: string): void { const agent = agentForBot(Number(bot.id)); if (agent?.kind === "skipper") { const threadId = threadIdForRoot(threadRootId, channelId) ?? ensureThread(threadRootId, channelId); @@ -432,10 +464,10 @@ function launchBot(bot: Row, channelId: number, msg: Row, authorId: number, thre run("INSERT INTO channel_activity (channel_id,thread_id,kind,summary,status,actor_type,created) VALUES (?,?,'escalation',?,'open','human',?)", channelId, threadId, String(msg.body).slice(0, 500), now()); broadcastToChannel(channelId, { type: "escalation", channelId, escalation: { id: escalationId, thread_id: threadId, reason: msg.body, status: "open" } }); const hostAuthorized = Boolean(q1("SELECT is_admin FROM users WHERE id=?", authorId)?.is_admin); - void runBot(bot, channelId, Number(msg.id), threadRootId, fresh, escalationId, hostAuthorized); + void runBot(bot, channelId, Number(msg.id), threadRootId, fresh, escalationId, hostAuthorized, hiddenContext); } else if (agent?.kind === "channel") { - if (Number(agent.channel_id) === channelId) void runBot(bot, channelId, Number(msg.id), threadRootId, fresh); - } else if (botIsInChannel(Number(bot.id), channelId)) void runBot(bot, channelId, Number(msg.id), threadRootId, fresh); + if (Number(agent.channel_id) === channelId) void runBot(bot, channelId, Number(msg.id), threadRootId, fresh, undefined, undefined, hiddenContext); + } else if (botIsInChannel(Number(bot.id), channelId)) void runBot(bot, channelId, Number(msg.id), threadRootId, fresh, undefined, undefined, hiddenContext); else sendToUsers([authorId], { type: "bot_prompt", botId: bot.id, botName: bot.name, channelId, triggerId: msg.id, threadRootId, fresh }); } @@ -494,6 +526,7 @@ function postMessage( uploads: { token: string; name: string; mime: string; size: number }[], modelPolicy?: { provider_id?: number | null; model?: string | null }, submittedPolicy?: { provider_id?: number | null; model?: string; source?: string }, + hiddenContext?: string, ): Row { const channel = q1("SELECT status,kind FROM channels WHERE id=?", channelId); if (!channel) throw new Error("Channel not found."); @@ -546,7 +579,7 @@ function postMessage( const msg = serializeMessage(id)!; broadcastToChannel(channelId, { type: "message", message: msg, parent: parentId ? serializeMessage(parentId) : null }); offerHumanMemberships(channelId, msg, user); - triggerBots(channelId, msg, Number(user.id)); + triggerBots(channelId, msg, Number(user.id), hiddenContext); return msg; } @@ -1681,6 +1714,7 @@ const server = createServer(async (req, res) => { if (b.modelPolicy && !user.is_admin) return json(res, 403, { error: "Only the Captain can choose a thread model." }); try { let body = String(b.body || ""); + let hiddenContext = ""; if (b.coworkPath) { const folderContext = b.coworkKind === "folder"; const path = folderContext ? normalizeCoworkFolderPath(String(b.coworkPath)) : normalizeCoworkPath(String(b.coworkPath)); @@ -1689,8 +1723,13 @@ const server = createServer(async (req, res) => { const suffix = b.parentId ? [] : [`Working ${folderContext ? "folder" : "file"}: /workspace/${path}`]; if (viewers.length) suffix.push(`Working with: ${viewers.map((username) => `@${username}`).join(" ")}`); if (suffix.length) body = `${body.trim()}\n\n${suffix.join("\n")}`; + // The format contract is agent-only context — never shown in chat. + // Injected as a hidden system note so it shapes the turn without + // cluttering the visible thread. + const contract = coworkFormatContract(path, folderContext); + if (contract && !b.parentId) hiddenContext = contract; } - return json(res, 200, { message: postMessage(cid, user, body, b.parentId ? Number(b.parentId) : null, (b.uploads as never[]) || [], b.modelPolicy as never, b.effectiveModelPolicy as never) }); + return json(res, 200, { message: postMessage(cid, user, body, b.parentId ? Number(b.parentId) : null, (b.uploads as never[]) || [], b.modelPolicy as never, b.effectiveModelPolicy as never, hiddenContext) }); } catch (error) { return json(res, 409, { error: (error as Error).message }); } } @@ -2031,7 +2070,11 @@ const server = createServer(async (req, res) => { const runtime = runtimeReadiness(); if (runtime.backend === "oci" && platform() === "win32") { const installer = await prepareWindowsWslRuntime(); - return json(res, 200, { ok: true, installer, runtime: runtimeReadiness() }); + return json(res, 200, { + ok: true, + installer: { opened: installer.opened, setup: installer.setup || windowsWslSetupStatus() }, + runtime: runtimeReadiness(), + }); } if (runtime.backend !== "apple") return json(res, 409, { error: "The root-owned OCI runtime is installed and verified by the 1Helm Linux host installer." }); const installer = await prepareAppleRuntimeInstaller(); @@ -2041,13 +2084,40 @@ const server = createServer(async (req, res) => { if (!user.is_admin) return json(res, 403, { error: "Captain/admin only" }); const runtime = runtimeReadiness(); if (runtime.backend !== "apple") { - if (!runtime.ready) return json(res, 409, { error: platform() === "win32" - ? "The shared WSL OCI runtime is not ready; complete 1Helm's one-time Windows administrator setup." - : "The OCI runtime is not ready; rerun the verified Linux host installer." }); - return json(res, 200, { ok: true, runtime }); + if (!runtime.engine_ready) { + const detail = String(runtime.error || "").trim(); + return json(res, 409, { error: platform() === "win32" + ? (detail + ? `The shared WSL OCI runtime is not ready: ${detail}` + : "The shared WSL OCI runtime is not ready. Open Create workspace / Set up shared runtime and finish the Windows administrator prompt (or reboot if Windows asked).") + : "The OCI runtime is not ready; rerun the verified Linux host installer." }); + } + // OCI hosts prepare the shared channel image once so first channel create + // is a clone/start, not a cold apt image build. + if (!runtime.image_ready) beginOciChannelComputerPrepare(); + return json(res, 200, { ok: true, runtime: runtimeReadiness(), prepare: channelComputerPrepareStatus() }); } return json(res, 200, { ok: true, runtime: await startAppleRuntime() }); } + if (p === "/api/channel-computers/runtime/prepare" && m === "POST") { + if (!user.is_admin) return json(res, 403, { error: "Captain/admin only" }); + const runtime = runtimeReadiness(); + if (runtime.backend !== "oci") return json(res, 409, { error: "Image preparation is only required for the OCI channel-computer backend." }); + if (!runtime.engine_ready) { + const detail = String(runtime.error || "").trim(); + return json(res, 409, { error: platform() === "win32" + ? (detail + ? `The shared WSL OCI runtime is not ready: ${detail}` + : "The shared WSL OCI runtime is not ready. Finish the Windows shared-runtime setup prompt (reboot if Windows required it), then retry.") + : "The OCI runtime is not ready; rerun the verified 1Helm Linux host installer first." }); + } + const prepare = beginOciChannelComputerPrepare(); + return json(res, 200, { ok: true, prepare, runtime: runtimeReadiness() }); + } + if (p === "/api/channel-computers/runtime/prepare" && m === "GET") { + if (!user.is_admin) return json(res, 403, { error: "Captain/admin only" }); + return json(res, 200, { prepare: channelComputerPrepareStatus(), runtime: runtimeReadiness() }); + } if ((mm = p.match(/^\/api\/channels\/(\d+)\/computer$/)) && m === "GET") { const channelId = Number(mm[1]); if (!canSee(user, channelId)) return json(res, 403, { error: "No access" }); diff --git a/test/channel-computers.mjs b/test/channel-computers.mjs index 71b8ed9..741aef8 100644 --- a/test/channel-computers.mjs +++ b/test/channel-computers.mjs @@ -167,7 +167,7 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive test("runtime digest and packaged image recipe stay pinned", async () => { assert.equal(computers.APPLE_RUNTIME_SHA256, "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"); assert.match(computers.APPLE_RUNTIME_URL, /\/1\.1\.0\/container-1\.1\.0-installer-signed\.pkg$/); - assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.29"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.30"); const packaging = await readFile(join(root, "scripts", "package-mac-dmg.cjs"), "utf8"); assert.match(packaging, /container\(\?:\$\|\\\/\)/, "release packaging includes container/ image assets"); const image = await readFile(join(root, "container", "Containerfile"), "utf8"); diff --git a/test/cowork-browser.mjs b/test/cowork-browser.mjs index 54be6c9..033dc89 100644 --- a/test/cowork-browser.mjs +++ b/test/cowork-browser.mjs @@ -617,21 +617,58 @@ test("Cowork, Files, Quick Note, Markdown, and mobile continuity work as one fil await page.click('[data-sidebar="desktop"] [data-sidebar-collapse-toggle]'); await page.waitForSelector('[data-sidebar="desktop"][data-sidebar-collapsed="false"]'); - // On a phone, scrolling away blurs the composer (allowing the OS keyboard - // to dismiss), settings navigation stays compact, and the visible viewport - // owns the application height without clipping below the screen. + // On a phone, a deliberate upward finger drag blurs the composer (so the OS + // keyboard can dismiss). Programmatic / keyboard-reflow scrolls must not. + // Settings navigation stays compact, and the visible viewport owns the + // application height without clipping below the screen. await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true }); await page.goto(`${base}/c/${channel.slug}/chat`, { waitUntil: "networkidle0" }); await page.waitForSelector('#msgs'); - const mobile = await page.evaluate(() => { + const mobile = await page.evaluate(async () => { const input = document.querySelector('textarea[data-composer-parent="root"]'); const messages = document.getElementById("msgs"); - input.focus(); messages.scrollTop = Math.max(0, messages.scrollHeight - messages.clientHeight); messages.dispatchEvent(new Event("scroll")); - messages.scrollTop = Math.max(1, messages.scrollTop - 160); messages.dispatchEvent(new Event("scroll")); + // Guarantee a scrollable conversation so upward drag can change scrollTop. + if (messages.scrollHeight <= messages.clientHeight + 40) { + const pad = document.createElement("div"); + pad.style.height = "1200px"; + pad.setAttribute("data-test-scroll-pad", ""); + messages.append(pad); + } + input.focus(); + // installMobileViewportBehavior ignores blur during the post-focus settle window. + await new Promise((resolve) => setTimeout(resolve, 750)); + const fireTouch = (type, clientY) => { + const target = messages; + const touchInit = { identifier: 1, target, clientX: 120, clientY, pageX: 120, pageY: clientY, screenX: 120, screenY: clientY, radiusX: 1, radiusY: 1, rotationAngle: 0, force: 1 }; + const touch = typeof Touch === "function" ? new Touch(touchInit) : touchInit; + const list = type === "touchend" || type === "touchcancel" ? [] : [touch]; + target.dispatchEvent(new TouchEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + touches: list, + targetTouches: list, + changedTouches: [touch], + })); + }; + // Finger moves down the screen → content scrolls toward history (touchDraggedUp). + fireTouch("touchstart", 420); + fireTouch("touchmove", 480); + messages.scrollTop = Math.max(0, messages.scrollHeight - messages.clientHeight); + messages.dispatchEvent(new Event("scroll", { bubbles: true })); + messages.scrollTop = Math.max(0, messages.scrollTop - 160); + messages.dispatchEvent(new Event("scroll", { bubbles: true })); + fireTouch("touchend", 480); const shell = document.querySelector(".workspace-shell")?.getBoundingClientRect(); - return { blurred: document.activeElement !== input, shellBottom: shell?.bottom, viewport: innerHeight, cssHeight: getComputedStyle(document.documentElement).getPropertyValue("--app-viewport-height") }; + return { + blurred: document.activeElement !== input, + shellBottom: shell?.bottom, + viewport: innerHeight, + cssHeight: getComputedStyle(document.documentElement).getPropertyValue("--app-viewport-height"), + scrollable: messages.scrollHeight > messages.clientHeight, + }; }); - assert.equal(mobile.blurred, true); + assert.equal(mobile.blurred, true, JSON.stringify(mobile)); assert.ok(Math.abs(mobile.shellBottom - mobile.viewport) <= 1 && mobile.cssHeight.endsWith("px"), JSON.stringify(mobile)); await page.click('[aria-label="Open navigation"]'); await page.waitForSelector('[data-sidebar="mobile"] [aria-label="Open settings"]'); diff --git a/test/desktop.mjs b/test/desktop.mjs index 98f1a09..4162a3e 100644 --- a/test/desktop.mjs +++ b/test/desktop.mjs @@ -154,6 +154,8 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(windowsPackager, /path\.join\(DIST, "RELEASES"\)/, "the GitHub asset keeps Squirrel's required literal RELEASES name"); assert.match(windowsPackager, /path\.join\(DIST, path\.basename\(nupkg\)\)/, "the uploaded package keeps the exact basename referenced by RELEASES"); assert.match(windowsPackager, /signPackagedExecutables\(appDir\)/, "release signing covers nested Windows executables before packaging"); + assert.match(windowsPackager, /cloudflared-windows-amd64\.exe/, "Windows packaging ships the Cloudflare tunnel connector"); + assert.match(windowsPackager, /resources.*cloudflared\.exe|cloudflared\.exe.*resources/, "cloudflared.exe is placed next to the packaged Electron resources root"); assert.match(windowsPackager, /IGNORE_CLIENT_BUILD_MODULES/, "Windows packaging omits client source already compiled into the browser bundle"); assert.match(windowsPackager, /IGNORE_INSTRUCTION_FILES/, "Windows packaging omits dependency maintainer instructions"); assert.match(windowsPackager, /\/AGENTS\\\.md\$/, "Windows packages never include dependency AGENTS.md files"); @@ -182,10 +184,21 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(windowsRuntime, /Get-AuthenticodeSignature/); assert.match(windowsRuntime, /CN=Microsoft Corporation/); assert.match(windowsRuntime, /msiexec\.exe/); - assert.match(windowsRuntime, /--set-default-version 2/); - assert.match(windowsRuntime, /-HostSetup[\s\S]*-Verb RunAs[\s\S]*\$names = @\(& \$wsl --list --quiet/, "only WSL host components cross UAC while the signed-in owner imports the shared distribution"); + assert.match(windowsRuntime, /--set-default-version["'\s,]+2/); + assert.match(windowsRuntime, /-HostSetup[\s\S]*-Verb RunAs[\s\S]*Get-WslDistributionNames/, "only WSL host components cross UAC while the signed-in owner imports the shared distribution"); + assert.match(windowsRuntime, /function Fetch-File[\s\S]*if \(\$HostSetup\)/, "Fetch-File is script-scoped so the signed-in owner path can download the Ubuntu rootfs after elevation"); + assert.match(windowsRuntime, /Write-SetupStatus/, "Windows setup writes machine-readable progress for the app UI"); + assert.match(windowsRuntime, /\$null -eq \$hostProcess -or \$null -eq \$hostProcess\.ExitCode/, "cancelled UAC is reported as setup failure instead of a silent no-op"); + assert.match(windowsRuntime, /Get-WslText|replace \[char\]0/, "WSL UTF-16 NUL output is normalized before version and distro matching"); + assert.match(windowsRuntime, /Test-PinnedWslRuntime/, "host setup verifies the pinned Microsoft WSL build"); + assert.match(windowsRuntime, /StatusPath/, "elevated HostSetup receives the shared status path so real errors reach the app"); + assert.match(windowsRuntime, /1603[\s\S]*Test-PinnedWslRuntime|Test-PinnedWslRuntime[\s\S]*1603/, "MSI 1603 falls back to re-verifying an already-present pinned WSL runtime"); assert.match(windowsRuntime, /\[automount\][\s\S]*enabled=false[\s\S]*\[interop\][\s\S]*enabled=false/, "the shared runtime exposes neither Windows drives nor process interop"); assert.doesNotMatch(windowsRuntime, /--update/); + assert.match(channelComputers, /HELM_WSL_SETUP_STATUS/, "Windows runtime install is tracked through a status file instead of fire-and-forget Start-Process"); + assert.match(channelComputers, /windows_setup/, "runtime readiness exposes Windows shared-runtime setup progress"); + assert.match(channelComputers, /spawn\("powershell\.exe"/, "Windows setup is a tracked child process owned by the app server"); + assert.doesNotMatch(channelComputers, /Start-Process -FilePath 'powershell\.exe' -ArgumentList @\('-NoProfile','-ExecutionPolicy','Bypass','-File'/, "Windows setup no longer fire-and-forgets an untracked PowerShell window"); const helperInstall = await readFile(join(root, "scripts", "ensure-node-pty-helper.cjs"), "utf8"); assert.match(helperInstall, /process\.platform === "darwin"/); assert.match(helperInstall, /chmodSync\(helper, 0o755\)/, "Mac installs restore node-pty's executable spawn helper before terminals open"); diff --git a/test/fake-oci-runtime.mjs b/test/fake-oci-runtime.mjs index e149121..c3fcfd9 100755 --- a/test/fake-oci-runtime.mjs +++ b/test/fake-oci-runtime.mjs @@ -75,7 +75,18 @@ const linkStorage = (name) => { const operation = args[0] || ""; if (operation === "version") { process.stdout.write("1helm-oci-runtime-v1\n"); process.exit(0); } if (operation === "ready") { process.stdout.write('{"ready":true,"version":"1helm-oci-runtime-v1","engine":"fake"}\n'); process.exit(0); } -if (operation === "image") process.exit(0); +if (operation === "image-status") { + const image = args[1] || ""; + const marker = join(root, "images", image.replaceAll("/", "_")); + process.stdout.write(`${JSON.stringify({ exists: existsSync(marker), image })}\n`); + process.exit(0); +} +if (operation === "image") { + const image = args[1] || ""; + mkdirSync(join(root, "images"), { recursive: true }); + writeFileSync(join(root, "images", image.replaceAll("/", "_")), "1\n"); + process.exit(0); +} if (operation === "create") { const [, name, owner, cpus, memoryMb] = args; if (!valid(name, owner)) fail("invalid create arguments"); diff --git a/test/onboarding-browser.mjs b/test/onboarding-browser.mjs index c7dd82a..aaa4289 100644 --- a/test/onboarding-browser.mjs +++ b/test/onboarding-browser.mjs @@ -3,6 +3,7 @@ import { createServer as createNetServer } from "node:net"; import { DatabaseSync } from "node:sqlite"; import { mkdirSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; import puppeteer from "puppeteer"; const root = process.cwd(); @@ -14,6 +15,29 @@ let browser; let pass = 0; let fail = 0; +/** APFS external volumes can race on deep venv trees (ENOTEMPTY) while Python + * site-packages are still settling after mnemosyne bootstrap. Cleanup must not + * fail a green browser suite. */ +async function removeTree(path) { + for (let attempt = 0; attempt < 8; attempt++) { + try { + rmSync(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + return; + } catch (error) { + const code = error && typeof error === "object" ? error.code : ""; + if (!["ENOTEMPTY", "EBUSY", "EPERM", "ENOENT"].includes(String(code)) || attempt === 7) { + if (code === "ENOENT") return; + // Last-resort: leave the temp tree; the suite already passed. + if (attempt === 7) { + console.warn(` warn - could not fully remove ${path}: ${error.message}`); + return; + } + } + await delay(100 * (attempt + 1)); + } + } +} + const freePort = () => new Promise((resolve, reject) => { const probe = createNetServer(); probe.once("error", reject); @@ -48,7 +72,7 @@ const waitForHeading = (page, title) => page.waitForFunction((text) => [...document.querySelectorAll("h2")].some((heading) => heading.textContent?.trim() === text), {}, title); try { - rmSync(runRoot, { recursive: true, force: true }); + await removeTree(runRoot); mkdirSync(runRoot, { recursive: true }); const dataDir = join(runRoot, "workspace"); const mockPort = await freePort(); @@ -204,7 +228,7 @@ try { if (app.exitCode == null) app.kill("SIGKILL"); } if (mock && mock.exitCode == null) mock.kill("SIGTERM"); - rmSync(runRoot, { recursive: true, force: true }); + await removeTree(runRoot); } console.log(`\n${pass} passed, ${fail} failed`); diff --git a/test/site.mjs b/test/site.mjs index 8aa1784..97306e5 100644 --- a/test/site.mjs +++ b/test/site.mjs @@ -184,7 +184,8 @@ test("installer assets are explicit and syntax-valid", () => { assert.match(ociHelper, /exec\)[\s\S]*\(\(\$# >= 6\)\)[\s\S]*missing exec separator/, "the helper accepts the six required argv values for one direct command"); assert.match(ociHelper, /exec --user 0:0[\s\S]*numeric_user="0:0"[\s\S]*numeric_user="\$AGENT_UID:\$AGENT_GID"[\s\S]*--user "\$AGENT_UID:\$AGENT_GID"/, "host-reboot recovery uses pinned numeric container identities instead of race-prone passwd-name lookup"); assert.match(ociHelper, /network\.json[\s\S]*--ip "\$ip" --mac-address "\$mac"/, "each channel receives a persistent static IP and locally administered MAC"); - assert.match(ociHelper, /container static network creation contract does not match[\s\S]*running container network identity does not match/, "runtime adoption verifies both declared and live network identity"); + assert.match(ociHelper, /container static network creation contract does not match[\s\S]*container network ID does not match[\s\S]*running container network identity does not match/, "runtime adoption verifies create-time and live network identity"); + assert.match(ociHelper, /network_ids_compatible/, "network ID checks tolerate short/full ids and pre-start NetworkID gaps"); assert.match(ociHelper, /backup_container[\s\S]*sha256sum[\s\S]*restore_container[\s\S]*backup digest does not match/, "backup and recovery are digest-qualified and ownership-gated"); assert.match(ociHelper, /source\.extractall\(destination, filter="data"\)/, "backup extraction rejects unsafe archive paths"); assert.match(ociRecipe, /docker\.io\/library\/ubuntu:24\.04@sha256:[a-f0-9]{64}/, "the OCI guest base is fully qualified and digest-pinned without mutable short-name state"); diff --git a/test/workspace-interactions.mjs b/test/workspace-interactions.mjs index c7cc45f..5c74c70 100644 --- a/test/workspace-interactions.mjs +++ b/test/workspace-interactions.mjs @@ -21,7 +21,7 @@ test("workspace sidebar interactions have durable, member-scoped contracts", () assert.match(app, /title: "Expand navigation"[\s\S]*title: "Collapse navigation"/, "the compact desktop rail always retains an explicit expand control"); assert.match(settings, /key: "group_unread_channels_first"/, "the setting persists through user UI state"); assert.match(app, /const members = Array\.isArray\(channel\?\.members\) \? channel\.members : \[\]/, "human suggestions come only from current-channel members"); - assert.match(app, /channel\?\.kind === "channel" \? \[resident, skipper\] : \[\]/, "agent channels suggest their resident before Skipper"); + assert.match(app, /channel\?\.kind === "channel"\s*\n\s*\? \[resident, skipper\]\.filter\(\(bot, index, list\) => !!bot && list\.findIndex\(\(other\) => other\?\.id === bot\.id\) === index\)\s*\n\s*: \[\]/, "agent channels suggest their resident before Skipper, deduped when the resident is Skipper"); assert.match(app, /k\.key === "Tab"[\s\S]*!input\.value\.trim\(\)[\s\S]*`@\$\{channel\.agent\.name\} `/, "Tab in an empty agent composer inserts the resident mention"); });