@saadnvd1/agent-os 0.3.698 → 0.3.700
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/bus/send/route.ts +11 -1
- package/app/api/bus/wait/route.ts +22 -0
- package/app/api/peer/hello/reveal/route.ts +6 -0
- package/app/api/peer/hello/route.ts +9 -0
- package/app/api/peer/hello/status/route.ts +6 -0
- package/app/api/peers/account/route.ts +9 -0
- package/app/api/peers/gate.test.ts +111 -0
- package/app/api/peers/link/[id]/confirm/route.ts +27 -0
- package/app/api/peers/link/[id]/route.ts +20 -0
- package/app/api/peers/link/route.ts +23 -0
- package/app/api/peers/pending/[id]/accept/route.ts +31 -0
- package/app/api/peers/pending/[id]/decline/route.ts +13 -0
- package/app/api/peers/pending/route.ts +7 -0
- package/app/api/sessions/route.ts +18 -1
- package/app/page.tsx +2 -0
- package/components/Hosts/AccountMachines.tsx +180 -0
- package/components/Hosts/HostRow.tsx +44 -35
- package/components/Hosts/HostsDialog.tsx +49 -37
- package/components/Hosts/IncomingLinkSheet.tsx +228 -0
- package/components/Hosts/LinkCodeSheet.tsx +186 -0
- package/components/Hosts/sheet.ts +3 -0
- package/components/SessionList/RowSubtitle.tsx +11 -1
- package/components/Welcome/ConnectBlock.tsx +1 -1
- package/data/peers/index.ts +2 -0
- package/data/peers/keys.ts +6 -0
- package/data/peers/queries.ts +141 -0
- package/lib/agents/brief.ts +3 -1
- package/lib/bus/format.ts +25 -4
- package/lib/bus/index.ts +113 -106
- package/lib/bus/resolve.ts +16 -2
- package/lib/bus/targets.ts +122 -0
- package/lib/bus/waits/kick.ts +12 -0
- package/lib/bus/waits/lines.ts +69 -0
- package/lib/bus/waits/register.ts +83 -0
- package/lib/bus/waits/store.ts +196 -0
- package/lib/bus/waits/tick.ts +192 -0
- package/lib/connect/control-api.ts +108 -2
- package/lib/connect/heartbeat.ts +11 -3
- package/lib/connect/serve.ts +2 -0
- package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
- package/lib/db/types.ts +4 -0
- package/lib/done/archive.ts +2 -0
- package/lib/orchestrator/describe.ts +13 -0
- package/lib/orchestrator/facts.ts +17 -0
- package/lib/peer/account.ts +198 -0
- package/lib/peer/cli.ts +24 -0
- package/lib/peer/code.ts +3 -0
- package/lib/peer/hello-route.ts +32 -0
- package/lib/peer/hello.ts +346 -0
- package/lib/peer/link-cli.ts +233 -0
- package/lib/peer/machines.ts +72 -0
- package/lib/peer/notify.ts +32 -0
- package/lib/peer/pairing-out.ts +382 -0
- package/lib/peer/pairing.ts +403 -0
- package/lib/peer/pin.ts +55 -0
- package/lib/peer/rpc-intake.ts +37 -8
- package/lib/peer/rpc-server.ts +19 -1
- package/lib/schedules/when.ts +1 -1
- package/lib/security/auth.ts +10 -2
- package/lib/security/gate.ts +3 -0
- package/lib/sessions/end.ts +3 -0
- package/package.json +1 -1
- package/scripts/agent-os +2 -0
- package/scripts/lib/commands.sh +2 -0
- package/server.ts +3 -0
|
@@ -214,17 +214,123 @@ export async function requestTunnel(
|
|
|
214
214
|
return { hostname: hostnameFrom(r), token: tokenFrom(r) };
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
/** What the account's plan allows; absent from an older Connect service. */
|
|
218
|
+
export interface Entitlements {
|
|
219
|
+
plan: string;
|
|
220
|
+
linkMachines: boolean;
|
|
221
|
+
machineLimit: number;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface Beat {
|
|
225
|
+
enabled: boolean;
|
|
226
|
+
entitlements?: Entitlements;
|
|
227
|
+
/** Changes whenever the list `peers` would give changes. */
|
|
228
|
+
peersEtag?: string;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const ETAG = /^[A-Za-z0-9_-]{1,128}$/;
|
|
232
|
+
|
|
233
|
+
export function entitlementsFrom(raw: unknown): Entitlements | undefined {
|
|
234
|
+
const e = (raw ?? null) as Record<string, unknown> | null;
|
|
235
|
+
if (!e || typeof e !== "object" || typeof e.link_machines !== "boolean")
|
|
236
|
+
return undefined;
|
|
237
|
+
return {
|
|
238
|
+
plan: typeof e.plan === "string" ? clean(e.plan, 32) : "unknown",
|
|
239
|
+
linkMachines: e.link_machines,
|
|
240
|
+
machineLimit:
|
|
241
|
+
typeof e.machine_limit === "number" &&
|
|
242
|
+
Number.isSafeInteger(e.machine_limit)
|
|
243
|
+
? e.machine_limit
|
|
244
|
+
: 0,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const etagFrom = (raw: unknown) =>
|
|
249
|
+
typeof raw === "string" && ETAG.test(raw) ? raw : undefined;
|
|
250
|
+
|
|
217
251
|
export async function heartbeat(
|
|
218
252
|
config: ConnectConfig,
|
|
219
253
|
privateKey: string,
|
|
220
254
|
version: string
|
|
221
|
-
) {
|
|
255
|
+
): Promise<Beat> {
|
|
222
256
|
const res = await signed(config, privateKey)(
|
|
223
257
|
"POST",
|
|
224
258
|
`/api/v1/machines/${config.machineId}/heartbeat`,
|
|
225
259
|
{ version }
|
|
226
260
|
);
|
|
227
|
-
|
|
261
|
+
const r = ((await res.json().catch(() => null)) ?? {}) as Record<
|
|
262
|
+
string,
|
|
263
|
+
unknown
|
|
264
|
+
>;
|
|
265
|
+
return {
|
|
266
|
+
enabled: r.enabled !== false,
|
|
267
|
+
entitlements: entitlementsFrom(r.entitlements),
|
|
268
|
+
peersEtag: etagFrom(r.peers_etag),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Another machine on this account, as the Connect service introduces it. */
|
|
273
|
+
export interface ListedMachine {
|
|
274
|
+
id: string;
|
|
275
|
+
name: string;
|
|
276
|
+
hostname: string;
|
|
277
|
+
/** Its Ed25519 key as the service holds it: SPKI PEM. */
|
|
278
|
+
publicKey: string;
|
|
279
|
+
lastSeenAt: string | null;
|
|
280
|
+
version: string | null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export interface PeerList {
|
|
284
|
+
entitlements?: Entitlements;
|
|
285
|
+
peersEtag?: string;
|
|
286
|
+
peers: ListedMachine[];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const MAX_PEERS = 100;
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The account's other machines this one may link to. Only an introduction:
|
|
293
|
+
* anything malformed is dropped here, and the keys still have to be
|
|
294
|
+
* confirmed by a person on both machines before either trusts the other.
|
|
295
|
+
*/
|
|
296
|
+
export async function listPeers(
|
|
297
|
+
config: ConnectConfig,
|
|
298
|
+
privateKey: string
|
|
299
|
+
): Promise<PeerList> {
|
|
300
|
+
const res = await signed(config, privateKey)(
|
|
301
|
+
"GET",
|
|
302
|
+
`/api/v1/machines/${config.machineId}/peers`
|
|
303
|
+
);
|
|
304
|
+
const r = (await res.json().catch(() => null)) as Record<
|
|
305
|
+
string,
|
|
306
|
+
unknown
|
|
307
|
+
> | null;
|
|
308
|
+
if (!r || !Array.isArray(r.peers))
|
|
309
|
+
throw new Error("the Connect service sent no machine list");
|
|
310
|
+
const str = (v: unknown, max: number) =>
|
|
311
|
+
typeof v === "string" && v.length <= max ? v : null;
|
|
312
|
+
const peers: ListedMachine[] = [];
|
|
313
|
+
for (const raw of r.peers.slice(0, MAX_PEERS)) {
|
|
314
|
+
const m = (raw ?? {}) as Record<string, unknown>;
|
|
315
|
+
const id = str(m.id, 8);
|
|
316
|
+
const hostname = str(m.hostname, 253);
|
|
317
|
+
const publicKey = str(m.public_key, 1024);
|
|
318
|
+
if (!id || !MACHINE_ID.test(id) || !hostname || !publicKey) continue;
|
|
319
|
+
if (id === config.machineId) continue;
|
|
320
|
+
peers.push({
|
|
321
|
+
id,
|
|
322
|
+
name: clean(str(m.name, 500) ?? id, 80),
|
|
323
|
+
hostname,
|
|
324
|
+
publicKey,
|
|
325
|
+
lastSeenAt: str(m.last_seen_at, 64),
|
|
326
|
+
version: str(m.agent_os_version, 64),
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
entitlements: entitlementsFrom(r.entitlements),
|
|
331
|
+
peersEtag: etagFrom(r.peers_etag),
|
|
332
|
+
peers,
|
|
333
|
+
};
|
|
228
334
|
}
|
|
229
335
|
|
|
230
336
|
export interface PushRequestBody {
|
package/lib/connect/heartbeat.ts
CHANGED
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from "fs";
|
|
9
9
|
import path from "path";
|
|
10
|
-
import { ApiError, clean } from "./control-api";
|
|
10
|
+
import { ApiError, clean, type Beat } from "./control-api";
|
|
11
11
|
|
|
12
12
|
export const HEARTBEAT_MS = 60_000;
|
|
13
13
|
|
|
14
14
|
export interface HeartbeatOptions {
|
|
15
|
-
beat: () => Promise<
|
|
15
|
+
beat: () => Promise<Beat>;
|
|
16
|
+
/** Every answer, for what it says about the account (plan, peers). */
|
|
17
|
+
onBeat?: (beat: Beat) => void;
|
|
16
18
|
/** The account disabled this machine: stop the tunnel. */
|
|
17
19
|
onDisabled: () => void;
|
|
18
20
|
/** The service no longer knows this machine. */
|
|
@@ -43,9 +45,15 @@ export function startHeartbeat(opts: HeartbeatOptions) {
|
|
|
43
45
|
const tick = async () => {
|
|
44
46
|
timer = null;
|
|
45
47
|
try {
|
|
46
|
-
const
|
|
48
|
+
const answer = await opts.beat();
|
|
49
|
+
const { enabled } = answer;
|
|
47
50
|
if (stopped) return;
|
|
48
51
|
failing = false;
|
|
52
|
+
try {
|
|
53
|
+
opts.onBeat?.(answer);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
log(`connect: ${clean((err as Error)?.message ?? String(err))}`);
|
|
56
|
+
}
|
|
49
57
|
if (enabled === false) {
|
|
50
58
|
stop();
|
|
51
59
|
log("connect: this machine was disabled in your account; tunnel off");
|
package/lib/connect/serve.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
type Tunnel,
|
|
41
41
|
} from "./control-api";
|
|
42
42
|
import { appVersion, startHeartbeat } from "./heartbeat";
|
|
43
|
+
import { noteHeartbeat } from "@/lib/peer/account";
|
|
43
44
|
import { ensureCloudflared } from "./cloudflared";
|
|
44
45
|
import {
|
|
45
46
|
reapTunnel,
|
|
@@ -331,6 +332,7 @@ export function startConnect(
|
|
|
331
332
|
const version = appVersion();
|
|
332
333
|
const heartbeat = startHeartbeat({
|
|
333
334
|
beat: () => beat(config, machineKey, version),
|
|
335
|
+
onBeat: noteHeartbeat,
|
|
334
336
|
log,
|
|
335
337
|
intervalMs: deps.heartbeatMs,
|
|
336
338
|
onDisabled: () => {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261020T1600_bus_waits",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// A session waiting on another (lib/bus/waits): for its reply, or for it
|
|
7
|
+
// to go idle or merge. Closed rows stay as history.
|
|
8
|
+
db.exec(`CREATE TABLE IF NOT EXISTS bus_waits (
|
|
9
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
10
|
+
waiter_id TEXT NOT NULL,
|
|
11
|
+
target_id TEXT NOT NULL,
|
|
12
|
+
kind TEXT NOT NULL,
|
|
13
|
+
message_id INTEGER,
|
|
14
|
+
request TEXT,
|
|
15
|
+
deadline INTEGER NOT NULL,
|
|
16
|
+
created_at INTEGER NOT NULL,
|
|
17
|
+
seen_busy_at INTEGER,
|
|
18
|
+
nudged_at INTEGER,
|
|
19
|
+
closed_at INTEGER,
|
|
20
|
+
outcome TEXT,
|
|
21
|
+
reply_id INTEGER
|
|
22
|
+
)`);
|
|
23
|
+
db.exec(
|
|
24
|
+
`CREATE INDEX IF NOT EXISTS idx_bus_waits_open ON bus_waits(target_id) WHERE closed_at IS NULL`
|
|
25
|
+
);
|
|
26
|
+
db.exec(
|
|
27
|
+
`CREATE INDEX IF NOT EXISTS idx_bus_waits_waiter ON bus_waits(waiter_id) WHERE closed_at IS NULL`
|
|
28
|
+
);
|
|
29
|
+
const cols = db.prepare(`PRAGMA table_info(bus_messages)`).all() as {
|
|
30
|
+
name: string;
|
|
31
|
+
}[];
|
|
32
|
+
if (!cols.some((c) => c.name === "reply_to"))
|
|
33
|
+
db.exec(`ALTER TABLE bus_messages ADD COLUMN reply_to INTEGER`);
|
|
34
|
+
},
|
|
35
|
+
} satisfies Migration;
|
package/lib/db/types.ts
CHANGED
|
@@ -90,6 +90,10 @@ export interface Session {
|
|
|
90
90
|
pause_id?: number | null;
|
|
91
91
|
// Why a requested resume hasn't happened yet.
|
|
92
92
|
pause_note?: string | null;
|
|
93
|
+
// Set only by GET /api/sessions: the sessions it waits on, and who waits on
|
|
94
|
+
// it, as a short line (lib/bus/waits/store).
|
|
95
|
+
waiting_on?: string | null;
|
|
96
|
+
waited_by?: string | null;
|
|
93
97
|
placed_note?: string | null;
|
|
94
98
|
// Set by done: hidden from the sidebar and the orchestrator, never deleted.
|
|
95
99
|
archived_at: string | null;
|
package/lib/done/archive.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import fs from "fs";
|
|
5
5
|
import { db, queries, type Session } from "../db";
|
|
6
6
|
import { statusOf } from "../orchestrator/facts";
|
|
7
|
+
import { kickWaits } from "../bus/waits/kick";
|
|
7
8
|
import { peerSessionLink } from "../hosts/peer-actions";
|
|
8
9
|
|
|
9
10
|
export interface ArchivedView {
|
|
@@ -26,6 +27,7 @@ export function archiveSession(id: string): void {
|
|
|
26
27
|
db.prepare(
|
|
27
28
|
`UPDATE sessions SET archived_at = datetime('now') WHERE id = ? AND archived_at IS NULL`
|
|
28
29
|
).run(id);
|
|
30
|
+
kickWaits();
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export function unarchiveSession(id: string): Session {
|
|
@@ -41,6 +41,19 @@ function sessionLine(f: SessionFacts): string {
|
|
|
41
41
|
parts.push(
|
|
42
42
|
`${f.plan.awaiting ? "plan ready, awaiting your approve_plan" : f.plan.planned ? "planned" : "no plan"}${f.plan.reason ? `: ${untrusted(f.name, f.plan.reason)}` : ""}`
|
|
43
43
|
);
|
|
44
|
+
if (f.waits?.on.length)
|
|
45
|
+
parts.push(
|
|
46
|
+
`waiting on ${f.waits.on
|
|
47
|
+
.map(
|
|
48
|
+
(w) =>
|
|
49
|
+
`${untrusted(f.name, w.name)} (${w.kind === "reply" ? "reply" : w.kind === "idle" ? "to go idle" : "to merge"}, ${Math.max(1, Math.round(w.leftMs / 60_000))}m left)`
|
|
50
|
+
)
|
|
51
|
+
.join(", ")}`
|
|
52
|
+
);
|
|
53
|
+
if (f.waits?.by.length)
|
|
54
|
+
parts.push(
|
|
55
|
+
`${f.waits.by.map((n) => untrusted(f.name, n)).join(", ")} waiting on it`
|
|
56
|
+
);
|
|
44
57
|
if (f.stack) {
|
|
45
58
|
const card = f.stack.ticket ? ` ${f.stack.ticket}` : "";
|
|
46
59
|
parts.push(
|
|
@@ -20,6 +20,7 @@ import { prFor, storedPR } from "../tasks/session";
|
|
|
20
20
|
import { lastActiveOf } from "../tasks/last-active";
|
|
21
21
|
import * as taskClient from "../tasks/contract/client";
|
|
22
22
|
import { deriveTaskState } from "../tasks";
|
|
23
|
+
import { waitBadges } from "../bus/waits/store";
|
|
23
24
|
import { waitingState } from "./task-state";
|
|
24
25
|
import { commitTime, repoOf } from "./repo";
|
|
25
26
|
import { CHECKLESS_MS, checklessFor, ciSettleIn } from "./task-state";
|
|
@@ -44,6 +45,11 @@ export interface SessionFacts {
|
|
|
44
45
|
compacting?: boolean;
|
|
45
46
|
// Last sign of life, in ms.
|
|
46
47
|
lastActive: number;
|
|
48
|
+
// Open waits (aos send --await, aos wait): who it waits on, and who waits on it.
|
|
49
|
+
waits?: {
|
|
50
|
+
on: { name: string; kind: string; leftMs: number }[];
|
|
51
|
+
by: string[];
|
|
52
|
+
} | null;
|
|
47
53
|
// Work that should end in a PR: a task, or a session on its own branch.
|
|
48
54
|
branch: string | null;
|
|
49
55
|
// A quick change the orchestrator makes itself (quick.ts): no agent.
|
|
@@ -301,6 +307,7 @@ export async function sessionFacts(
|
|
|
301
307
|
workspaceId: string
|
|
302
308
|
): Promise<SessionFacts[]> {
|
|
303
309
|
await statusDetector.refreshCache();
|
|
310
|
+
const badges = waitBadges();
|
|
304
311
|
return Promise.all(
|
|
305
312
|
workspaceSessions(workspaceId).map(async (s) => {
|
|
306
313
|
const [{ status, activity, needsInput }, task] = await Promise.all([
|
|
@@ -315,6 +322,16 @@ export async function sessionFacts(
|
|
|
315
322
|
status,
|
|
316
323
|
activity,
|
|
317
324
|
needsInput,
|
|
325
|
+
waits: badges.has(s.id)
|
|
326
|
+
? {
|
|
327
|
+
on: badges.get(s.id)!.on.map((w) => ({
|
|
328
|
+
name: w.name,
|
|
329
|
+
kind: w.kind,
|
|
330
|
+
leftMs: Math.max(0, w.deadline - Date.now()),
|
|
331
|
+
})),
|
|
332
|
+
by: badges.get(s.id)!.by.map((w) => w.name),
|
|
333
|
+
}
|
|
334
|
+
: null,
|
|
318
335
|
stalled:
|
|
319
336
|
s.view === "chat" && !hostLink(s.host_id) ? chatStall(s.id) : null,
|
|
320
337
|
compacting:
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The other machines on this machine's Connect account, and whether its plan
|
|
3
|
+
* includes linking. Connect only introduces: a listed key is who may ask to
|
|
4
|
+
* pair, never who is trusted (that takes the code typed on the target).
|
|
5
|
+
*
|
|
6
|
+
* Kept in memory, shared through globalThis by the server and the routes.
|
|
7
|
+
* The list is fetched when it's older than a minute and someone needs it,
|
|
8
|
+
* and marked stale when the heartbeat's peers_etag changes; fetches are
|
|
9
|
+
* shared and spaced, so a flood of hellos can't hammer the service.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import crypto from "crypto";
|
|
13
|
+
import { readConnect } from "../connect/config";
|
|
14
|
+
import {
|
|
15
|
+
listPeers,
|
|
16
|
+
type Beat,
|
|
17
|
+
type Entitlements,
|
|
18
|
+
type PeerList,
|
|
19
|
+
} from "../connect/control-api";
|
|
20
|
+
import { peerHostAllowed } from "./client";
|
|
21
|
+
import { rawPublic } from "./keys";
|
|
22
|
+
|
|
23
|
+
export interface AccountPeer {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
hostname: string;
|
|
27
|
+
/** Its Ed25519 public key, base64url of the raw 32 bytes. */
|
|
28
|
+
signPub: string;
|
|
29
|
+
lastSeenAt: string | null;
|
|
30
|
+
version: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AccountView {
|
|
34
|
+
enrolled: boolean;
|
|
35
|
+
entitlements: Entitlements | null;
|
|
36
|
+
peers: AccountPeer[];
|
|
37
|
+
/** When the list was last fetched, 0 if never. */
|
|
38
|
+
fetchedAt: number;
|
|
39
|
+
error: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const LIST_MAX_AGE_MS = 60_000;
|
|
43
|
+
// Never more often than this, whoever asks.
|
|
44
|
+
const MIN_GAP_MS = 5_000;
|
|
45
|
+
|
|
46
|
+
interface State {
|
|
47
|
+
entitlements: Entitlements | null;
|
|
48
|
+
etag: string | null;
|
|
49
|
+
peers: AccountPeer[];
|
|
50
|
+
fetchedAt: number;
|
|
51
|
+
triedAt: number;
|
|
52
|
+
stale: boolean;
|
|
53
|
+
error: string | null;
|
|
54
|
+
inflight: Promise<void> | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type Fetcher = () => Promise<PeerList | null>;
|
|
58
|
+
|
|
59
|
+
const g = globalThis as unknown as {
|
|
60
|
+
__agentosPeerAccount?: State;
|
|
61
|
+
__agentosPeerFetcher?: Fetcher;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const fresh = (): State => ({
|
|
65
|
+
entitlements: null,
|
|
66
|
+
etag: null,
|
|
67
|
+
peers: [],
|
|
68
|
+
fetchedAt: 0,
|
|
69
|
+
triedAt: 0,
|
|
70
|
+
stale: false,
|
|
71
|
+
error: null,
|
|
72
|
+
inflight: null,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const state = () => (g.__agentosPeerAccount ??= fresh());
|
|
76
|
+
|
|
77
|
+
/** The signed call, or null when this machine isn't enrolled. */
|
|
78
|
+
const realFetcher: Fetcher = async () => {
|
|
79
|
+
const read = readConnect();
|
|
80
|
+
if (!read.ok) return null;
|
|
81
|
+
return listPeers(read.files.config, read.files.machineKey);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Tests: a fake Connect service, and a clean slate. */
|
|
85
|
+
export function setAccountFetcher(f: Fetcher | null) {
|
|
86
|
+
g.__agentosPeerFetcher = f ?? undefined;
|
|
87
|
+
g.__agentosPeerAccount = fresh();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Control's SPKI PEM as the raw key the pins use, or null. */
|
|
91
|
+
export function rawFromSpki(pem: string): string | null {
|
|
92
|
+
try {
|
|
93
|
+
const key = crypto.createPublicKey(pem);
|
|
94
|
+
return key.asymmetricKeyType === "ed25519" ? rawPublic(key) : null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function adopt(list: PeerList) {
|
|
101
|
+
const s = state();
|
|
102
|
+
const peers: AccountPeer[] = [];
|
|
103
|
+
for (const m of list.peers) {
|
|
104
|
+
const signPub = rawFromSpki(m.publicKey);
|
|
105
|
+
// Only a Connect machine's own name: a lying list can't aim this
|
|
106
|
+
// machine anywhere else.
|
|
107
|
+
if (!signPub || !peerHostAllowed(m.hostname)) continue;
|
|
108
|
+
peers.push({
|
|
109
|
+
id: m.id,
|
|
110
|
+
name: m.name,
|
|
111
|
+
hostname: m.hostname,
|
|
112
|
+
signPub,
|
|
113
|
+
lastSeenAt: m.lastSeenAt,
|
|
114
|
+
version: m.version,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
s.peers = peers;
|
|
118
|
+
if (list.entitlements) s.entitlements = list.entitlements;
|
|
119
|
+
if (list.peersEtag) s.etag = list.peersEtag;
|
|
120
|
+
s.fetchedAt = Date.now();
|
|
121
|
+
s.stale = false;
|
|
122
|
+
s.error = null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function refresh(): Promise<void> {
|
|
126
|
+
const s = state();
|
|
127
|
+
if (s.inflight) return s.inflight;
|
|
128
|
+
if (Date.now() - s.triedAt < MIN_GAP_MS) return;
|
|
129
|
+
s.triedAt = Date.now();
|
|
130
|
+
s.inflight = (async () => {
|
|
131
|
+
try {
|
|
132
|
+
const list = await (g.__agentosPeerFetcher ?? realFetcher)();
|
|
133
|
+
if (list) adopt(list);
|
|
134
|
+
else {
|
|
135
|
+
s.peers = [];
|
|
136
|
+
s.error = "This machine isn't connected to AgentOS Connect.";
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
// The last list stays; only its age says it's old.
|
|
140
|
+
s.error = `Can't reach your AgentOS account: ${
|
|
141
|
+
err instanceof Error ? err.message : String(err)
|
|
142
|
+
}`.slice(0, 300);
|
|
143
|
+
} finally {
|
|
144
|
+
s.inflight = null;
|
|
145
|
+
}
|
|
146
|
+
})();
|
|
147
|
+
return s.inflight;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const enrolled = () =>
|
|
151
|
+
g.__agentosPeerFetcher ? true : readConnect().ok === true;
|
|
152
|
+
|
|
153
|
+
/** The account's machines, fetched first if the list is older than maxAgeMs. */
|
|
154
|
+
export async function accountView(
|
|
155
|
+
maxAgeMs = LIST_MAX_AGE_MS
|
|
156
|
+
): Promise<AccountView> {
|
|
157
|
+
if (!enrolled())
|
|
158
|
+
return {
|
|
159
|
+
enrolled: false,
|
|
160
|
+
entitlements: null,
|
|
161
|
+
peers: [],
|
|
162
|
+
fetchedAt: 0,
|
|
163
|
+
error: null,
|
|
164
|
+
};
|
|
165
|
+
const s = state();
|
|
166
|
+
if (s.stale || Date.now() - s.fetchedAt > maxAgeMs) await refresh();
|
|
167
|
+
return {
|
|
168
|
+
enrolled: true,
|
|
169
|
+
entitlements: s.entitlements,
|
|
170
|
+
peers: s.peers,
|
|
171
|
+
fetchedAt: s.fetchedAt,
|
|
172
|
+
error: s.error,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The machine as the account lists it now, or null when it isn't listed. */
|
|
177
|
+
export async function listedPeer(
|
|
178
|
+
machineId: string
|
|
179
|
+
): Promise<AccountPeer | null> {
|
|
180
|
+
const view = await accountView();
|
|
181
|
+
return view.peers.find((p) => p.id === machineId) ?? null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Whether the plan includes linking: null until the service has said. */
|
|
185
|
+
export async function linkingAllowed(): Promise<boolean | null> {
|
|
186
|
+
const { entitlements } = await accountView();
|
|
187
|
+
return entitlements ? entitlements.linkMachines : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** What a heartbeat says: the plan now, and whether the list changed. */
|
|
191
|
+
export function noteHeartbeat(beat: Beat) {
|
|
192
|
+
const s = state();
|
|
193
|
+
if (beat.entitlements) s.entitlements = beat.entitlements;
|
|
194
|
+
if (beat.peersEtag && beat.peersEtag !== s.etag) {
|
|
195
|
+
s.etag = beat.peersEtag;
|
|
196
|
+
s.stale = true;
|
|
197
|
+
}
|
|
198
|
+
}
|
package/lib/peer/cli.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Entry for agent-os link / unlink (lib/peer/link-cli.ts). Argv: port, then the command. */
|
|
2
|
+
|
|
3
|
+
import { createInterface } from "readline/promises";
|
|
4
|
+
import { runLinkCli } from "./link-cli";
|
|
5
|
+
|
|
6
|
+
const [port, ...args] = process.argv.slice(2);
|
|
7
|
+
runLinkCli(args, {
|
|
8
|
+
base: `http://127.0.0.1:${Number(port) || 3011}`,
|
|
9
|
+
fetch,
|
|
10
|
+
out: (line) => console.log(line),
|
|
11
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
12
|
+
ask: async (prompt) => {
|
|
13
|
+
if (!process.stdin.isTTY) return null;
|
|
14
|
+
const rl = createInterface({
|
|
15
|
+
input: process.stdin,
|
|
16
|
+
output: process.stdout,
|
|
17
|
+
});
|
|
18
|
+
try {
|
|
19
|
+
return await rl.question(prompt);
|
|
20
|
+
} finally {
|
|
21
|
+
rl.close();
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
}).then((code) => process.exit(code));
|
package/lib/peer/code.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three public pairing routes: a body of at most 4 KiB, JSON, handed to
|
|
3
|
+
* the pairing code, and the answer never cached.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
7
|
+
import { readCapped } from "../security/read-capped";
|
|
8
|
+
import { HELLO_MAX_BYTES } from "./hello";
|
|
9
|
+
import type { Reply } from "./pairing";
|
|
10
|
+
|
|
11
|
+
export function helloRoute(
|
|
12
|
+
handle: (body: unknown, request: NextRequest) => Reply | Promise<Reply>
|
|
13
|
+
) {
|
|
14
|
+
return async (request: NextRequest) => {
|
|
15
|
+
const raw = await readCapped(request, HELLO_MAX_BYTES);
|
|
16
|
+
let reply: Reply;
|
|
17
|
+
if (raw === null) reply = { status: 413, body: { error: "too large" } };
|
|
18
|
+
else {
|
|
19
|
+
let body: unknown = null;
|
|
20
|
+
try {
|
|
21
|
+
body = JSON.parse(raw);
|
|
22
|
+
} catch {
|
|
23
|
+
// parsed as nothing: refused as a bad message
|
|
24
|
+
}
|
|
25
|
+
reply = await handle(body, request);
|
|
26
|
+
}
|
|
27
|
+
return NextResponse.json(reply.body, {
|
|
28
|
+
status: reply.status,
|
|
29
|
+
headers: { "Cache-Control": "no-store" },
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
}
|