@saadnvd1/agent-os 0.3.693 → 0.3.694

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import fs from "fs";
2
2
  import os from "os";
3
3
  import path from "path";
4
+ import { onWindowsDrive, wslVersion } from "../wsl";
4
5
 
5
6
  // Where agent CLIs install themselves outside a login shell's PATH: a chat
6
7
  // worker and the server never source ~/.zshrc, so a CLI that lives under nvm
@@ -37,6 +38,13 @@ function executable(file: string): boolean {
37
38
  }
38
39
  }
39
40
 
41
+ // WSL appends the Windows PATH, so a CLI installed only on Windows (an npm
42
+ // shim under /mnt/c) would be found and run with a Windows home and config
43
+ // over the slow drive. Only the Linux install counts.
44
+ export function skipDir(dir: string, wsl = wslVersion()): boolean {
45
+ return wsl !== 0 && onWindowsDrive(dir);
46
+ }
47
+
40
48
  const found = new Map<string, string | null>();
41
49
 
42
50
  // The absolute path of a CLI, or null when it isn't installed.
@@ -45,7 +53,7 @@ export function resolveCli(name: string, fresh = false): string | null {
45
53
  const dirs = [
46
54
  ...(process.env.PATH ?? "").split(path.delimiter).filter(Boolean),
47
55
  ...extraDirs(),
48
- ];
56
+ ].filter((d) => !skipDir(d));
49
57
  const hit = dirs.map((d) => path.join(d, name)).find(executable) ?? null;
50
58
  found.set(name, hit);
51
59
  return hit;
package/lib/ports.ts CHANGED
@@ -7,13 +7,17 @@
7
7
  import { execFile } from "child_process";
8
8
  import { promisify } from "util";
9
9
  import { db } from "./db";
10
+ import { windowsListeningPorts } from "./windows-ports";
10
11
 
11
12
  const execFileAsync = promisify(execFile);
12
13
 
13
14
  export const MAX_SLOT = 200;
14
15
 
15
16
  // Something outside AgentOS listening on it. lsof exits 1 when nothing is.
17
+ // Under WSL 2 a Windows process on the port counts too: localhost is shared
18
+ // with Windows, which lsof can't see. When Windows can't be asked, lsof alone.
16
19
  export async function isPortInUse(port: number): Promise<boolean> {
20
+ if ((await windowsListeningPorts())?.has(port)) return true;
17
21
  try {
18
22
  const { stdout } = await execFileAsync(
19
23
  "lsof",
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Ports Windows is listening on, seen from inside WSL 2. WSL's localhost
3
+ * forwarding shares ports with Windows, but lsof in the distro can't see a
4
+ * Windows process, so a dev server on Windows :3000 and a session's :3000
5
+ * collide. netstat.exe answers in ~100ms where PowerShell takes seconds to
6
+ * start. Any failure is "unknown": callers fall back to the lsof check and
7
+ * never block on this.
8
+ */
9
+
10
+ import { execFile } from "child_process";
11
+ import { promisify } from "util";
12
+ import { wslVersion } from "./wsl";
13
+
14
+ const execFileAsync = promisify(execFile);
15
+
16
+ const TTL_MS = 3000;
17
+
18
+ /**
19
+ * Listening ports in `netstat.exe -ano -p TCP` output. A listener has a
20
+ * wildcard peer (0.0.0.0:0 or [::]:0), which holds in every Windows locale;
21
+ * the state column ("LISTENING") is translated.
22
+ */
23
+ export function parseNetstatListeners(out: string): Set<number> {
24
+ const ports = new Set<number>();
25
+ for (const line of out.split(/\r?\n/)) {
26
+ const cols = line.trim().split(/\s+/);
27
+ if (cols.length < 4 || cols[0].toUpperCase() !== "TCP") continue;
28
+ const [, local, peer] = cols;
29
+ if (!/^(0\.0\.0\.0|\[::\]):0$/.test(peer)) continue;
30
+ const port = Number(local.slice(local.lastIndexOf(":") + 1));
31
+ if (Number.isInteger(port) && port > 0) ports.add(port);
32
+ }
33
+ return ports;
34
+ }
35
+
36
+ type Run = (file: string, args: string[]) => Promise<string>;
37
+
38
+ const defaultRun: Run = async (file, args) =>
39
+ (await execFileAsync(file, args, { timeout: 4000, maxBuffer: 8 << 20 }))
40
+ .stdout;
41
+
42
+ let cache: { at: number; ports: Set<number> | null } | undefined;
43
+ let inflight: Promise<Set<number> | null> | undefined;
44
+
45
+ /** The ports Windows listens on, or null when unknown (not WSL 2, or it failed). */
46
+ export async function windowsListeningPorts(
47
+ opts: { wsl?: number; run?: Run; now?: number } = {}
48
+ ): Promise<Set<number> | null> {
49
+ if ((opts.wsl ?? wslVersion()) !== 2) return null;
50
+ const now = opts.now ?? Date.now();
51
+ if (cache && now - cache.at < TTL_MS) return cache.ports;
52
+ inflight ??= (opts.run ?? defaultRun)("netstat.exe", ["-ano", "-p", "TCP"])
53
+ .then((out) => parseNetstatListeners(out))
54
+ .catch(() => null)
55
+ .then((ports) => {
56
+ cache = { at: now, ports };
57
+ return ports;
58
+ })
59
+ .finally(() => {
60
+ inflight = undefined;
61
+ });
62
+ return inflight;
63
+ }
64
+
65
+ export function resetWindowsPortsCache() {
66
+ cache = undefined;
67
+ inflight = undefined;
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.693",
3
+ "version": "0.3.694",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
package/scripts/agent-os CHANGED
@@ -65,6 +65,7 @@ source "$SCRIPT_DIR/lib/common.sh"
65
65
  source "$SCRIPT_DIR/lib/prerequisites.sh"
66
66
  source "$SCRIPT_DIR/lib/ai-clis.sh"
67
67
  source "$SCRIPT_DIR/lib/commands.sh"
68
+ source "$SCRIPT_DIR/lib/machine.sh"
68
69
 
69
70
  # The package family the installers key off (debian, redhat, linux, macos),
70
71
  # and the WSL version (0 off WSL).
@@ -85,6 +86,7 @@ case "${1:-}" in
85
86
  passkeys) shift; (cd "$(dirname "$SCRIPT_DIR")" && npx tsx lib/security/passkeys-cli.ts "$@") ;;
86
87
  logs) cmd_logs ;;
87
88
  update) cmd_update ;;
89
+ machine) cmd_machine ;;
88
90
  enable) cmd_enable ;;
