@saadnvd1/agent-os 0.3.693 → 0.3.697

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.
@@ -9,7 +9,7 @@ import { useApprovalsModeQuery, useSetApprovalsMode } from "@/data/approvals";
9
9
  export const NO_APPROVALS_WARNING = [
10
10
  "Every ask is approved the moment it is raised, hard lines included: public posts and messages, money, irreversible or destructive changes, credentials.",
11
11
  "A PR stuck failing its gates, with no CI, or too big to review still merges.",
12
- "Brakes stop holding new starts, and plan-only tasks go on to implement without waiting for a plan approval.",
12
+ "Brakes stop holding new starts. Plan-only tasks still wait for approve_plan.",
13
13
  "Every agent AgentOS starts runs with its permission prompts off, and prompts already waiting are allowed.",
14
14
  ];
15
15
 
@@ -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;
@@ -2,9 +2,7 @@
2
2
  // brings what already exists in line with it.
3
3
 
4
4
  import { db } from "../db";
5
- import { isRemoteHost } from "../hosts";
6
5
  import { reapplyChatModes } from "../chat/settings";
7
- import { approvePlanNow } from "../models/plan-step";
8
6
  import { autoApproveAsk, type AskRow } from "../orchestrator/asks";
9
7
  import { addNote } from "../orchestrator/notes";
10
8
  import { listOrchestrators } from "../orchestrator/home";
@@ -15,7 +13,7 @@ import {
15
13
  } from "./no-approvals";
16
14
 
17
15
  // Everything already waiting when the mode goes on is approved: open asks,
18
- // plan-only tasks at their plan, tool cards in live chats.
16
+ // tool cards in live chats.
19
17
  async function sweep(): Promise<void> {
20
18
  const asks = db
21
19
  .prepare(`SELECT * FROM orchestrator_asks WHERE status = 'open'`)
@@ -27,24 +25,6 @@ async function sweep(): Promise<void> {
27
25
  console.error(`auto-approving ask ${ask.id} failed:`, e);
28
26
  }
29
27
  }
30
- const plans = db
31
- .prepare(
32
- `SELECT id, host_id, plan_waits_for_caller FROM session_rows WHERE plan_phase = 'awaiting' AND task_status = 'running'`
33
- )
34
- .all() as {
35
- id: string;
36
- host_id: string;
37
- plan_waits_for_caller: number | null;
38
- }[];
39
- for (const p of plans) {
40
- // A plan placed here by another machine is that machine's to decide.
41
- if (p.plan_waits_for_caller && !isRemoteHost(p.host_id)) continue;
42
- try {
43
- await approvePlanNow(p.id, undefined, "no-approvals");
44
- } catch (e) {
45
- console.error(`auto-approving plan of ${p.id} failed:`, e);
46
- }
47
- }
48
28
  }
49
29
 
