Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
61 changes: 55 additions & 6 deletions scripts/1helm-lxc-net
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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
}
Comment on lines +49 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make DHCP an explicit, tested part of network health. The helper currently validates bridge/DNS identity but not whether dnsmasq serves DHCP, and the regression test mirrors that incomplete contract.

  • scripts/1helm-lxc-net#L49-L67: require the expected --dhcp-range configuration before returning healthy.
  • test/site.mjs#L199-L199: assert that the validated dnsmasq contract includes DHCP configuration.
📍 Affects 2 files
  • scripts/1helm-lxc-net#L49-L67 (this comment)
  • test/site.mjs#L199-L199
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/1helm-lxc-net` around lines 49 - 67, The bridge_dns_healthy contract
must validate dnsmasq DHCP configuration in addition to its existing bridge,
DNS, and interface checks. In scripts/1helm-lxc-net, require the expected
--dhcp-range argument before returning healthy, handling the same argument
representation used for --listen-address; in test/site.mjs, update the
regression assertion to include this DHCP requirement.


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
Expand All @@ -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
39 changes: 34 additions & 5 deletions scripts/1helm-lxc-runtime
Original file line number Diff line number Diff line change
Expand Up @@ -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]+$'

Expand Down Expand Up @@ -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"
Comment on lines +97 to +108

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Do not delete until marker-less ownership and shutdown are proven.

Line 100 recursively removes a configless container directory without inspecting its owner marker. Lines 106-108 also ignore stop/destroy failures and then remove the directory anyway. A running container or a retained owned machine can therefore lose its root filesystem. Check the marker on every cleanup path, and abort if lxc-stop/lxc-destroy fails before rm -rf.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/1helm-lxc-runtime` around lines 97 - 108, Update remove_incomplete to
inspect the ownership marker before any deletion, including the
configless-container path, and abort when a marker indicates retained ownership.
Require lxc-stop and lxc-destroy to succeed before removing the container
directory; do not suppress their failures or perform rm -rf after an
unsuccessful operation.

}
image_values() {
case "$1" in
amd64) printf '%s\n%s\n' "$AMD64_ROOTFS_SHA256" "$AMD64_META_SHA256" ;;
Expand Down Expand Up @@ -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" \
Expand All @@ -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)"
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions site/public/install-lxc-runtime.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion src/client/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
62 changes: 47 additions & 15 deletions src/server/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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<string, unknown> = {}): Record<string, string> => {
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
Expand Down Expand Up @@ -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<string, unknown>) || {}) });
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<string, unknown>) || {}) });
Comment on lines +106 to +111

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prefer USERPROFILE over HOME on Windows.

A Windows process can inherit a POSIX-style HOME; passing that to native cmd.exe causes both PTY endpoints to fail before command execution. Select USERPROFILE first on Windows, while retaining HOME first on Unix.

  • src/server/agent.ts#L106-L111: use USERPROFILE || HOME || process.cwd() when process.platform === "win32".
  • src/server/agent.ts#L156-L162: apply the same platform-specific fallback for interactive terminals.
Proposed fix
- const cwd = b.cwd ? String(b.cwd) : process.env.HOME || process.env.USERPROFILE || process.cwd();
+ const cwd = b.cwd ? String(b.cwd) : process.platform === "win32"
+   ? process.env.USERPROFILE || process.env.HOME || process.cwd()
+   : process.env.HOME || process.env.USERPROFILE || process.cwd();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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<string, unknown>) || {}) });
const cwd = b.cwd ? String(b.cwd) : process.platform === "win32"
? process.env.USERPROFILE || process.env.HOME || process.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<string, unknown>) || {}) });
📍 Affects 1 file
  • src/server/agent.ts#L106-L111 (this comment)
  • src/server/agent.ts#L156-L162
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/agent.ts` around lines 106 - 111, Update both cwd fallback sites
in src/server/agent.ts (lines 106-111 and 156-162): in the agent PTY spawn flow
and interactive terminal flow, select USERPROFILE before HOME when
process.platform is "win32", retain HOME before USERPROFILE on Unix, and fall
back to process.cwd() in either case.

} 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;
Expand Down Expand Up @@ -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) => {
Expand Down
Loading
Loading