@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.
- package/app/api/sessions/route.ts +1 -0
- package/components/Draft/DraftChips.tsx +10 -5
- package/components/Draft/DraftPanel.tsx +3 -0
- package/data/sessions/queries.ts +2 -0
- package/hooks/useDraftRequests.ts +9 -3
- package/lib/agents/cli-path.ts +9 -1
- package/lib/bus/read-scope.ts +2 -1
- package/lib/drafts.ts +13 -3
- package/lib/ports.ts +4 -0
- package/lib/projects.ts +27 -6
- package/lib/sessions/launch.ts +35 -2
- package/lib/sidebar/shelves.ts +5 -4
- package/lib/windows-ports.ts +68 -0
- package/lib/workspaces.ts +6 -0
- package/package.json +1 -1
- package/scripts/agent-os +2 -0
- package/scripts/ci-machine-link.sh +31 -0
- package/scripts/lib/ai-clis.sh +14 -14
- package/scripts/lib/commands.sh +60 -1
- 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 +109 -1
|
@@ -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(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
package/data/sessions/queries.ts
CHANGED
|
@@ -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(
|
|
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
|
};
|
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/bus/read-scope.ts
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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
|
-
//
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/lib/sessions/launch.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -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
|
-
|
|
395
|
-
|
|
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
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/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
|
@@ -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,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 = [
|
|
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 = () => {
|