89
91
  disable) cmd_disable ;;
90
92
  uninstall) cmd_uninstall ;;
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env bash
2
+ # CI check of the linked-machine path, from one AgentOS machine to another
3
+ # (here the same box, over ssh to localhost): the exact steps lib/hosts/link.ts
4
+ # takes. ssh in for a pairing code on the machine's own loopback, claim it over
5
+ # HTTP, keep the device token, and call the machine's API with it. Run with
6
+ # the server already up on $1 (default 3011) and sshd running.
7
+ set -euo pipefail
8
+
9
+ port="${1:-3011}"
10
+ key="$(mktemp -d)/id"
11
+ ssh-keygen -q -t ed25519 -N "" -f "$key"
12
+ mkdir -p ~/.ssh && chmod 700 ~/.ssh
13
+ cat "$key.pub" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys
14
+
15
+ ssh_run() {
16
+ ssh -i "$key" -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
17
+ -o UserKnownHostsFile="$(dirname "$key")/known_hosts" localhost "$@"
18
+ }
19
+
20
+ code=$(ssh_run "curl -fsS -m 10 -X POST http://127.0.0.1:$port/api/pair/start" \
21
+ | sed -n 's/.*"code":"\([^"]*\)".*/\1/p')
22
+ [[ -n "$code" ]] || { echo "no pairing code"; exit 1; }
23
+
24
+ headers=$(curl -fsS -D - -o /dev/null -X POST "http://localhost:$port/api/pair/claim" \
25
+ -H 'Content-Type: application/json' \
26
+ -d "{\"code\":\"$code\",\"name\":\"AgentOS CI\"}")
27
+ token=$(printf '%s' "$headers" | tr -d '\r' | sed -n 's/^[Ss]et-[Cc]ookie: *[^=]*=\([^;]*\).*/\1/p' | head -1)
28
+ [[ -n "$token" ]] || { echo "no device token in the claim response"; exit 1; }
29
+
30
+ curl -fsS -H "Authorization: Bearer $token" "http://localhost:$port/api/tasks" > /dev/null
31
+ echo "machine link path ok (ssh -> pair -> token -> API)"
@@ -4,19 +4,19 @@
4
4
  detect_ai_clis() {
5
5
  local installed=()
6
6
 
7
- command -v claude &> /dev/null && installed+=("claude")
8
- command -v codex &> /dev/null && installed+=("codex")
9
- command -v opencode &> /dev/null && installed+=("opencode")
10
- command -v kilo &> /dev/null && installed+=("kilo")
11
- command -v gemini &> /dev/null && installed+=("gemini")
12
- command -v aider &> /dev/null && installed+=("aider")
13
- command -v cursor &> /dev/null && installed+=("cursor")
7
+ have_cli claude && installed+=("claude")
8
+ have_cli codex && installed+=("codex")
9
+ have_cli opencode && installed+=("opencode")
10
+ have_cli kilo && installed+=("kilo")
11
+ have_cli gemini && installed+=("gemini")
12
+ have_cli aider && installed+=("aider")
13
+ have_cli cursor && installed+=("cursor")
14
14
 
15
15
  echo "${installed[*]}"
16
16
  }
