@saadnvd1/agent-os 0.3.692 → 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.
@@ -61,6 +61,7 @@ export async function POST(request: NextRequest) {
61
61
  const { session, initialPrompt, repeat } = await launchSession({
62
62
  id: str(body.id),
63
63
  projectId: str(body.projectId),
64
+ workspaceId: str(body.workspaceId),
64
65
  project: projectRefOf(body.project),
65
66
  workingDirectory: str(body.workingDirectory),
66
67
  agentType:
@@ -98,11 +98,16 @@ export function DraftChips({
98
98
  }, [hostGone, onChange]);
99
99
 
100
100
  const pickProject = (id: string | null) => {
101
- const next = newDraft(draft.id, real.find((p) => p.id === id) ?? null, {
102
- agentType: draft.agentType,
103
- model: draft.model,
104
- access: draft.access,
105
- });
101
+ const next = newDraft(
102
+ draft.id,
103
+ real.find((p) => p.id === id) ?? null,
104
+ {
105
+ agentType: draft.agentType,
106
+ model: draft.model,
107
+ access: draft.access,
108
+ },
109
+ { workspaceId: draft.workspaceId }
110
+ );
106
111
  onChange({
107
112
  ...next,
108
113
  openPr: !!id && draft.openPr,
@@ -12,6 +12,7 @@ import { discoversModels } from "@/lib/model-catalog";
12
12
  import { useDiscoveredModels } from "@/data/agents";
13
13
  import { useHostsQuery } from "@/data/hosts";
14
14
  import { useGitCheck } from "@/data/git/queries";
15
+ import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
15
16
  import { useLaunchSession } from "@/data/sessions";
16
17
  import { useCreateTask } from "@/data/tasks";
17
18
  import { usePanes } from "@/contexts/PaneContext";
@@ -50,6 +51,7 @@ export function DraftPanel({
50
51
  discovers
51
52
  );
52
53
  const launch = useLaunchSession();
54
+ const { workspace } = useSelectedWorkspace();
53
55
  const createTask = useCreateTask();
54
56
  const [prefill, setPrefill] = useState<{ text: string; at: number }>();
55
57
  const pending = launch.isPending || createTask.isPending;
@@ -93,6 +95,7 @@ export function DraftPanel({
93
95
  }
94
96
  const { session, initialPrompt, repeat } = await launch.mutateAsync({
95
97
  projectId: project?.id ?? null,
98
+ workspaceId: draft.workspaceId ?? workspace?.id ?? null,
96
99
  id: draft.id,
97
100
  hostId:
98
101
  !project || draft.hostId !== project.host_id
@@ -182,6 +182,8 @@ export interface LaunchSessionInput {
182
182
  // The draft's id: the session's, and a resend's key to the same one.
183
183
  id: string;
184
184
  projectId: string | null;
185
+ // The workspace selected: where a session in no project is filed.
186
+ workspaceId?: string | null;
185
187
  // A scratch chat's machine; a project's sessions run where it lives, or
186
188
  // on a linked machine.
187
189
  hostId?: string;
@@ -46,9 +46,15 @@ export function useDraftRequests(options: Options) {
46
46
  useEffect(() => {
47
47
  hydrateDrafts();
48
48
  const open = (projectId: string | null, openPr = false) => {
49
- const { projects, viewing, show } = latest.current;
49
+ const { projects, viewing, show, workspaceId } = latest.current;
50
50
  const drafts = Object.values(draftsStore.drafts) as Draft[];
51
- const found = reusableDraft(drafts, projectId, draftHasText, openPr);
51
+ const found = reusableDraft(
52
+ drafts,
53
+ projectId,
54
+ draftHasText,
55
+ openPr,
56
+ workspaceId
57
+ );
52
58
  if (found) return show(found.id);
53
59
  const from = viewing.draftId ? draftsStore.drafts[viewing.draftId] : null;
54
60
  const s = viewing.session;
@@ -58,7 +64,7 @@ export function useDraftRequests(options: Options) {
58
64
  ? { agentType: s.agent_type, model: s.model, access: s.chat_access }
59
65
  : {};
60
66
  const project = projects.find((p) => p.id === projectId) ?? null;
61
- const draft = makeDraft(uuid(), project, carry, { openPr });
67
+ const draft = makeDraft(uuid(), project, carry, { openPr, workspaceId });
62
68
  draftsActions.put(draft);
63
69
  show(draft.id);
64
70
  };
@@ -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;
@@ -60,7 +60,8 @@ function inScope(s: ReadableSession, scope: ReaderScope): boolean {
60
60
  if (s.role) return false;
61
61
  if (scope.workspaceId) {
62
62
  const p = s.project_id ? getProject(s.project_id) : null;
63
- return !!p && p.workspace_id === scope.workspaceId;
63
+ // A session in no project is in the workspace it was started in.
64
+ return (p?.workspace_id ?? s.workspace_id) === scope.workspaceId;
64
65
  }
65
66
  return s.project_id === scope.projectId;
66
67
  }
package/lib/drafts.ts CHANGED
@@ -13,6 +13,8 @@ export interface Draft {
13
13
  id: string;
14
14
  // null: a scratch chat, in no project.
15
15
  projectId: string | null;
16
+ // The workspace selected when it was made: where a scratch chat is filed.
17
+ workspaceId?: string | null;
16
18
  hostId: string;
17
19
  agentType: AgentType;
18
20
  model: string;
@@ -55,13 +57,14 @@ export function newDraft(
55
57
  id: string,
56
58
  project: DraftProject | null,
57
59
  carry: Carry,
58
- opts: { openPr?: boolean; now?: number } = {}
60
+ opts: { openPr?: boolean; now?: number; workspaceId?: string | null } = {}
59
61
  ): Draft {
60
62
  const real = project && !project.is_uncategorized ? project : null;
61
63
  const agentType = real?.agent_type ?? carry.agentType ?? "claude";
62
64
  return {
63
65
  id,
64
66
  projectId: real?.id ?? null,
67
+ workspaceId: real ? null : (opts.workspaceId ?? null),
65
68
  hostId: real?.host_id || "local",
66
69
  agentType,
67
70
  model: resolveModelForAgent(
@@ -82,11 +85,18 @@ export function reusableDraft(
82
85
  drafts: Draft[],
83
86
  projectId: string | null,
84
87
  hasText: (draftId: string) => boolean,
85
- openPr = false
88
+ openPr = false,
89
+ workspaceId: string | null = null
86
90
  ): Draft | null {
91
+ // An empty scratch chat from another workspace would file this one's
92
+ // session there.
87
93
  return (
88
94
  drafts.find(
89
- (d) => d.projectId === projectId && d.openPr === openPr && !hasText(d.id)
95
+ (d) =>
96
+ d.projectId === projectId &&
97
+ d.openPr === openPr &&
98
+ (projectId !== null || (d.workspaceId ?? null) === workspaceId) &&
99
+ !hasText(d.id)
90
100
  ) ?? null
91
101
  );
92
102
  }
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",
package/lib/projects.ts CHANGED
@@ -278,11 +278,12 @@ export function deleteProject(id: string): boolean {
278
278
  const project = getProject(id);
279
279
  if (!project || project.is_uncategorized) return false;
280
280
 
281
- // Move all sessions to Uncategorized
282
- const sessions = queries.getSessionsByProject(db).all(id) as Session[];
283
- for (const session of sessions) {
284
- queries.updateSessionProject(db).run("uncategorized", session.id);
285
- }
281
+ // Its sessions go to Uncategorized, staying in the project's workspace.
282
+ db.prepare(
283
+ `UPDATE sessions SET project_id = 'uncategorized', updated_at = datetime('now'),
284
+ workspace_id = CASE WHEN role IS NULL THEN ? ELSE workspace_id END
285
+ WHERE project_id = ?`
286
+ ).run(project.workspace_id ?? null, id);
286
287
 
287
288
  // Delete dev server instances
288
289
  queries.deleteDevServersByProject(db).run(id);
@@ -312,7 +313,27 @@ export function moveSessionToProject(
312
313
  sessionId: string,
313
314
  projectId: string
314
315
  ): void {
315
- queries.updateSessionProject(db).run(projectId, sessionId);
316
+ const to = getProject(projectId);
317
+ const from = db
318
+ .prepare(
319
+ `SELECT p.workspace_id AS workspace_id, s.workspace_id AS own
320
+ FROM sessions s LEFT JOIN projects p ON p.id = s.project_id WHERE s.id = ?`
321
+ )
322
+ .get(sessionId) as
323
+ { workspace_id: string | null; own: string | null } | undefined;
324
+ db.transaction(() => {
325
+ queries.updateSessionProject(db).run(projectId, sessionId);
326
+ // A project's session follows the project; one left in none keeps the
327
+ // workspace it was in.
328
+ db.prepare(
329
+ `UPDATE sessions SET workspace_id = ? WHERE id = ? AND role IS NULL`
330
+ ).run(
331
+ to && !to.is_uncategorized
332
+ ? null
333
+ : (from?.workspace_id ?? from?.own ?? null),
334
+ sessionId
335
+ );
336
+ })();
316
337
  }
317
338
 
318
339
  /**
@@ -16,6 +16,7 @@ import { isDiscoveredModel } from "../chat/discovered-models";
16
16
  import { parseOverride, resolveRole, setSessionModel } from "../models/resolve";
17
17
  import { ANTHROPIC } from "../models/roles";
18
18
  import { getProject } from "../projects";
19
+ import { getWorkspace } from "../workspaces";
19
20
  import { getHost, isRemoteHost } from "../hosts";
20
21
  import { hostLink } from "../hosts/remote-api";
21
22
  import { startOnPeer } from "../hosts/peer-actions";
@@ -78,6 +79,9 @@ export interface LaunchInput {
78
79
  // role's model (or `model`), not the interactive one's.
79
80
  startedBy?: string | null;
80
81
  startedVia?: StartVia;
82
+ // The workspace selected when it was started. A session in a project
83
+ // follows the project; one in none keeps this (see inWorkspace).
84
+ workspaceId?: string | null;
81
85
  groupPath?: string;
82
86
  systemPrompt?: string | null;
83
87
  }
@@ -178,6 +182,29 @@ export function launchSession(input: LaunchInput): Promise<Launched> {
178
182
  return p;
179
183
  }
180
184
 
185
+ // The workspace a project-less session is filed under: the one it was started
186
+ // from, else that of the orchestrator starting it. A workspace deleted since
187
+ // the draft was made is dropped rather than failing the send.
188
+ function homeWorkspace(input: LaunchInput): string | null {
189
+ const picked = input.workspaceId?.trim();
190
+ if (picked) return getWorkspace(picked) ? picked : null;
191
+ if (!input.startedBy) return null;
192
+ const by = db
193
+ .prepare(
194
+ `SELECT workspace_id FROM sessions WHERE id = ? AND role = 'orchestrator'`
195
+ )
196
+ .get(input.startedBy) as { workspace_id: string | null } | undefined;
197
+ return by?.workspace_id ?? null;
198
+ }
199
+
200
+ function setHome(id: string, workspaceId: string | null): void {
201
+ if (workspaceId)
202
+ db.prepare(`UPDATE sessions SET workspace_id = ? WHERE id = ?`).run(
203
+ workspaceId,
204
+ id
205
+ );
206
+ }
207
+
181
208
  async function launch(input: LaunchInput, id: string): Promise<Launched> {
182
209
  const project = input.project
183
210
  ? await ensureProject(input.project)
@@ -185,6 +212,7 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
185
212
  ? getProject(input.projectId)
186
213
  : undefined;
187
214
  const scratch = !project || project.is_uncategorized;
215
+ const home = scratch ? homeWorkspace(input) : null;
188
216
  const hostId = (scratch ? input.hostId : null) || project?.host_id || "local";
189
217
  // A project here can start on a linked machine, which finds or clones it.
190
218
  const carried =
@@ -210,7 +238,7 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
210
238
  if (link) {
211
239
  if (input.images?.length || input.files?.length)
212
240
  throw new Error(`Images and files can't be sent to ${link.hostName} yet`);
213
- return startOnPeer(link, {
241
+ const started = await startOnPeer(link, {
214
242
  id,
215
243
  project: scratch ? null : (project ?? null),
216
244
  ref: carried && project ? await projectRef(project) : undefined,
@@ -222,6 +250,10 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
222
250
  prompt: input.prompt?.trim(),
223
251
  view: input.view ?? DEFAULT_START_VIEW,
224
252
  });
253
+ setHome(started.session.id, home);
254
+ return home
255
+ ? { ...started, session: { ...started.session, workspace_id: home } }
256
+ : started;
225
257
  }
226
258
  const projectPath = expand(folder);
227
259
  if (scratch && projectPath === SCRATCH_DIR)
@@ -242,7 +274,7 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
242
274
  const routed =
243
275
  agentType === "claude" && (picked || !input.model?.trim()) && !remote
244
276
  ? resolveRole(role, {
245
- workspaceId: project?.workspace_id,
277
+ workspaceId: project?.workspace_id ?? home,
246
278
  override: picked,
247
279
  })
248
280
  : null;
@@ -302,6 +334,7 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
302
334
  input.startedVia ?? (input.startedBy ? "orchestrator" : null),
303
335
  id
304
336
  );
337
+ setHome(id, home);
305
338
  if (interactive) setSessionModel(id, interactive);
306
339
  if (input.access)
307
340
  db.prepare(`UPDATE sessions SET chat_access = ? WHERE id = ?`).run(
@@ -382,7 +382,9 @@ export function doneSummary(
382
382
  }
383
383
 
384
384
  // A workspace holds its projects' sessions and its own orchestrator; no
385
- // workspace selected means every session.
385
+ // workspace selected means every session. A session in a project follows the
386
+ // project, so moving it moves them; one in no project (scratch) belongs to the
387
+ // workspace it was started in, kept on the session.
386
388
  export function inWorkspace(
387
389
  session: Pick<Session, "project_id" | "workspace_id" | "role">,
388
390
  workspaceOf: (projectId: string) => string | null | undefined,
@@ -391,9 +393,8 @@ export function inWorkspace(
391
393
  if (!workspaceId) return true;
392
394
  if (session.role === "orchestrator")
393
395
  return session.workspace_id === workspaceId;
394
- return (
395
- !!session.project_id && workspaceOf(session.project_id) === workspaceId
396
- );
396
+ const byProject = session.project_id ? workspaceOf(session.project_id) : null;
397
+ return (byProject ?? session.workspace_id) === workspaceId;
397
398
  }
398
399
 
399
400
  // A workspace's projects, or every project when none is selected.
@@ -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/lib/workspaces.ts CHANGED
@@ -115,6 +115,11 @@ export function deleteWorkspace(id: string): void {
115
115
  `UPDATE projects SET workspace_id = NULL WHERE workspace_id = ?`
116
116
  ).run(id);
117
117
  db.prepare(`DELETE FROM workspaces WHERE id = ?`).run(id);
118
+ // Sessions filed here by being started here go back to "All workspaces".
119
+ db.prepare(
120
+ `UPDATE sessions SET workspace_id = NULL
121
+ WHERE role IS NULL AND workspace_id = ?`
122
+ ).run(id);
118
123
  // Its orchestrator goes with it; a running worker idles out on its own.
119
124
  db.prepare(
120
125
  `DELETE FROM sessions WHERE role = 'orchestrator' AND workspace_id = ?`
@@ -128,6 +133,7 @@ export function deleteWorkspace(id: string): void {
128
133
  })();
129
134
  }
130
135
 
136
+ // A project's sessions follow it, so moving one needs no change to them.
131
137
  export function setProjectWorkspace(
132
138
  projectId: string,
133
139
  workspaceId: string | null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.692",
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 = () => {