@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.
- package/components/Settings/NoApprovalsSection.tsx +1 -1
- package/lib/agents/cli-path.ts +9 -1
- package/lib/approvals/enable.ts +1 -21
- package/lib/approvals/no-approvals.ts +1 -1
- package/lib/models/plan-step.ts +5 -13
- package/lib/ports.ts +4 -0
- package/lib/tasks/contract/plan.ts +1 -14
- package/lib/windows-ports.ts +68 -0
- package/lib/worktree-sweep/testTemp.ts +1 -0
- package/package.json +1 -1
- package/scripts/agent-os +2 -0
- package/scripts/ci-machine-link.sh +31 -0
- package/scripts/install-deps +6 -1
- package/scripts/lib/ai-clis.sh +14 -14
- package/scripts/lib/commands.sh +61 -2
- package/scripts/lib/common.sh +42 -0
- package/scripts/lib/machine.sh +87 -0
- package/scripts/lib/machine.test.ts +141 -0
- package/scripts/lib/wsl.test.ts +160 -2
|
@@ -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
|
|
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
|
|
package/lib/agents/cli-path.ts
CHANGED
|
@@ -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/approvals/enable.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
*
|
|
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
|
*/
|
package/lib/models/plan-step.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
} from "./resolve";
|
|
20
20
|
import { ANTHROPIC } from "./roles";
|
|
21
21
|
import { queueEvent } from "../orchestrator/events";
|
|
22
|
-
import {
|
|
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
|
-
//
|
|
455
|
-
//
|
|
456
|
-
|
|
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 (
|
|
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
|
|
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
|
+
}
|
package/package.json
CHANGED
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)"
|
package/scripts/install-deps
CHANGED
|
@@ -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
|
|
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
|
|
package/scripts/lib/ai-clis.sh
CHANGED
|
@@ -4,19 +4,19 @@
|
|
|
4
4
|
detect_ai_clis() {
|
|
5
5
|
local installed=()
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
61
|
+
if have_cli pipx; then
|
|
62
62
|
pipx install aider-chat
|
|
63
|
-
elif
|
|
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
|
|
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
|
|
87
|
+
if have_cli kilo; then
|
|
88
88
|
log_success "Kilo Code CLI already installed"
|
|
89
89
|
return 0
|
|
90
90
|
fi
|
package/scripts/lib/commands.sh
CHANGED
|
@@ -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
|
-
|
|
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"
|
package/scripts/lib/common.sh
CHANGED
|
@@ -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
|
+
});
|
package/scripts/lib/wsl.test.ts
CHANGED
|
@@ -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 = [
|
|
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
|
-
|
|
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
|
+
});
|