17
17
 
18
18
  install_claude_code() {
19
- if command -v claude &> /dev/null; then
19
+ if have_cli claude; then
20
20
  log_success "Claude Code already installed"
21
21
  return 0
22
22
  fi
@@ -36,7 +36,7 @@ install_claude_code() {
36
36
  }
37
37
 
38
38
  install_codex() {
39
- if command -v codex &> /dev/null; then
39
+ if have_cli codex; then
40
40
  log_success "Codex already installed"
41
41
  return 0
42
42
  fi
@@ -51,16 +51,16 @@ install_codex() {
51
51
  }
52
52
 
53
53
  install_aider() {
54
- if command -v aider &> /dev/null; then
54
+ if have_cli aider; then
55
55
  log_success "Aider already installed"
56
56
  return 0
57
57
  fi
58
58
 
59
59
  log_info "Installing Aider..."
60
60
 
61
- if command -v pipx &> /dev/null; then
61
+ if have_cli pipx; then
62
62
  pipx install aider-chat
63
- elif command -v pip3 &> /dev/null; then
63
+ elif have_cli pip3; then
64
64
  pip3 install aider-chat
65
65
  else
66
66
  log_error "Please install Python/pip first, then run: pip install aider-chat"
@@ -69,7 +69,7 @@ install_aider() {
69
69
  }
70
70
 
71
71
  install_gemini_cli() {
72
- if command -v gemini &> /dev/null; then
72
+ if have_cli gemini; then
73
73
  log_success "Gemini CLI already installed"
74
74
  return 0
75
75
  fi
@@ -84,7 +84,7 @@ install_gemini_cli() {
84
84
  }
85
85
 
86
86
  install_kilocode_cli() {
87
- if command -v kilo &> /dev/null; then
87
+ if have_cli kilo; then
88
88
  log_success "Kilo Code CLI already installed"
89
89
  return 0
90
90
  fi
@@ -309,6 +309,50 @@ cmd_update() {
309
309
  fi
310
310
  }
311
311
 
312
+ # Turn systemd on in wsl.conf: add [boot] systemd=true, keeping the rest of
313
+ # the file, after a backup. Nothing is removed.
314
+ enable_wsl_systemd() {
315
+ local conf="${WSL_CONF:-/etc/wsl.conf}" cur out
316
+ cur=$(mktemp) out=$(mktemp)
317
+ [[ -f "$conf" ]] && cat "$conf" > "$cur"
318
+ if grep -qiE '^[[:space:]]*systemd[[:space:]]*=' "$cur"; then
319
+ awk '{ if (tolower($0) ~ /^[ \t]*systemd[ \t]*=/) print "systemd=true"; else print }' "$cur" > "$out"
320
+ elif grep -qiE '^\[boot\]' "$cur"; then
321
+ awk '{ print } tolower($0) ~ /^\[boot\]/ { print "systemd=true" }' "$cur" > "$out"
322
+ else
323
+ { cat "$cur"; printf '\n[boot]\nsystemd=true\n'; } > "$out"
324
+ fi
325
+ local rc=0
326
+ if [[ -n "${WSL_CONF:-}" || "$(id -u)" == 0 ]]; then
327
+ { [[ ! -f "$conf" ]] || cp "$conf" "$conf.agentos-bak"; } && cp "$out" "$conf" || rc=$?
328
+ else
329
+ { [[ ! -f "$conf" ]] || sudo cp "$conf" "$conf.agentos-bak"; } && sudo cp "$out" "$conf" || rc=$?
330
+ fi
331
+ rm -f "$cur" "$out"
332
+ return $rc
333
+ }
334
+
335
+ # Register the Windows logon task (current user, no admin) and run it once.
336
+ enable_wsl_logon_task() {
337
+ if ! command -v schtasks.exe &> /dev/null; then
338
+ log_warn "schtasks.exe isn't reachable from WSL; keep a terminal open or start the distro from Windows"
339
+ return 0
340
+ fi
341
+ if [[ -z "${WSL_DISTRO_NAME:-}" ]]; then
342
+ log_warn "WSL_DISTRO_NAME isn't set here, so no Windows login task was made. Run 'agent-os enable' from a normal WSL terminal."
343
+ return 0
344
+ fi
345
+ local name launcher
346
+ name=$(wsl_task_name "$WSL_DISTRO_NAME")
347
+ launcher=$(wsl_launcher "$WSL_DISTRO_NAME" 1)
348
+ if schtasks.exe /Create /F /SC ONLOGON /TN "$name" /TR "$launcher" > /dev/null 2>&1; then
349
+ schtasks.exe /Run /TN "$name" > /dev/null 2>&1 || true
350
+ log_success "Windows login task \"$name\" keeps the distro running"
351
+ else
352
+ log_warn "Couldn't create the Windows login task. Without it WSL stops the distro, and AgentOS, when no terminal is open."
353
+ fi
354
+ }
355
+
312
356
  cmd_enable() {
313
357
  if [[ ! -d "$REPO_DIR" ]]; then
314
358
  log_error "AgentOS is not installed. Run 'agent-os install' first."
@@ -378,8 +422,12 @@ EOF
378
422
 
379
423
  systemctl --user daemon-reload
380
424
  systemctl --user enable agent-os
425
+ enable_linger
381
426
  log_success "Auto-start enabled (systemd)"
382
427
  echo " Service: $service_path"
428
+ if [[ "${WSL:-0}" != 0 ]]; then
429
+ enable_wsl_logon_task
430
+ fi
383
431
 
384
432
  elif [[ "${WSL:-0}" != 0 ]]; then
385
433
  log_error "Auto-start under WSL needs systemd. Add this to /etc/wsl.conf:"
@@ -387,6 +435,13 @@ EOF
387
435
  echo " systemd=true"
388
436
  echo " then run 'wsl --shutdown' from Windows, reopen your terminal, and run"
389
437
  echo " 'agent-os enable' again."
438
+ if is_interactive && prompt_yn "Add it to ${WSL_CONF:-/etc/wsl.conf} for you?" "y"; then
439
+ if enable_wsl_systemd; then
440
+ echo ""
441
+ log_success "systemd is on for the next start. Run 'wsl --shutdown' from Windows, reopen, then 'agent-os enable'."
442
+ return 0
443
+ fi
444
+ fi
390
445
  exit 1
391
446
 
392
447
  else
@@ -411,7 +466,10 @@ cmd_disable() {
411
466
  systemctl --user disable agent-os 2>/dev/null || true
412
467
  rm -f "$HOME/.config/systemd/user/agent-os.service"
413
468
  systemctl --user daemon-reload
414
- log_success "Auto-start disabled"
469
+ if [[ "${WSL:-0}" != 0 && -n "${WSL_DISTRO_NAME:-}" ]] && command -v schtasks.exe &> /dev/null; then
470
+ schtasks.exe /Delete /F /TN "$(wsl_task_name "$WSL_DISTRO_NAME")" > /dev/null 2>&1 || true
471
+ fi
472
+ log_success "Auto-start disabled (linger is left on; other services may use it)"
415
473
 
416
474
  else
417
475
  log_error "Could not detect init system"
@@ -503,6 +561,7 @@ cmd_help() {
503
561
  echo " passkeys reset Revoke every passkey (recovery; asks you to confirm)"
504
562
  echo " logs Tail server logs"
505
563
  echo " update Update to latest version"
564
+ echo " machine Make this Linux box or WSL distro linkable from another AgentOS"
506
565
  echo " enable Enable auto-start on boot"
507
566
  echo " disable Disable auto-start"
508
567
  echo " uninstall Remove AgentOS completely"
@@ -132,3 +132,45 @@ get_tailscale_ip() {
132
132
  tailscale ip -4 2>/dev/null | head -1
133
133
  fi
134
134
  }
135
+
136
+ # Is this CLI installed here? Under WSL the Windows PATH is appended, and a
137
+ # CLI only installed on Windows (/mnt/c/...) doesn't count: it would run with
138
+ # a Windows home and config.
139
+ have_cli() {
140
+ local path
141
+ path=$(command -v "$1" 2>/dev/null) || return 1
142
+ if [[ "${WSL:-0}" != 0 && "$path" == /mnt/[a-zA-Z]/* ]]; then
143
+ return 1
144
+ fi
145
+ return 0
146
+ }
147
+
148
+ # The Windows logon task that keeps the WSL distro running. WSL stops an idle
149
+ # distro seconds after its last process exits, service and all, so this holds
150
+ # one `sleep infinity` open from Windows login on.
151
+ wsl_task_name() {
152
+ echo "AgentOS ($1)"
153
+ }
154
+
155
+ # conhost --headless hides the console window (Windows 11); the plain wsl.exe
156
+ # fallback flashes one on older builds.
157
+ wsl_launcher() {
158
+ local distro="$1" headless="${2:-1}"
159
+ if [[ "$headless" == 1 ]]; then
160
+ echo "conhost.exe --headless wsl.exe -d $distro --exec /bin/sleep infinity"
161
+ else
162
+ echo "wsl.exe -d $distro --exec /bin/sleep infinity"
163
+ fi
164
+ }
165
+
166
+ # Keep user services running without a login session (a headless box, or a
167
+ # WSL distro with no terminal open). Tries without sudo first.
168
+ enable_linger() {
169
+ command -v loginctl &> /dev/null || return 0
170
+ local user="${USER:-$(id -un)}"
171
+ if loginctl enable-linger "$user" 2>/dev/null \
172
+ || sudo -n loginctl enable-linger "$user" 2>/dev/null; then
173
+ return 0
174
+ fi
175
+ log_warn "Couldn't turn on linger, so AgentOS stops when you log out. Run: sudo loginctl enable-linger $user"
176
+ }
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env bash
2
+ # `agent-os machine`: make this Linux box (a WSL distro, a DGX Spark) linkable
3
+ # from another AgentOS: sshd for pairing, Tailscale for reaching it, the
4
+ # server kept running. It never edits sshd_config or authorized_keys.
5
+
6
+ # The ssh service's unit name differs by distro (Debian: ssh, others: sshd).
7
+ ssh_unit() {
8
+ if systemctl list-unit-files ssh.service 2>/dev/null | grep -q '^ssh.service'; then
9
+ echo ssh
10
+ else
11
+ echo sshd
12
+ fi
13
+ }
14
+
15
+ # MagicDNS name of this node (no trailing dot), empty when Tailscale is off.
16
+ tailscale_name() {
17
+ command -v tailscale &> /dev/null || return 0
18
+ tailscale status --json 2>/dev/null \
19
+ | tr ',' '\n' \
20
+ | sed -n 's/.*"DNSName":"\([^"]*\)".*/\1/p' \
21
+ | head -1 | sed 's/\.$//'
22
+ }
23
+
24
+ cmd_machine() {
25
+ if [[ "$OS" == "macos" ]]; then
26
+ log_info "A Mac is linked from the other side: on the machine you'll link from, open Machines and add this Mac."
27
+ return 0
28
+ fi
29
+ if [[ ! -d "$REPO_DIR" ]]; then
30
+ log_error "AgentOS is not installed. Run 'agent-os install' first."
31
+ exit 1
32
+ fi
33
+ if ! has_systemd; then
34
+ if [[ "${WSL:-0}" != 0 ]]; then
35
+ log_error "This needs systemd. Run 'agent-os enable', which offers to turn it on, then try again."
36
+ else
37
+ log_error "This needs systemd to keep sshd and AgentOS running."
38
+ fi
39
+ exit 1
40
+ fi
41
+
42
+ if ! command -v sshd &> /dev/null && [[ ! -x "${SSHD_FALLBACK-/usr/sbin/sshd}" ]]; then
43
+ if [[ "$OS" == "debian" ]]; then
44
+ log_info "Installing openssh-server..."
45
+ apt_install openssh-server || { log_error "Couldn't install openssh-server"; exit 1; }
46
+ else
47
+ log_error "Install your distro's OpenSSH server package, then run this again."
48
+ exit 1
49
+ fi
50
+ fi
51
+ if ! sudo systemctl enable --now "$(ssh_unit)"; then
52
+ log_error "Couldn't start sshd"
53
+ exit 1
54
+ fi
55
+ log_success "sshd is running"
56
+
57
+ local name
58
+ name=$(tailscale_name)
59
+ if ! command -v tailscale &> /dev/null; then
60
+ log_warn "Tailscale isn't installed here. Install it inside this machine (not just on Windows):"
61
+ echo " https://tailscale.com/download/linux"
62
+ echo " then 'sudo tailscale up' and run 'agent-os machine' again."
63
+ exit 1
64
+ elif [[ -z "$name" ]]; then
65
+ log_warn "Tailscale is installed but this machine isn't signed in. Run: sudo tailscale up"
66
+ echo " then run 'agent-os machine' again."
67
+ exit 1
68
+ fi
69
+
70
+ cmd_enable
71
+ # The server follows tailnet address changes on its own; it only needs to be up.
72
+ is_running || cmd_start
73
+
74
+ local user="${USER:-$(id -un)}" ip
75
+ ip=$(get_tailscale_ip)
76
+ echo ""
77
+ log_success "Ready to link. On the machine you'll link from (any AgentOS: a Mac, Linux or WSL), open Machines → Add:"
78
+ echo " ssh host $name"
79
+ echo " user $user"
80
+ echo " url http://$name:$PORT"
81
+ echo ""
82
+ echo " If that machine can't ssh in yet, run this on it first:"
83
+ echo " ssh-copy-id $user@$name"
84
+ if [[ -n "$ip" ]] && ! curl -sf "http://$ip:$PORT/api/health" > /dev/null 2>&1; then
85
+ log_warn "The server isn't answering on the tailnet address $ip yet. Check 'agent-os logs'."
86
+ fi
87
+ }
@@ -0,0 +1,141 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import {
3
+ chmodSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ symlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
13
+ import { tempDir } from "@/lib/test-temp";
14
+
15
+ // `agent-os machine` on any OS: systemctl, sshd, tailscale and the server
16
+ // commands are fakes that record what they were asked.
17
+ const LIB = join(import.meta.dirname);
18
+ const TOOLS = [
19
+ "tr",
20
+ "cat",
21
+ "sed",
22
+ "head",
23
+ "grep",
24
+ "mkdir",
25
+ "id",
26
+ "mktemp",
27
+ "cp",
28
+ "rm",
29
+ ];
30
+
31
+ let dir: string;
32
+
33
+ function fake(name: string, body: string) {
34
+ const p = join(dir, "bin", name);
35
+ writeFileSync(p, `#!/bin/sh\n${body}\n`);
36
+ chmodSync(p, 0o755);
37
+ }
38
+ const log = () =>
39
+ existsSync(join(dir, "calls.log"))
40
+ ? readFileSync(join(dir, "calls.log"), "utf8")
41
+ : "";
42
+ const record = (name: string) =>
43
+ fake(name, `echo "${name} $*" >> "${dir}/calls.log"`);
44
+
45
+ function machine(env: Record<string, string> = {}, os = "debian") {
46
+ mkdirSync(join(dir, "repo"), { recursive: true });
47
+ return spawnSync(
48
+ "/bin/bash",
49
+ [
50
+ "-euo",
51
+ "pipefail",
52
+ "-c",
53
+ `source "${LIB}/common.sh"; source "${LIB}/prerequisites.sh"; source "${LIB}/commands.sh"; source "${LIB}/machine.sh"; WSL=2 OS=${os} REPO_DIR="${dir}/repo" PORT=3011; has_systemd() { return 0; }; is_running() { return 0; }; cmd_enable() { echo "enable called"; }; cmd_machine`,
54
+ join(dir, "agent-os"),
55
+ ],
56
+ {
57
+ cwd: dir,
58
+ encoding: "utf8",
59
+ env: {
60
+ PATH: join(dir, "bin"),
61
+ HOME: dir,
62
+ USER: "tester",
63
+ NODE_ENV: "test",
64
+ SSHD_FALLBACK: "",
65
+ ...env,
66
+ },
67
+ }
68
+ );
69
+ }
70
+
71
+ beforeEach(() => {
72
+ dir = tempDir("aos-machine-");
73
+ mkdirSync(join(dir, "bin"));
74
+ for (const tool of TOOLS) {
75
+ const real = ["/usr/bin", "/bin"]
76
+ .map((d) => join(d, tool))
77
+ .find(existsSync);
78
+ if (real) symlinkSync(real, join(dir, "bin", tool));
79
+ }
80
+ fake("uname", 'echo "Linux"');
81
+ record("sudo");
82
+ fake("sshd", "true");
83
+ fake("systemctl", "exit 0");
84
+ fake("curl", "exit 0");
85
+ });
86
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
87
+
88
+ describe("agent-os machine", () => {
89
+ it("stops with the install pointer when Tailscale is missing", () => {
90
+ const r = machine();
91
+ expect(r.status).toBe(1);
92
+ expect(r.stdout).toContain("Tailscale isn't installed");
93
+ expect(log()).toContain("sudo systemctl enable --now sshd");
94
+ });
95
+
96
+ it("stops with 'tailscale up' when signed out", () => {
97
+ fake("tailscale", "echo '{\"Self\":{}}'");
98
+ const r = machine();
99
+ expect(r.status).toBe(1);
100
+ expect(r.stdout).toContain("sudo tailscale up");
101
+ });
102
+
103
+ it("prints the values for the machine you'll link from, never a Mac-only line", () => {
104
+ fake(
105
+ "tailscale",
106
+ 'case "$1" in ip) echo 100.64.0.9;; *) echo \'{"Self":{"DNSName":"pc-wsl.tail1234.ts.net."}}\';; esac'
107
+ );
108
+ const r = machine();
109
+ expect(r.status).toBe(0);
110
+ expect(r.stdout).toContain("enable called");
111
+ expect(r.stdout).toContain("pc-wsl.tail1234.ts.net");
112
+ expect(r.stdout).toContain("http://pc-wsl.tail1234.ts.net:3011");
113
+ expect(r.stdout).toContain("ssh-copy-id tester@pc-wsl.tail1234.ts.net");
114
+ expect(r.stdout).toContain("the machine you'll link from");
115
+ expect(r.stdout).not.toMatch(/on the mac\b/i);
116
+ });
117
+
118
+ it("installs openssh-server on Debian when sshd is missing, and never touches sshd_config", () => {
119
+ rmSync(join(dir, "bin", "sshd"));
120
+ machine();
121
+ expect(log()).toContain("sudo apt-get install -y openssh-server");
122
+ expect(log()).not.toMatch(/sshd_config|authorized_keys/);
123
+ });
124
+
125
+ it("needs systemd", () => {
126
+ mkdirSync(join(dir, "repo"), { recursive: true });
127
+ const r = spawnSync(
128
+ "/bin/bash",
129
+ [
130
+ "-c",
131
+ `source "${LIB}/common.sh"; source "${LIB}/machine.sh"; WSL=2 OS=debian REPO_DIR="${dir}/repo"; has_systemd() { return 1; }; cmd_machine`,
132
+ ],
133
+ {
134
+ encoding: "utf8",
135
+ env: { PATH: join(dir, "bin"), HOME: dir, NODE_ENV: "test" },
136
+ }
137
+ );
138
+ expect(r.status).toBe(1);
139
+ expect(r.stdout + r.stderr).toContain("systemd");
140
+ });
141
+ });
@@ -18,7 +18,20 @@ import { tempDir } from "@/lib/test-temp";
18
18
  // tools) and the environment only this, so a run inside WSL sees neither
19
19
  // the real wslview nor WSL_DISTRO_NAME.
20
20
  const LIB = join(import.meta.dirname);
21
- const TOOLS = ["tr", "cat", "sed", "cut", "awk", "head", "grep", "mkdir"];
21
+ const TOOLS = [
22
+ "tr",
23
+ "cat",
24
+ "sed",
25
+ "cut",
26
+ "awk",
27
+ "head",
28
+ "grep",
29
+ "mkdir",
30
+ "mktemp",
31
+ "cp",
32
+ "rm",
33
+ "id",
34
+ ];
22
35
 
23
36
  let dir: string;
24
37
 
@@ -170,6 +183,101 @@ describe("agent-os enable", () => {
170
183
  });
171
184
  });
172
185
 
186
+ describe("agent-os enable keeping the box up", () => {
187
+ const log = (n: string) =>
188
+ existsSync(join(dir, n)) ? readFileSync(join(dir, n), "utf8") : "";
189
+ const setup = () => {
190
+ fake("ps", "echo systemd");
191
+ fake("systemctl", 'echo "systemctl $*" >> "' + dir + '/systemctl.log"');
192
+ fake("loginctl", 'echo "loginctl $*" >> "' + dir + '/loginctl.log"');
193
+ fake("schtasks.exe", 'echo "schtasks $*" >> "' + dir + '/schtasks.log"');
194
+ mkdirSync(join(dir, "repo"));
195
+ };
196
+ const enable = (env: Record<string, string> = {}) =>
197
+ run(`WSL=2 OS=debian REPO_DIR="${dir}/repo"; cmd_enable`, "", {
198
+ USER: "tester",
199
+ WSL_DISTRO_NAME: "Ubuntu-24.04",
200
+ ...env,
201
+ });
202
+
203
+ it("turns on linger and registers a Windows login task that runs once", () => {
204
+ setup();
205
+ const r = enable();
206
+ expect(r.status).toBe(0);
207
+ expect(log("loginctl.log")).toContain("loginctl enable-linger tester");
208
+ const task = log("schtasks.log");
209
+ expect(task).toContain(
210
+ "/Create /F /SC ONLOGON /TN AgentOS (Ubuntu-24.04) /TR conhost.exe --headless wsl.exe -d Ubuntu-24.04 --exec /bin/sleep infinity"
211
+ );
212
+ expect(task).toContain("/Run /TN AgentOS (Ubuntu-24.04)");
213
+ });
214
+
215
+ it("makes no Windows task off WSL, and none without a distro name", () => {
216
+ setup();
217
+ run(`WSL=0 OS=debian REPO_DIR="${dir}/repo"; cmd_enable`, "", {
218
+ USER: "tester",
219
+ });
220
+ expect(log("schtasks.log")).toBe("");
221
+ expect(log("loginctl.log")).toContain("enable-linger");
222
+ const r = enable({ WSL_DISTRO_NAME: "" });
223
+ expect(r.stdout + r.stderr).toContain("WSL_DISTRO_NAME");
224
+ expect(log("schtasks.log")).toBe("");
225
+ });
226
+
227
+ it("disable deletes the Windows task", () => {
228
+ setup();
229
+ const r = run(`WSL=2 OS=debian; cmd_disable`, "", {
230
+ WSL_DISTRO_NAME: "Ubuntu-24.04",
231
+ });
232
+ expect(r.status).toBe(0);
233
+ expect(log("schtasks.log")).toContain(
234
+ "/Delete /F /TN AgentOS (Ubuntu-24.04)"
235
+ );
236
+ });
237
+
238
+ it("adds systemd=true to wsl.conf, keeping the rest and a backup", () => {
239
+ const conf = join(dir, "wsl.conf");
240
+ writeFileSync(conf, "[user]\ndefault=me\n[boot]\ncommand=true\n");
241
+ const r = run("enable_wsl_systemd", "", { WSL_CONF: conf });
242
+ expect(r.status).toBe(0);
243
+ expect(readFileSync(conf, "utf8")).toBe(
244
+ "[user]\ndefault=me\n[boot]\nsystemd=true\ncommand=true\n"
245
+ );
246
+ expect(readFileSync(conf + ".agentos-bak", "utf8")).toContain(
247
+ "command=true"
248
+ );
249
+ });
250
+
251
+ it("creates [boot] when missing, and flips systemd=false", () => {
252
+ const conf = join(dir, "wsl.conf");
253
+ run("enable_wsl_systemd", "", { WSL_CONF: conf });
254
+ expect(readFileSync(conf, "utf8")).toContain("[boot]\nsystemd=true");
255
+ writeFileSync(conf, "[boot]\nsystemd=false\n");
256
+ run("enable_wsl_systemd", "", { WSL_CONF: conf });
257
+ expect(readFileSync(conf, "utf8")).toBe("[boot]\nsystemd=true\n");
258
+ });
259
+ });
260
+
261
+ describe("have_cli", () => {
262
+ it("ignores a CLI that only exists on the Windows drive, under WSL", () => {
263
+ const win = join(dir, "mnt", "c");
264
+ mkdirSync(win, { recursive: true });
265
+ const shim = join(win, "claude");
266
+ writeFileSync(shim, "#!/bin/sh\n");
267
+ chmodSync(shim, 0o755);
268
+ // The path has no /mnt/c prefix here, so check the pattern with a stub.
269
+ fake("command", "true");
270
+ const hit = (wsl: string, p: string) =>
271
+ run(
272
+ `WSL=${wsl}; command() { echo "${p}"; }; have_cli claude && echo yes || echo no`,
273
+ ""
274
+ ).stdout.trim();
275
+ expect(hit("2", "/mnt/c/Users/me/npm/claude")).toBe("no");
276
+ expect(hit("2", "/home/me/.local/bin/claude")).toBe("yes");
277
+ expect(hit("0", "/mnt/c/Users/me/npm/claude")).toBe("yes");
278
+ });
279
+ });
280
+
173
281
  describe("installing prerequisites", () => {
174
282
  // sudo runs nothing, it only records what it was asked.
175
283
  const sudoLog = () => {