50
30
  export async function switchNoApprovals(on: boolean): Promise<void> {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * No approvals mode: while it's on, nothing AgentOS starts or runs waits on
3
3
  * a person. Asks are approved as they're raised, brakes let starts through,
4
- * plan-only tasks go on to implement, and agents launch with their harness's
4
+ * and agents launch with their harness's
5
5
  * permission prompts off. Off by default; app-wide; every point that honours
6
6
  * it reads it here and records what it did with recordAutoApproval.
7
7
  */
@@ -19,7 +19,7 @@ import {
19
19
  } from "./resolve";
20
20
  import { ANTHROPIC } from "./roles";
21
21
  import { queueEvent } from "../orchestrator/events";
22
- import { noApprovalsOn, recordAutoApproval } from "../approvals/no-approvals";
22
+ import { recordAutoApproval } from "../approvals/no-approvals";
23
23
  import { addNote } from "../orchestrator/notes";
24
24
  import { amendBrief, scopeChangesOf } from "../orchestrator/amendments";
25
25
  import { PIECES_PROMPT, foldedBrief, pieceBrief } from "../plans/pieces";
@@ -451,15 +451,15 @@ export async function handoffAfterPlan(
451
451
  deleteQueued(sessionId, planRetryMessageId(sessionId));
452
452
  const retried = waiting.get(sessionId)?.retried ?? false;
453
453
  forget(sessionId);
454
- // No approvals mode doesn't stop at the plan to wait for one. Pieces that
455
- // couldn't be registered always do: building on a guess is the bug.
456
- const auto = !!s.plan_only && noApprovalsOn() && !s.plan_waits_for_caller;
454
+ // A plan-only task never goes on by itself, in No approvals mode too: it
455
+ // was asked to stop and show the plan. Pieces that couldn't be registered
456
+ // always hold a plan-first one: building on a guess is the bug.
457
457
  const planText = readPlan(s);
458
458
  const pieceErrors =
459
459
  (planText && !s.plan_waits_for_caller
460
460
  ? splitOf(s, planText)?.errors
461
461
  : null) ?? [];
462
- if ((!s.plan_only || auto) && !pieceErrors.length) {
462
+ if (!s.plan_only && !pieceErrors.length) {
463
463
  const ref = await startImplementing(
464
464
  s,
465
465
  "planning",
@@ -467,14 +467,6 @@ export async function handoffAfterPlan(
467
467
  undefined,
468
468
  retried
469
469
  );
470
- if (ref && auto)
471
- recordAutoApproval({
472
- source: "plan",
473
- workspaceId: sessionWorkspace(s),
474
- sessionId,
475
- subject: `plan for ${s.name}`,
476
- detail: "implementing without waiting for approve_plan",
477
- });
478
470
  return ref !== null;
479
471
  }
480
472
  if (!advancePlan(db, sessionId, "planning", "awaiting")) return false;
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",
@@ -16,10 +16,6 @@ import { isRemoteHost } from "../../hosts";
16
16
  import { peerSource } from "../../hosts/peer-load";
17
17
  import { hostLink, requireHostLink } from "../../hosts/remote-api";
18
18
  import { LOCAL, machineCapabilities } from "../../load/machines";
19
- import {
20
- noApprovalsOn,
21
- recordAutoApproval,
22
- } from "../../approvals/no-approvals";
23
19
  import { implementArrived, planNoteFor } from "../../models/plan-step";
24
20
  import { sessionWorkspace } from "../../models/resolve";
25
21
  import { queueEvent } from "../../orchestrator/events";
@@ -112,8 +108,7 @@ export async function planWritten(
112
108
  // As for a task planned here: a plan-first task goes on by itself, a
113
109
  // plan-only one waits unless no-approvals is on, and pieces that couldn't
114
110
  // be registered always hold it.
115
- const auto = !!fresh.plan_only && noApprovalsOn();
116
- const goOn = (!fresh.plan_only || auto) && !errors.length;
111
+ const goOn = !fresh.plan_only && !errors.length;
117
112
  const target = goOn ? implementTarget(fresh, split) : null;
118
113
  const next =
119
114
  target === "there"
@@ -148,14 +143,6 @@ export async function planWritten(
148
143
  return;
149
144
  }
150
145
  await continuePlanThere(fresh);
151
- if (auto)
152
- recordAutoApproval({
153
- source: "plan",
154
- workspaceId,
155
- sessionId: s.id,
156
- subject: `plan for ${s.name}`,
157
- detail: "implementing without waiting for approve_plan",
158
- });
159
146
  }
160
147
 
161
148
  /**
@@ -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
+ }
@@ -37,6 +37,7 @@ export const TEST_TEMP_PREFIXES: readonly string[] = [
37
37
  "aos-kind-",
38
38
  "aos-launch-plain-",
39
39
  "aos-launch-repo-",
40
+ "aos-machine-",
40
41
  "aos-limits-",
41
42
  "aos-mcp-",
42
43
  "aos-merge-policy-",
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.697",
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)"
@@ -13,7 +13,12 @@ source scripts/lib/pnpm.sh
13
13
  if [ -s "$HOME/.nvm/nvm.sh" ]; then
14
14
  # shellcheck disable=SC1091
15
15
  source "$HOME/.nvm/nvm.sh" >/dev/null
16
- nvm use >/dev/null
16
+ # A box with nvm but without .nvmrc's version (a CI runner) keeps the node on PATH.
17
+ nvm use >/dev/null 2>&1 || true
18
+ fi
19
+ if [ "$(node -p 'process.versions.node.split(".")[0]')" -lt 22 ]; then
20
+ echo "Node 22 or newer is required (found $(node -v)); run: nvm install" >&2
21
+ exit 1
17
22
  fi
18
23
  use_pnpm
19
24
 
@@ -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
@@ -189,7 +189,7 @@ cmd_stop() {
189
189
  while ps -p "$pid" &> /dev/null && [[ $count -lt 10 ]]; do
190
190
  printf "."
191
191
  sleep 1
192
- ((count++))
192
+ count=$((count + 1))
193
193
  done
194
194
  echo ""
195
195
 
@@ -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,26 @@ 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
+ ];
35
+
36
+ // Bash 4+ (Linux, CI) fails `set -e` on `((n++))` at 0; macOS's 3.2 doesn't.
37
+ const BASH =
38
+ ["/opt/homebrew/bin/bash", "/usr/local/bin/bash", "/bin/bash"].find(
39
+ existsSync
40
+ ) ?? "/bin/bash";
22
41
 
23
42
  let dir: string;
24
43
 
@@ -35,7 +54,7 @@ function run(
35
54
  ) {
36
55
  writeFileSync(join(dir, "osrelease"), osrelease);
37
56
  return spawnSync(
38
- "/bin/bash",
57
+ BASH,
39
58
  [
40
59
  "-euo",
41
60
  "pipefail",
@@ -170,6 +189,101 @@ describe("agent-os enable", () => {
170
189
  });
171
190
  });
172
191
 
192
+ describe("agent-os enable keeping the box up", () => {
193
+ const log = (n: string) =>
194
+ existsSync(join(dir, n)) ? readFileSync(join(dir, n), "utf8") : "";
195
+ const setup = () => {
196
+ fake("ps", "echo systemd");
197
+ fake("systemctl", 'echo "systemctl $*" >> "' + dir + '/systemctl.log"');
198
+ fake("loginctl", 'echo "loginctl $*" >> "' + dir + '/loginctl.log"');
199
+ fake("schtasks.exe", 'echo "schtasks $*" >> "' + dir + '/schtasks.log"');
200
+ mkdirSync(join(dir, "repo"));
201
+ };
202
+ const enable = (env: Record<string, string> = {}) =>
203
+ run(`WSL=2 OS=debian REPO_DIR="${dir}/repo"; cmd_enable`, "", {
204
+ USER: "tester",
205
+ WSL_DISTRO_NAME: "Ubuntu-24.04",
206
+ ...env,
207
+ });
208
+
209
+ it("turns on linger and registers a Windows login task that runs once", () => {
210
+ setup();
211
+ const r = enable();
212
+ expect(r.status).toBe(0);
213
+ expect(log("loginctl.log")).toContain("loginctl enable-linger tester");
214
+ const task = log("schtasks.log");
215
+ expect(task).toContain(
216
+ "/Create /F /SC ONLOGON /TN AgentOS (Ubuntu-24.04) /TR conhost.exe --headless wsl.exe -d Ubuntu-24.04 --exec /bin/sleep infinity"
217
+ );
218
+ expect(task).toContain("/Run /TN AgentOS (Ubuntu-24.04)");
219
+ });
220
+
221
+ it("makes no Windows task off WSL, and none without a distro name", () => {
222
+ setup();
223
+ run(`WSL=0 OS=debian REPO_DIR="${dir}/repo"; cmd_enable`, "", {
224
+ USER: "tester",
225
+ });
226
+ expect(log("schtasks.log")).toBe("");
227
+ expect(log("loginctl.log")).toContain("enable-linger");
228
+ const r = enable({ WSL_DISTRO_NAME: "" });
229
+ expect(r.stdout + r.stderr).toContain("WSL_DISTRO_NAME");
230
+ expect(log("schtasks.log")).toBe("");
231
+ });
232
+
233
+ it("disable deletes the Windows task", () => {
234
+ setup();
235
+ const r = run(`WSL=2 OS=debian; cmd_disable`, "", {
236
+ WSL_DISTRO_NAME: "Ubuntu-24.04",
237
+ });
238
+ expect(r.status).toBe(0);
239
+ expect(log("schtasks.log")).toContain(
240
+ "/Delete /F /TN AgentOS (Ubuntu-24.04)"
241
+ );
242
+ });
243
+
244
+ it("adds systemd=true to wsl.conf, keeping the rest and a backup", () => {
245
+ const conf = join(dir, "wsl.conf");
246
+ writeFileSync(conf, "[user]\ndefault=me\n[boot]\ncommand=true\n");
247
+ const r = run("enable_wsl_systemd", "", { WSL_CONF: conf });
248
+ expect(r.status).toBe(0);
249
+ expect(readFileSync(conf, "utf8")).toBe(
250
+ "[user]\ndefault=me\n[boot]\nsystemd=true\ncommand=true\n"
251
+ );
252
+ expect(readFileSync(conf + ".agentos-bak", "utf8")).toContain(
253
+ "command=true"
254
+ );
255
+ });
256
+
257
+ it("creates [boot] when missing, and flips systemd=false", () => {
258
+ const conf = join(dir, "wsl.conf");
259
+ run("enable_wsl_systemd", "", { WSL_CONF: conf });
260
+ expect(readFileSync(conf, "utf8")).toContain("[boot]\nsystemd=true");
261
+ writeFileSync(conf, "[boot]\nsystemd=false\n");
262
+ run("enable_wsl_systemd", "", { WSL_CONF: conf });
263
+ expect(readFileSync(conf, "utf8")).toBe("[boot]\nsystemd=true\n");
264
+ });
265
+ });
266
+
267
+ describe("have_cli", () => {
268
+ it("ignores a CLI that only exists on the Windows drive, under WSL", () => {
269
+ const win = join(dir, "mnt", "c");
270
+ mkdirSync(win, { recursive: true });
271
+ const shim = join(win, "claude");
272
+ writeFileSync(shim, "#!/bin/sh\n");
273
+ chmodSync(shim, 0o755);
274
+ // The path has no /mnt/c prefix here, so check the pattern with a stub.
275
+ fake("command", "true");
276
+ const hit = (wsl: string, p: string) =>
277
+ run(
278
+ `WSL=${wsl}; command() { echo "${p}"; }; have_cli claude && echo yes || echo no`,
279
+ ""
280
+ ).stdout.trim();
281
+ expect(hit("2", "/mnt/c/Users/me/npm/claude")).toBe("no");
282
+ expect(hit("2", "/home/me/.local/bin/claude")).toBe("yes");
283
+ expect(hit("0", "/mnt/c/Users/me/npm/claude")).toBe("yes");
284
+ });
285
+ });
286
+
173
287
  describe("installing prerequisites", () => {
174
288
  // sudo runs nothing, it only records what it was asked.
175
289
  const sudoLog = () => {
@@ -205,3 +319,47 @@ describe("installing prerequisites", () => {
205
319
  expect(r.stdout).not.toContain("Prerequisites installed");
206
320
  });
207
321
  });
322
+
323
+ describe("cmd_stop", () => {
324
+ // `ps` reports the server alive for the first `alive` checks, then gone.
325
+ // A server that takes a moment to exit used to trip `((count++))` (status 1
326
+ // at 0) under `set -e`, so stop exited 1 after the server had stopped.
327
+ const stop = (alive: number) => {
328
+ mkdirSync(join(dir, "home"), { recursive: true });
329
+ fake(
330
+ "ps",
331
+ `n=$(cat "${dir}/ps-count" 2>/dev/null || echo 0); echo $((n + 1)) > "${dir}/ps-count"; [ "$n" -lt ${alive} ]`
332
+ );
333
+ fake("sleep", "exit 0");
334
+ return run(
335
+ `AGENT_OS_HOME="${dir}/home"; PID_FILE="$AGENT_OS_HOME/agent-os.pid"; echo $$ > "$PID_FILE"; kill() { return 0; }; cmd_stop`,
336
+ ""
337
+ );
338
+ };
339
+
340
+ it("returns 0 when the server exits after a few checks", () => {
341
+ const r = stop(3);
342
+ expect(r.status).toBe(0);
343
+ expect(r.stdout).toContain("AgentOS stopped");
344
+ expect(existsSync(join(dir, "home", "agent-os.pid"))).toBe(false);
345
+ });
346
+
347
+ it("force kills and still returns 0 when it outlives the timeout", () => {
348
+ const r = stop(1000);
349
+ expect(r.status).toBe(0);
350
+ expect(r.stdout).toContain("Force killing");
351
+ });
352
+
353
+ it("has no bare ((x++)) in scripts/lib: it returns 1 at 0 under set -e", () => {
354
+ for (const f of [
355
+ "commands.sh",
356
+ "common.sh",
357
+ "machine.sh",
358
+ "prerequisites.sh",
359
+ ]) {
360
+ expect(readFileSync(join(LIB, f), "utf8"), f).not.toMatch(
361
+ /^\s*\(\(\s*\w+(\+\+|--)\s*\)\)\s*$/m
362
+ );
363
+ }
364
+ });
365
+ });