diff --git a/CHANGELOG.md b/CHANGELOG.md index da7f446..2201195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.24] - 2026-07-29 + +### Fixed + +- Windows `#main` host terminals now use the native `cmd.exe` contract and + return a bounded API error when PTY startup fails instead of attempting + `/bin/sh` or crashing the local server. +- Windows channel computers now reject Local System hosting with an explicit + remediation before invoking WSL. The supported desktop continues to run in + the signed-in user's session, where that user's retained WSL distributions + are available. +- Linux LXC readiness now repairs and verifies the bridge address, the exact + `dnsmasq` process, DHCP/DNS, and owned NAT rules. Provisioning reconstructs + only a validated marker-less partial container left by an interrupted + bootstrap, while preserving every ownership-marked channel computer. +- Channel creation and command activity no longer claim success before the + private computer passes verification. Nonzero command exits and runtime + transport errors are recorded and shown as failed rather than complete. + ## [0.0.23] - 2026-07-28 ### Added @@ -732,7 +751,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.23...HEAD +[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.24...HEAD +[0.0.24]: https://github.com/gitcommit90/1Helm/compare/v0.0.23...v0.0.24 [0.0.23]: https://github.com/gitcommit90/1Helm/compare/v0.0.22...v0.0.23 [0.0.22]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.22 [0.0.21]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.21 diff --git a/README.md b/README.md index 4ae9369..edbf4b1 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,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 narrow workspace mirrors. | | `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `lxc` on Linux, `wsl` on Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. | -| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.23` | Versioned channel-machine image contract. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.24` | Versioned channel-machine image contract. | ### Agent-first JSON CLI diff --git a/package-lock.json b/package-lock.json index a02a669..817572b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1helm", - "version": "0.0.23", + "version": "0.0.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.23", + "version": "0.0.24", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index ec4db8e..43752b4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.23", + "version": "0.0.24", "private": true, "type": "module", "license": "AGPL-3.0-only", diff --git a/scripts/1helm-lxc-net b/scripts/1helm-lxc-net index cab0f06..4d6c8e1 100755 --- a/scripts/1helm-lxc-net +++ b/scripts/1helm-lxc-net @@ -7,6 +7,9 @@ set -euo pipefail MARKER="/run/1helm-lxc-net-owned" RULE_MARKER="/run/1helm-lxc-net-rules-owned" +DNSMASQ_PID="/run/lxc/dnsmasq.pid" +BRIDGE="lxcbr0" +BRIDGE_CIDR="10.0.3.1/24" LXC_NET="" for candidate in /usr/libexec/lxc/lxc-net /usr/lib/lxc/lxc-net; do [[ -x "$candidate" ]] && LXC_NET="$candidate" && break; done [[ -n "$LXC_NET" ]] || { echo "lxc-net is unavailable" >&2; exit 1; } @@ -43,17 +46,63 @@ remove_rules() { fi } +bridge_dns_healthy() { + local pid command_line executable + [[ -d "/sys/class/net/$BRIDGE" ]] || return 1 + ip link show dev "$BRIDGE" | grep -Eq 'state UP|<[^>]*\bUP\b' || return 1 + ip -4 -o addr show dev "$BRIDGE" | awk '{print $4}' | grep -Fxq "$BRIDGE_CIDR" || return 1 + [[ -r "$DNSMASQ_PID" ]] || return 1 + pid="$(cat "$DNSMASQ_PID")" + [[ "$pid" =~ ^[0-9]+$ && -r "/proc/$pid/cmdline" ]] || return 1 + kill -0 "$pid" 2>/dev/null || return 1 + executable="$(basename "$(readlink -f "/proc/$pid/exe")")" + [[ "$executable" == dnsmasq ]] || return 1 + command_line="$(tr '\0' '\n' <"/proc/$pid/cmdline")" + if grep -Fxq -- '--listen-address' <<<"$command_line"; then + grep -Fxq '10.0.3.1' <<<"$command_line" || return 1 + else + grep -Fxq -- '--listen-address=10.0.3.1' <<<"$command_line" || return 1 + fi + grep -Fxq -- '--interface=lxcbr0' <<<"$command_line" || return 1 +} + +network_healthy() { + bridge_dns_healthy || return 1 + [[ -e "$RULE_MARKER" ]] || return 1 + nft list table inet onehelm_lxc >/dev/null 2>&1 || return 1 + nft list table ip onehelm_lxc >/dev/null 2>&1 || return 1 +} + +ensure_rules() { + if [[ -e "$RULE_MARKER" ]] \ + && nft list table inet onehelm_lxc >/dev/null 2>&1 \ + && nft list table ip onehelm_lxc >/dev/null 2>&1; then + return + fi + install_rules +} + case "${1:-}" in start) - if [[ -d /sys/class/net/lxcbr0 ]]; then - ip link show dev lxcbr0 >/dev/null - install_rules + if network_healthy; then exit 0 fi + if bridge_dns_healthy; then + ensure_rules + network_healthy || { echo "lxcbr0 bridge, DNS, DHCP, or NAT did not become healthy" >&2; exit 1; } + exit 0 + fi + # A oneshot service can remain "active" after its forked dnsmasq dies or + # its bridge loses its address. Stop only lxc-net's exact private bridge, + # then rebuild it instead of adopting the stale interface by name alone. + "$LXC_NET" stop force >/dev/null 2>&1 || true "$LXC_NET" start - [[ -d /sys/class/net/lxcbr0 ]] || { echo "lxcbr0 was not created" >&2; exit 1; } install -m 0600 /dev/null "$MARKER" - install_rules + ensure_rules + network_healthy || { echo "lxcbr0 bridge, DNS, DHCP, or NAT did not become healthy" >&2; exit 1; } + ;; + check) + network_healthy || { echo "lxcbr0 bridge, DNS, DHCP, or NAT is not healthy" >&2; exit 1; } ;; stop) remove_rules @@ -62,5 +111,5 @@ case "${1:-}" in rm -f -- "$MARKER" fi ;; - *) echo "Usage: $0 {start|stop}" >&2; exit 2 ;; + *) echo "Usage: $0 {start|check|stop}" >&2; exit 2 ;; esac diff --git a/scripts/1helm-lxc-runtime b/scripts/1helm-lxc-runtime index 2d8bc79..7459a10 100755 --- a/scripts/1helm-lxc-runtime +++ b/scripts/1helm-lxc-runtime @@ -12,6 +12,7 @@ LXC_PATH="$LXC_ROOT/machines" CACHE_BASE="/var/cache/1helm-lxc" ASSET_ROOT="/opt/1helm/runtime/lxc" CONFIG_TEMPLATE="/etc/1helm/lxc-unprivileged.conf" +NETWORK_HELPER="/usr/libexec/1helm-lxc-net" NAME_PATTERN='^1helm-[a-f0-9]{16}-channel-[0-9]+$' OWNER_PATTERN='^[a-f0-9]{16}:[0-9]+$' @@ -88,6 +89,24 @@ owned() { [[ -f "$(config_path "$1")" ]] || die "container does not exist" [[ "$(marker "$1")" == "$2" ]] || die "ownership marker does not match" } +ensure_network() { + [[ -x "$NETWORK_HELPER" ]] || die "private network helper is missing" + "$NETWORK_HELPER" start + "$NETWORK_HELPER" check +} +remove_incomplete() { + local name="$1" current + [[ -e "$(config_path "$name")" || -e "$LXC_PATH/$name" ]] || return + if [[ ! -f "$(config_path "$name")" ]]; then + rm -rf -- "$LXC_PATH/$name" + return + fi + current="$(marker "$name")" + [[ -z "$current" ]] || die "container name already exists with an ownership marker" + [[ "$(state "$name")" != RUNNING ]] || lxc-stop -P "$LXC_PATH" -n "$name" -t 10 >/dev/null 2>&1 || true + lxc-destroy -P "$LXC_PATH" -n "$name" >/dev/null 2>&1 || true + [[ ! -e "$LXC_PATH/$name" ]] || rm -rf -- "$LXC_PATH/$name" +} image_values() { case "$1" in amd64) printf '%s\n%s\n' "$AMD64_ROOTFS_SHA256" "$AMD64_META_SHA256" ;; @@ -126,6 +145,7 @@ prepare_cache() { attach() { local name="$1" owner="$2" user="$3" workdir="$4"; shift 4 owned "$name" "$owner" + ensure_network start "$name" if [[ "$user" == root ]]; then exec lxc-attach -P "$LXC_PATH" -n "$name" --clear-env -- /usr/bin/env -C "$workdir" \ @@ -145,12 +165,21 @@ case "$operation" in ready) for command in lxc-create lxc-attach lxc-destroy lxc-info lxc-start lxc-stop sha256sum tar; do command -v "$command" >/dev/null || die "missing $command"; done [[ -r "$CONFIG_TEMPLATE" ]] || die "unprivileged LXC configuration is missing" - [[ -d /sys/fs/cgroup && -d /sys/class/net/lxcbr0 ]] || die "LXC cgroups or the private bridge are not ready" + [[ -d /sys/fs/cgroup ]] || die "LXC cgroups are not ready" + [[ -x "$NETWORK_HELPER" ]] || die "private network helper is missing" + "$NETWORK_HELPER" start || die "private bridge, DNS, DHCP, or NAT could not be repaired" + "$NETWORK_HELPER" check || die "private bridge, DNS, DHCP, or NAT is not ready" printf '{"ready":true,"version":"%s"}\n' "$RUNTIME_VERSION" ;; inspect) name="${1:-}"; owner="${2:-}"; valid_identity "$name" "$owner" if [[ ! -f "$(config_path "$name")" ]]; then printf 'null\n'; exit 0; fi + # A crash or failed guest bootstrap can leave the exact validated machine + # without the owner marker that is written last. Report only that empty + # partial state as missing so create can remove and rebuild it. Any nonempty + # mismatched marker remains a hard ownership failure. + current_owner="$(marker "$name")" + if [[ -z "$current_owner" ]]; then printf 'null\n'; exit 0; fi owned "$name" "$owner" status="$(state "$name")" cpu_set="$(sed -n 's/^lxc\.cgroup2\.cpuset\.cpus[[:space:]]*=[[:space:]]*//p' "$(config_path "$name")" | tail -n1)" @@ -166,6 +195,7 @@ case "$operation" in [[ "$cpus" =~ ^[1-8]$ ]] || die "invalid CPU count" [[ "$memory_mb" =~ ^[0-9]+$ ]] && ((memory_mb >= 1024 && memory_mb <= 16384)) || die "invalid memory" [[ "$arch" == amd64 || "$arch" == arm64 ]] || die "unsupported architecture" + remove_incomplete "$name" [[ ! -e "$(config_path "$name")" ]] || die "container name already exists" [[ -r "$CONFIG_TEMPLATE" ]] || die "unprivileged configuration is missing" created=0 @@ -186,6 +216,7 @@ case "$operation" in install -d -m 0711 "$LXC_ROOT" "$LXC_PATH" install -d -m 0700 "$CACHE_BASE" prepare_cache "$arch" + ensure_network created=1 LXC_CACHE_PATH="$CACHE_BASE" lxc-create -P "$LXC_PATH" -n "$name" -f "$CONFIG_TEMPLATE" -t download -- \ --dist ubuntu --release noble --arch "$arch" --variant default --force-cache @@ -195,10 +226,8 @@ case "$operation" in start "$name" lxc-attach -P "$LXC_PATH" -n "$name" --clear-env -- /bin/sh -eu -c ' export DEBIAN_FRONTEND=noninteractive - if [ ! -s /etc/resolv.conf ]; then - rm -f /etc/resolv.conf - printf "nameserver 10.0.3.1\n" >/etc/resolv.conf - fi + rm -f /etc/resolv.conf + printf "nameserver 10.0.3.1\n" >/etc/resolv.conf apt-get update apt-get install -y --no-install-recommends bash build-essential ca-certificates coreutils cron curl dbus file findutils git gzip iproute2 iputils-ping jq less locales man-db nano openssh-client procps python3 python3-pip rsync sudo systemd systemd-sysv tar tzdata unzip vim-tiny wget xz-utils zip apt-get clean diff --git a/site/public/install-lxc-runtime.sh b/site/public/install-lxc-runtime.sh index b9247d9..74af4da 100755 --- a/site/public/install-lxc-runtime.sh +++ b/site/public/install-lxc-runtime.sh @@ -239,6 +239,7 @@ visudo -cf "$TEMP_ROOT/sudoers" >/dev/null install -o root -g root -m 0440 "$TEMP_ROOT/sudoers" "$SUDOERS_PATH" systemctl daemon-reload systemctl enable --now 1helm-lxc-net.service +"$NETWORK_HELPER_PATH" start "$HELPER_PATH" ready >/dev/null sudo -u "$SERVICE_USER" sudo -n "$HELPER_PATH" version | grep -qx '1helm-lxc-runtime-v1' printf 'Installed 1Helm LXC runtime v1 with Ubuntu Noble image %s (%s).\n' "$IMAGE_BUILD" "$IMAGE_ARCH" diff --git a/src/client/channel.ts b/src/client/channel.ts index d8a17c8..b3309a6 100644 --- a/src/client/channel.ts +++ b/src/client/channel.ts @@ -27,7 +27,7 @@ export function openCreateChannel(onCreated: (channel: Channel) => void): void { status.textContent = ""; submit.disabled = true; submit.textContent = "Preparing agent and private computer…"; try { - const result = await api<{ channel: Channel }>("/api/channels", { body: { name: name.value, purpose: purpose.value, template: selectedTemplate } }); + const result = await api<{ channel: Channel; computer_ready?: boolean }>("/api/channels", { body: { name: name.value, purpose: purpose.value, template: selectedTemplate } }); close(); onCreated(result.channel); } catch (error) { status.textContent = (error as Error).message; diff --git a/src/server/agent.ts b/src/server/agent.ts index 8043fd6..69e365b 100644 --- a/src/server/agent.ts +++ b/src/server/agent.ts @@ -3,6 +3,7 @@ import type { Socket } from "node:net"; import { spawn, type IPty } from "node-pty"; import { WebSocketServer, type WebSocket } from "ws"; import { existsSync } from "node:fs"; +import { delimiter, join } from "node:path"; /** * Embedded, open-terminal-compatible agent (https://github.com/open-webui/open-terminal). @@ -14,18 +15,42 @@ type Entry = { type: string; data: string }; type Proc = { id: string; command: string; buf: Entry[]; status: string; exit_code: number | null; pty: IPty; waiters: (() => void)[] }; type Term = { id: string; pty: IPty; created_at: string; pid: number; scrollback: Buffer[]; bytes: number }; -const requestedShell = process.env.SHELL || "/bin/bash"; -const SHELL = requestedShell.startsWith("/") && existsSync(requestedShell) - ? requestedShell - : ["/bin/zsh", "/bin/bash", "/bin/sh"].find(existsSync) || "/bin/sh"; +export type NativeShell = { executable: string; executeArgs(command: string): string[]; interactiveArgs: string[] }; + +/** Resolve the host's actual native shell without ever applying Unix paths to + * Windows. Keep this pure enough to prove the Windows plan on non-Windows CI. */ +export function resolveNativeShell( + hostPlatform = process.platform, + env: NodeJS.ProcessEnv = process.env, + present: (path: string) => boolean = existsSync, +): NativeShell { + if (hostPlatform === "win32") { + const absolute = [env.HELM_NATIVE_SHELL, env.ComSpec, env.SystemRoot ? join(env.SystemRoot, "System32", "cmd.exe") : ""] + .filter(Boolean).find((candidate) => present(String(candidate))); + const executable = String(absolute || env.HELM_NATIVE_SHELL || env.ComSpec || "cmd.exe"); + return { executable, executeArgs: (command) => ["/d", "/s", "/c", command], interactiveArgs: ["/d"] }; + } + const requested = env.SHELL || "/bin/bash"; + const executable = requested.startsWith("/") && present(requested) + ? requested + : ["/bin/zsh", "/bin/bash", "/bin/sh"].find(present) || "/bin/sh"; + return { + executable, + executeArgs: (command) => ["-lc", `export PATH="$HELM_NATIVE_PATH"; unset HELM_NATIVE_PATH; ${command}`], + interactiveArgs: executable.endsWith("/zsh") ? ["-f"] : executable.endsWith("/bash") ? ["--noprofile", "--norc", "-i"] : ["-i"], + }; +} + +const NATIVE_SHELL = resolveNativeShell(); +const SHELL = NATIVE_SHELL.executable; export function terminalPromptEnvironment(shell: string): { PROMPT?: string; PS1: string } { if (shell.endsWith("/zsh")) return { PROMPT: "%n@%m:%~%# ", PS1: "%n@%m:%~%# " }; if (shell.endsWith("/bash")) return { PS1: "\\u@\\h:\\w\\$ " }; return { PS1: "$PWD$ " }; } const nativeEnv = (extra: Record = {}): Record => { - const preferred = ["/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin", "/usr/local/sbin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]; - const path = [...preferred, ...String(extra.PATH || process.env.PATH || "").split(":")].filter((item, index, all) => item && all.indexOf(item) === index).join(":"); + const preferred = process.platform === "win32" ? [] : ["/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin", "/usr/local/sbin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]; + const path = [...preferred, ...String(extra.PATH || process.env.PATH || "").split(delimiter)].filter((item, index, all) => item && all.indexOf(item) === index).join(delimiter); // Prompt escapes are shell-specific. Supplying Bash's `\u`/`\w` escapes to // zsh renders those bytes literally, while zsh's `%~` is meaningless to // Bash. Set only the syntax understood by the selected native shell. No @@ -78,10 +103,15 @@ export function startAgent(port: number, apiKey: string, host = "127.0.0.1"): Pr if (path === "/execute" && req.method === "POST") { const b = await readBody(req); const command = String(b.command || ""); - const cwd = b.cwd ? String(b.cwd) : process.env.HOME || process.cwd(); - // Login shells can replace the inherited PATH from /etc/zprofile. Export - // it again after shell startup so Homebrew CLIs work without profile edits. - const pty = spawn(SHELL, ["-lc", `export PATH="$HELM_NATIVE_PATH"; unset HELM_NATIVE_PATH; ${command}`], { cols: 80, rows: 24, cwd, env: nativeEnv((b.env as Record) || {}) }); + const cwd = b.cwd ? String(b.cwd) : process.env.HOME || process.env.USERPROFILE || process.cwd(); + // Unix login shells can replace PATH from their profiles; the resolved + // shell plan restores it there. Windows receives cmd.exe's native argv. + let pty: IPty; + try { + pty = spawn(SHELL, NATIVE_SHELL.executeArgs(command), { cols: 80, rows: 24, cwd, env: nativeEnv((b.env as Record) || {}) }); + } catch (error) { + return send(res, 500, { detail: `Native command shell could not start: ${(error as Error).message}` }); + } const p: Proc = { id: rid("exec-"), command, buf: [], status: "running", exit_code: null, pty, waiters: [] }; procs.set(p.id, p); let responseFinished = false; @@ -123,14 +153,16 @@ export function startAgent(port: number, apiKey: string, host = "127.0.0.1"): Pr if (path === "/api/terminals" && req.method === "POST") { const b = await readBody(req); - const cwd = b.cwd ? String(b.cwd) : process.env.HOME || process.cwd(); + const cwd = b.cwd ? String(b.cwd) : process.env.HOME || process.env.USERPROFILE || process.cwd(); // Start the interactive shell with the complete native PATH already in // its environment. Skip startup files here because they can replace the // inherited PATH; nothing is typed into the live PTY or its history. - const shellArgs = SHELL.endsWith("/zsh") ? ["-f"] - : SHELL.endsWith("/bash") ? ["--noprofile", "--norc", "-i"] - : ["-i"]; - const pty = spawn(SHELL, shellArgs, { name: "xterm-256color", cols: Number(b.cols) || 80, rows: Number(b.rows) || 24, cwd, env: nativeEnv() }); + let pty: IPty; + try { + pty = spawn(SHELL, NATIVE_SHELL.interactiveArgs, { name: "xterm-256color", cols: Number(b.cols) || 80, rows: Number(b.rows) || 24, cwd, env: nativeEnv() }); + } catch (error) { + return send(res, 500, { detail: `Native terminal shell could not start: ${(error as Error).message}` }); + } const t: Term = { id: rid("term-"), pty, created_at: new Date().toISOString(), pid: pty.pid, scrollback: [], bytes: 0 }; terms.set(t.id, t); pty.onData((data) => { diff --git a/src/server/agents.ts b/src/server/agents.ts index 17a9d18..e83fdb7 100644 --- a/src/server/agents.ts +++ b/src/server/agents.ts @@ -209,7 +209,7 @@ export function provisionChannel(opts: { name: string; purpose: string; userId: announcementId, channelId, announcement, now(), now(), ).lastInsertRowid; run("INSERT INTO thread_summaries (thread_id, content, created) VALUES (?,?,?)", threadId, announcement, now()); - run("INSERT INTO channel_activity (channel_id, thread_id, kind, summary, actor_type, created) VALUES (?,?,'lifecycle',?,'system',?)", channelId, threadId, `Provisioned @${mentionName} and its channel world.`, now()); + run("INSERT INTO channel_activity (channel_id, thread_id, kind, summary, status, actor_type, created) VALUES (?,?,'lifecycle',?,'running','system',?)", channelId, threadId, `Created @${mentionName}; provisioning its private channel computer.`, now()); return { channelId, agentId, botId, announcementId, created: true }; }); const createdAgent = agentForChannel(result.channelId); @@ -226,10 +226,12 @@ export async function provisionChannelWithComputer(opts: { name: string; purpose const provisioned = provisionChannel(opts); try { await provisionChannelComputer(provisioned.channelId); + run("UPDATE channel_activity SET summary=?,status='complete',updated=? WHERE channel_id=? AND kind='lifecycle' AND status='running'", `Provisioned the resident and verified its private channel computer.`, now(), provisioned.channelId); return { ...provisioned, computerReady: true }; } catch (error) { const message = (error as Error).message || "channel computer provisioning failed"; run("UPDATE agents SET status='waiting' WHERE id=?", provisioned.agentId); + run("UPDATE channel_activity SET summary=?,status='failed',updated=? WHERE channel_id=? AND kind='lifecycle' AND status='running'", `Created the resident, but its private channel computer failed verification: ${message}`.slice(0, 500), now(), provisioned.channelId); run("INSERT INTO channel_activity (channel_id,kind,summary,status,actor_type,created) VALUES (?,'computer',?,'failed','skipper',?)", provisioned.channelId, `Channel computer provisioning needs attention: ${message}`.slice(0, 500), now()); return { ...provisioned, computerReady: false, computerError: message }; } diff --git a/src/server/bots.ts b/src/server/bots.ts index 2d576d6..4815923 100644 --- a/src/server/bots.ts +++ b/src/server/bots.ts @@ -1086,10 +1086,11 @@ async function runCommand(bot: Row, agent: RuntimeAgent | undefined, channelId: if (agent?.kind === "channel") { try { const result = await runChannelCommand(channelId, command, signal); - return `status=${result.status}\nexit_code=${result.exit_code}\n${result.output || "(no output)"}`.slice(0, 8000); + const status = result.status === "completed" && result.exit_code === 0 ? "completed" : result.status === "running" ? "running" : "failed"; + return `status=${status}\nexit_code=${result.exit_code}\n${result.output || "(no output)"}`.slice(0, 8000); } catch (error) { if ((error as Error).name === "AbortError") throw error; - return `Error running command: ${(error as Error).message}`; + return `Error: command could not run: ${(error as Error).message}`; } } const assignedRows = q(`SELECT c.id, c.name FROM computers c JOIN bot_computers bc ON bc.computer_id=c.id WHERE bc.bot_id=? ORDER BY c.id`, bot.id); @@ -1114,13 +1115,20 @@ async function runCommand(bot: Row, agent: RuntimeAgent | undefined, channelId: try { const result = await execOnComputer(computer, execCommand, cwd, 60, signal); const output = cwd ? result.output.split(cwd).join("/workspace") : result.output; - return `status=${result.status}\nexit_code=${result.exit_code}\n${output || "(no output)"}`.slice(0, 8000); + const status = result.status === "completed" && result.exit_code === 0 ? "completed" : result.status === "running" ? "running" : "failed"; + return `status=${status}\nexit_code=${result.exit_code}\n${output || "(no output)"}`.slice(0, 8000); } catch (error) { if ((error as Error).name === "AbortError") throw error; - return `Error running command: ${(error as Error).message}`; + return `Error: command could not run: ${(error as Error).message}`; } } +export function toolActionStatus(result: string): "failed" | "running" | "complete" { + if (/^Error:/i.test(result) || /^status=failed(?:\n|$)/i.test(result)) return "failed"; + if (/^status=running(?:\n|$)/i.test(result)) return "running"; + return "complete"; +} + async function createNativeChannel(nameInput: string, purposeInput: string, userId: number): Promise { if (!userId) return "Error: a Captain could not be identified for this request."; const name = normalizeChannelName(nameInput); @@ -1801,7 +1809,7 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread if ((error as Error).name === "AbortError") throw error; result = `Error: ${(error as Error).message}`; } - const actionStatus = result.startsWith("Error:") ? "failed" : result.startsWith("status=running") ? "running" : "complete"; + const actionStatus = toolActionStatus(result); finishAction(actionId, threadId, channelId, result, actionStatus, actor); updateProgress(progressId, `${name.replaceAll("_", " ")}: ${input || "action"}\n${result}`.trim(), actionStatus === "failed" ? "failed" : actionStatus === "running" ? "running" : "complete"); if (actionStatus === "failed") { diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index d568158..9e2fedc 100644 --- a/src/server/channel-computers.ts +++ b/src/server/channel-computers.ts @@ -67,7 +67,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.23"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.24"; const CONTAINER_CANDIDATES = [process.env.HELM_CONTAINER_CLI, "/usr/local/bin/container", "/opt/homebrew/bin/container", "container"].filter(Boolean) as string[]; const LXC_RUNTIME_VERSION = "1helm-lxc-runtime-v1"; const LXC_HELPER_CANDIDATES = [ @@ -275,6 +275,7 @@ function resolveLxcHelper(): string { } function resolveWslCli(): string { + if (windowsSystemAccount()) throw new Error("1Helm cannot use WSL while running as Windows Local System. Launch 1Helm in the signed-in Windows user's session so WSL and its retained distributions are available."); const candidates = [process.env.HELM_WSL_CLI, process.env.SystemRoot ? join(process.env.SystemRoot, "System32", "wsl.exe") : "", "wsl.exe"].filter(Boolean) as string[]; for (const candidate of candidates) { if (candidate.includes("/") || candidate.includes("\\")) { if (existsSync(candidate)) return candidate; } @@ -283,6 +284,15 @@ function resolveWslCli(): string { throw new Error("WSL 2 is not installed. Run Windows' verified 1Helm setup as Administrator once."); } +/** WSL distributions are scoped to an interactive Windows user and Microsoft + * explicitly rejects Local System. This accepts injected values for CI. */ +export function windowsSystemAccount(env: NodeJS.ProcessEnv = process.env, hostPlatform = platform()): boolean { + if (hostPlatform !== "win32") return false; + const username = String(env.USERNAME || env.USER || "").trim().toLowerCase(); + const profile = String(env.USERPROFILE || "").replaceAll("/", "\\").toLowerCase(); + return username === "system" || profile.endsWith("\\windows\\system32\\config\\systemprofile"); +} + function privateWslInstallRoot(): string { if (platform() === "win32") return join(dirname(DATA_DIR), "1Helm-WSL"); return join(DATA_DIR, "wsl"); diff --git a/src/server/db.ts b/src/server/db.ts index d7828fb..0a3bc72 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -1009,7 +1009,7 @@ export function migrate(): void { const platformBackend = process.platform === "darwin" ? "apple" : process.platform === "win32" ? "wsl" : "lxc"; const configuredBackend = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || platformBackend); const backend = ["apple", "lxc", "wsl", "native", "mock"].includes(configuredBackend) ? configuredBackend : platformBackend; - const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.23"); + const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.24"); // Earlier Linux/Windows releases persisted the compatibility `native` // seam into every channel row. A production host update must actually // move those rows onto the platform isolation backend; changing the unit's diff --git a/src/server/index.ts b/src/server/index.ts index 917278b..ef58e16 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1309,7 +1309,10 @@ const server = createServer(async (req, res) => { const channel = channelView(user, row); broadcastChannelMeta(provisioned.channelId, "channel_new"); if (provisioned.announcementId) broadcastToChannel(provisioned.channelId, { type: "message", message: serializeMessage(provisioned.announcementId) }); - return json(res, provisioned.created ? 201 : 200, { channel, created: provisioned.created }); + return json(res, provisioned.computerReady ? (provisioned.created ? 201 : 200) : 503, { + channel, created: provisioned.created, computer_ready: provisioned.computerReady, + ...(provisioned.computerError ? { error: `Channel created, but its private computer failed verification: ${provisioned.computerError}` } : {}), + }); } catch (error) { const message = (error as Error).message; return json(res, /already exists/i.test(message) ? 409 : 400, { error: message }); diff --git a/test/autonomy-platform.mjs b/test/autonomy-platform.mjs index 2765cf6..e7e0116 100644 --- a/test/autonomy-platform.mjs +++ b/test/autonomy-platform.mjs @@ -9,9 +9,10 @@ process.env.CTRL_DATA_DIR = dataDir; const dbModule = await import("../src/server/db.ts"); const { db, q1, run, now, seed } = dbModule; const { verifyAuditChain } = await import("../src/server/audit.ts"); -const { buildContext, runtimePromptTiersForChannel, runtimeToolNamesForChannel, validateAskUserInput } = await import("../src/server/bots.ts"); +const { buildContext, runtimePromptTiersForChannel, runtimeToolNamesForChannel, toolActionStatus, validateAskUserInput } = await import("../src/server/bots.ts"); const { inspectWebSource, isPublicWebAddress, validateWebSourceUrl } = await import("../src/server/web-source.ts"); -const { terminalPromptEnvironment } = await import("../src/server/agent.ts"); +const { resolveNativeShell, terminalPromptEnvironment } = await import("../src/server/agent.ts"); +const { windowsSystemAccount } = await import("../src/server/channel-computers.ts"); const turns = await import("../src/server/turns.ts"); const catalog = await import("../src/server/skill-catalog.ts"); const history = await import("../src/server/history.ts"); @@ -70,6 +71,28 @@ test("native terminal prompts use the selected shell's cwd syntax", () => { assert.equal("PROMPT" in bash, false); }); +test("native terminals use cmd.exe on Windows and never a Unix fallback", () => { + const cmd = "C:\\Windows\\System32\\cmd.exe"; + const shell = resolveNativeShell("win32", { ComSpec: cmd }, (candidate) => candidate === cmd); + assert.equal(shell.executable, cmd); + assert.deepEqual(shell.executeArgs("whoami"), ["/d", "/s", "/c", "whoami"]); + assert.deepEqual(shell.interactiveArgs, ["/d"]); + assert.doesNotMatch(JSON.stringify(shell), /\/bin\/(?:sh|bash|zsh)/); +}); + +test("Windows Local System is rejected before WSL access", () => { + assert.equal(windowsSystemAccount({ USERNAME: "SYSTEM", USERPROFILE: "C:\\Windows\\System32\\config\\systemprofile" }, "win32"), true); + assert.equal(windowsSystemAccount({ USERNAME: "defaultuser0", USERPROFILE: "C:\\Users\\defaultuser0" }, "win32"), false); + assert.equal(windowsSystemAccount({ USERNAME: "SYSTEM" }, "linux"), false); +}); + +test("nonzero and transport-error command results cannot be stored as complete", () => { + assert.equal(toolActionStatus("status=completed\nexit_code=0\nok"), "complete"); + assert.equal(toolActionStatus("status=failed\nexit_code=100\napt failed"), "failed"); + assert.equal(toolActionStatus("Error: command could not run: runtime unavailable"), "failed"); + assert.equal(toolActionStatus("status=running\nexit_code=null"), "running"); +}); + test("a finalized turn is immutable to stale stream writers", () => { seed(); const channelId = run("INSERT INTO channels (name,slug,kind,topic,purpose,status,created) VALUES ('turn-fence','turn-fence','channel','','','active',?)", now()).lastInsertRowid; diff --git a/test/channel-computers.mjs b/test/channel-computers.mjs index cc8a3e3..3b68ed5 100644 --- a/test/channel-computers.mjs +++ b/test/channel-computers.mjs @@ -168,7 +168,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.23"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.24"); 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/desktop.mjs b/test/desktop.mjs index c752e9d..7aa9c35 100644 --- a/test/desktop.mjs +++ b/test/desktop.mjs @@ -133,10 +133,14 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(server, /prepareAppRemoval\(\)/, "the control plane performs and verifies the owned-VM cleanup before uninstall"); assert.match(server, /process\.emit\("1helm-removal-prepared"\)/, "successful cleanup notifies the native shell to disable automatic relaunch"); const channelComputers = await readFile(join(root, "src", "server", "channel-computers.ts"), "utf8"); + const embeddedTerminal = await readFile(join(root, "src", "server", "agent.ts"), "utf8"); assert.match(channelComputers, /\["machine", "delete", computer\.machine_id\]/, "uninstall cleanup uses Apple's complete machine deletion operation"); assert.match(channelComputers, /printf '\[automount\]/, "private WSL distros disable Windows-drive automount"); assert.match(channelComputers, /! findmnt -rn \/mnt\/c[\s\S]*rmdir \/mnt\/c \/mnt\/d[\s\S]*test ! -e \/mnt\/c/, "WSL removes only inert drive mountpoint directories after proving they are not mounted"); assert.match(channelComputers, /test ! -e \/mnt\/c/, "WSL provisioning verifies the host C drive is not visible"); + assert.match(channelComputers, /windowsSystemAccount\(\)[\s\S]*cannot use WSL while running as Windows Local System/, "WSL fails with an actionable host-identity error before invoking an unsupported SYSTEM session"); + assert.match(embeddedTerminal, /hostPlatform === "win32"[\s\S]*ComSpec[\s\S]*cmd\.exe[\s\S]*\["\/d", "\/s", "\/c", command\]/, "Windows host commands and #main Terminal use the native cmd shell contract"); + assert.match(embeddedTerminal, /Native terminal shell could not start/, "a terminal spawn failure returns an HTTP error instead of crashing the server"); const windowsPackager = await readFile(join(root, "scripts", "package-windows.cjs"), "utf8"); const macPackager = await readFile(join(root, "scripts", "package-mac-dmg.cjs"), "utf8"); const windowsRemoval = await readFile(join(root, "scripts", "windows-removal.cjs"), "utf8"); diff --git a/test/site.mjs b/test/site.mjs index 3a31850..03fbc0a 100644 --- a/test/site.mjs +++ b/test/site.mjs @@ -196,6 +196,13 @@ test("installer assets are explicit and syntax-valid", () => { assert.match(lxcHelper, /cpu_count/, "LXC inspection counts noncontiguous delegated CPU lists correctly"); assert.match(lxcNetwork, /1helm-lxc-net-owned/, "the bridge wrapper stops only a bridge it started"); assert.match(lxcNetwork, /1helm-lxc-net-rules-owned[\s\S]*table ip onehelm_lxc[\s\S]*masquerade/, "an adopted bridge receives a separately owned, removable outbound NAT contract"); + assert.match(lxcNetwork, /BRIDGE_CIDR="10\.0\.3\.1\/24"[\s\S]*bridge_dns_healthy[\s\S]*state UP[\s\S]*DNSMASQ_PID[\s\S]*--interface=lxcbr0[\s\S]*network_healthy/, "runtime health requires an up/addressed bridge and its exact dnsmasq process"); + assert.match(lxcNetwork, /bridge_dns_healthy[\s\S]*ensure_rules[\s\S]*network_healthy/, "a healthy bridge can restore only its owned firewall rules without disrupting containers"); + assert.match(lxcNetwork, /"\$LXC_NET" stop force[\s\S]*"\$LXC_NET" start[\s\S]*network_healthy/, "a dead private bridge/DNS stack is rebuilt and reverified instead of adopted by interface name"); + assert.match(lxcHelper, /ready\)[\s\S]*"\$NETWORK_HELPER" start[\s\S]*"\$NETWORK_HELPER" check/, "runtime readiness repairs and then verifies the full network contract"); + assert.match(lxcHelper, /inspect\)[\s\S]*current_owner[\s\S]*printf 'null/, "inspection exposes only marker-less interrupted creates as safely rebuildable partial machines"); + assert.match(lxcHelper, /remove_incomplete[\s\S]*marker[\s\S]*lxc-destroy[\s\S]*rm -rf -- "\$LXC_PATH\/\$name"/, "create recovers only the exact validated marker-less partial container"); + assert.match(lxcInstaller, /systemctl enable --now 1helm-lxc-net\.service[\s\S]*"\$NETWORK_HELPER_PATH" start[\s\S]*"\$HELPER_PATH" ready/, "host installs and updates actively repair then verify the bridge contract without disrupting a healthy network"); assert.match(lxcHelper, /nameserver 10\.0\.3\.1[\s\S]*apt-get update/, "new guests have bridge DNS before package bootstrap"); assert.match(lxcConfig, /lxc\.apparmor\.profile = generated/); assert.match(lxcConfig, /lxc\.apparmor\.allow_nesting = 0/);