@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.
Files changed (65) hide show
  1. package/app/api/bus/send/route.ts +11 -1
  2. package/app/api/bus/wait/route.ts +22 -0
  3. package/app/api/peer/hello/reveal/route.ts +6 -0
  4. package/app/api/peer/hello/route.ts +9 -0
  5. package/app/api/peer/hello/status/route.ts +6 -0
  6. package/app/api/peers/account/route.ts +9 -0
  7. package/app/api/peers/gate.test.ts +111 -0
  8. package/app/api/peers/link/[id]/confirm/route.ts +27 -0
  9. package/app/api/peers/link/[id]/route.ts +20 -0
  10. package/app/api/peers/link/route.ts +23 -0
  11. package/app/api/peers/pending/[id]/accept/route.ts +31 -0
  12. package/app/api/peers/pending/[id]/decline/route.ts +13 -0
  13. package/app/api/peers/pending/route.ts +7 -0
  14. package/app/api/sessions/route.ts +18 -1
  15. package/app/page.tsx +2 -0
  16. package/components/Hosts/AccountMachines.tsx +180 -0
  17. package/components/Hosts/HostRow.tsx +44 -35
  18. package/components/Hosts/HostsDialog.tsx +49 -37
  19. package/components/Hosts/IncomingLinkSheet.tsx +228 -0
  20. package/components/Hosts/LinkCodeSheet.tsx +186 -0
  21. package/components/Hosts/sheet.ts +3 -0
  22. package/components/SessionList/RowSubtitle.tsx +11 -1
  23. package/components/Welcome/ConnectBlock.tsx +1 -1
  24. package/data/peers/index.ts +2 -0
  25. package/data/peers/keys.ts +6 -0
  26. package/data/peers/queries.ts +141 -0
  27. package/lib/agents/brief.ts +3 -1
  28. package/lib/bus/format.ts +25 -4
  29. package/lib/bus/index.ts +113 -106
  30. package/lib/bus/resolve.ts +16 -2
  31. package/lib/bus/targets.ts +122 -0
  32. package/lib/bus/waits/kick.ts +12 -0
  33. package/lib/bus/waits/lines.ts +69 -0
  34. package/lib/bus/waits/register.ts +83 -0
  35. package/lib/bus/waits/store.ts +196 -0
  36. package/lib/bus/waits/tick.ts +192 -0
  37. package/lib/connect/control-api.ts +108 -2
  38. package/lib/connect/heartbeat.ts +11 -3
  39. package/lib/connect/serve.ts +2 -0
  40. package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
  41. package/lib/db/types.ts +4 -0
  42. package/lib/done/archive.ts +2 -0
  43. package/lib/orchestrator/describe.ts +13 -0
  44. package/lib/orchestrator/facts.ts +17 -0
  45. package/lib/peer/account.ts +198 -0
  46. package/lib/peer/cli.ts +24 -0
  47. package/lib/peer/code.ts +3 -0
  48. package/lib/peer/hello-route.ts +32 -0
  49. package/lib/peer/hello.ts +346 -0
  50. package/lib/peer/link-cli.ts +233 -0
  51. package/lib/peer/machines.ts +72 -0
  52. package/lib/peer/notify.ts +32 -0
  53. package/lib/peer/pairing-out.ts +382 -0
  54. package/lib/peer/pairing.ts +403 -0
  55. package/lib/peer/pin.ts +55 -0
  56. package/lib/peer/rpc-intake.ts +37 -8
  57. package/lib/peer/rpc-server.ts +19 -1
  58. package/lib/schedules/when.ts +1 -1
  59. package/lib/security/auth.ts +10 -2
  60. package/lib/security/gate.ts +3 -0
  61. package/lib/sessions/end.ts +3 -0
  62. package/package.json +1 -1
  63. package/scripts/agent-os +2 -0
  64. package/scripts/lib/commands.sh +2 -0
  65. package/server.ts +3 -0
@@ -0,0 +1,346 @@
1
+ /**
2
+ * The pairing messages between two machines, and the code both show.
3
+ * Everything here is public data, signed by its sender with its Connect
4
+ * machine key; each signature covers a domain string, both machine ids, a
5
+ * timestamp and a nonce, so no message stands in for another or for another
6
+ * pair of machines.
7
+ *
8
+ * A → B hello {from, to, ts, nonce, sign_pub, box_pub, commit}
9
+ * commit = SHA-256(a_box ‖ a_sign ‖ a_nonce)
10
+ * B → A ack {request_id, sign_pub, box_pub, b_nonce} (bound to the hello)
11
+ * A → B reveal {request_id, a_nonce}
12
+ * A → B status {request_id}
13
+ * B → A answer {state} (bound to that call's nonce)
14
+ *
15
+ * The code is HKDF over both machines' keys and both nonces. A has fixed
16
+ * its keys and nonce before it sees b_nonce, and B's nonce is fresh, so
17
+ * whoever substitutes keys gets one guess in a million per attempt.
18
+ *
19
+ * Two codes come from that transcript. A shows the first and a person types
20
+ * it on B, which proves to B that A's keys are the ones it holds. B then
21
+ * shows the second (a different HKDF info) and the person types it on A:
22
+ * that proves B's keys to A, so a control plane that lists a key of its own
23
+ * for B gets nothing pinned on either side.
24
+ */
25
+
26
+ import crypto, { type KeyObject } from "crypto";
27
+ import { MACHINE_ID } from "../connect/control-api";
28
+ import { SKEW_MS } from "./envelope";
29
+ import { signPublic } from "./keys";
30
+
31
+ export const HELLO_MAX_BYTES = 4096;
32
+ export { SKEW_MS };
33
+
34
+ const B64_16 = /^[A-Za-z0-9_-]{22}$/;
35
+ const B64_32 = /^[A-Za-z0-9_-]{43}$/;
36
+ const B64_SIG = /^[A-Za-z0-9_-]{86}$/;
37
+
38
+ export type LinkState = "pending" | "accepted" | "declined" | "expired";
39
+ const STATES: readonly LinkState[] = [
40
+ "pending",
41
+ "accepted",
42
+ "declined",
43
+ "expired",
44
+ ];
45
+
46
+ const D = {
47
+ hello: "agentos-peer-hello-v1",
48
+ ack: "agentos-peer-hello-ack-v1",
49
+ reveal: "agentos-peer-reveal-v1",
50
+ status: "agentos-peer-status-v1",
51
+ answer: "agentos-peer-answer-v1",
52
+ } as const;
53
+
54
+ const b64 = (b: Buffer) => b.toString("base64url");
55
+ const raw = (s: string) => Buffer.from(s, "base64url");
56
+ export const randomNonce = (bytes: 16 | 32) => b64(crypto.randomBytes(bytes));
57
+
58
+ // Every field is checked against a pattern with no newline before it's
59
+ // signed or verified, so joining with "\n" is unambiguous.
60
+ const message = (domain: string, fields: (string | number)[]) =>
61
+ Buffer.from([domain, ...fields.map(String)].join("\n"));
62
+
63
+ const signFields = (
64
+ key: KeyObject,
65
+ domain: string,
66
+ fields: (string | number)[]
67
+ ) => b64(crypto.sign(null, message(domain, fields), key));
68
+
69
+ function verifyFields(
70
+ pub: string,
71
+ sig: string,
72
+ domain: string,
73
+ fields: (string | number)[]
74
+ ): boolean {
75
+ if (!B64_SIG.test(sig)) return false;
76
+ try {
77
+ return crypto.verify(
78
+ null,
79
+ message(domain, fields),
80
+ signPublic(pub),
81
+ raw(sig)
82
+ );
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ export const commitTo = (boxPub: string, signPub: string, nonce: string) =>
89
+ b64(
90
+ crypto
91
+ .createHash("sha256")
92
+ .update(Buffer.concat([raw(boxPub), raw(signPub), raw(nonce)]))
93
+ .digest()
94
+ );
95
+
96
+ export interface Transcript {
97
+ from: string;
98
+ to: string;
99
+ aSign: string;
100
+ aBox: string;
101
+ bSign: string;
102
+ bBox: string;
103
+ aNonce: string;
104
+ bNonce: string;
105
+ }
106
+
107
+ const INFO = { sas: "agentos-peer-sas-v1", confirm: "agentos-peer-confirm-v1" };
108
+
109
+ /**
110
+ * Six digits from both sides' keys and nonces: `sas` is shown on A and typed
111
+ * on B, `confirm` is shown on B once that matched and typed on A.
112
+ */
113
+ export function deriveCode(
114
+ o: Transcript,
115
+ purpose: keyof typeof INFO = "sas"
116
+ ): string {
117
+ const ikm = Buffer.concat(
118
+ [o.aSign, o.aBox, o.bSign, o.bBox, o.aNonce, o.bNonce].map(raw)
119
+ );
120
+ const okm = Buffer.from(
121
+ crypto.hkdfSync(
122
+ "sha256",
123
+ ikm,
124
+ Buffer.alloc(0),
125
+ `${INFO[purpose]}|${o.from}|${o.to}`,
126
+ 8
127
+ )
128
+ );
129
+ return (okm.readBigUInt64BE(0) % BigInt(1_000_000))
130
+ .toString()
131
+ .padStart(6, "0");
132
+ }
133
+
134
+ export { showCode } from "./code";
135
+
136
+ /** What a person typed, as six digits, or null. */
137
+ export function typedCode(input: unknown): string | null {
138
+ if (typeof input !== "string" || input.length > 32) return null;
139
+ const digits = input.replace(/[\s-]/g, "");
140
+ return /^\d{6}$/.test(digits) ? digits : null;
141
+ }
142
+
143
+ export const sameCode = (a: string, b: string) =>
144
+ a.length === b.length &&
145
+ crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
146
+
147
+ // ----- the messages -----
148
+
149
+ interface Signed {
150
+ from: string;
151
+ to: string;
152
+ ts: number;
153
+ nonce: string;
154
+ sig: string;
155
+ }
156
+
157
+ export interface Hello extends Signed {
158
+ v: 1;
159
+ sign_pub: string;
160
+ box_pub: string;
161
+ commit: string;
162
+ }
163
+
164
+ export interface Ack {
165
+ request_id: string;
166
+ sign_pub: string;
167
+ box_pub: string;
168
+ b_nonce: string;
169
+ sig: string;
170
+ }
171
+
172
+ export interface Reveal extends Signed {
173
+ v: 1;
174
+ request_id: string;
175
+ a_nonce: string;
176
+ }
177
+
178
+ export interface StatusAsk extends Signed {
179
+ v: 1;
180
+ request_id: string;
181
+ }
182
+
183
+ export interface Answer {
184
+ state: LinkState;
185
+ sig: string;
186
+ }
187
+
188
+ const obj = (v: unknown): Record<string, unknown> | null =>
189
+ v && typeof v === "object" && !Array.isArray(v)
190
+ ? (v as Record<string, unknown>)
191
+ : null;
192
+
193
+ function parseSigned(v: unknown): (Record<string, unknown> & Signed) | null {
194
+ const o = obj(v);
195
+ if (!o || o.v !== 1) return null;
196
+ if (typeof o.from !== "string" || !MACHINE_ID.test(o.from)) return null;
197
+ if (typeof o.to !== "string" || !MACHINE_ID.test(o.to)) return null;
198
+ if (typeof o.ts !== "number" || !Number.isSafeInteger(o.ts)) return null;
199
+ if (typeof o.nonce !== "string" || !B64_16.test(o.nonce)) return null;
200
+ if (typeof o.sig !== "string" || !B64_SIG.test(o.sig)) return null;
201
+ return o as Record<string, unknown> & Signed;
202
+ }
203
+
204
+ const is32 = (v: unknown): v is string =>
205
+ typeof v === "string" && B64_32.test(v);
206
+ const is16 = (v: unknown): v is string =>
207
+ typeof v === "string" && B64_16.test(v);
208
+
209
+ export function parseHello(v: unknown): Hello | null {
210
+ const o = parseSigned(v);
211
+ if (!o || !is32(o.sign_pub) || !is32(o.box_pub) || !is32(o.commit))
212
+ return null;
213
+ return o as unknown as Hello;
214
+ }
215
+
216
+ export function parseReveal(v: unknown): Reveal | null {
217
+ const o = parseSigned(v);
218
+ if (!o || !is16(o.request_id) || !is32(o.a_nonce)) return null;
219
+ return o as unknown as Reveal;
220
+ }
221
+
222
+ export function parseStatusAsk(v: unknown): StatusAsk | null {
223
+ const o = parseSigned(v);
224
+ if (!o || !is16(o.request_id)) return null;
225
+ return o as unknown as StatusAsk;
226
+ }
227
+
228
+ export function parseAck(v: unknown): Ack | null {
229
+ const o = obj(v);
230
+ if (!o || !is16(o.request_id) || !is32(o.sign_pub) || !is32(o.box_pub))
231
+ return null;
232
+ if (!is32(o.b_nonce) || typeof o.sig !== "string" || !B64_SIG.test(o.sig))
233
+ return null;
234
+ return o as unknown as Ack;
235
+ }
236
+
237
+ export function parseAnswer(v: unknown): Answer | null {
238
+ const o = obj(v);
239
+ if (!o || !STATES.includes(o.state as LinkState)) return null;
240
+ if (typeof o.sig !== "string" || !B64_SIG.test(o.sig)) return null;
241
+ return o as unknown as Answer;
242
+ }
243
+
244
+ const helloFields = (h: Omit<Hello, "sig" | "v">) => [
245
+ h.from,
246
+ h.to,
247
+ h.ts,
248
+ h.nonce,
249
+ h.sign_pub,
250
+ h.box_pub,
251
+ h.commit,
252
+ ];
253
+
254
+ export function signHello(key: KeyObject, h: Omit<Hello, "sig" | "v">): Hello {
255
+ return { v: 1, ...h, sig: signFields(key, D.hello, helloFields(h)) };
256
+ }
257
+
258
+ export const verifyHello = (h: Hello) =>
259
+ verifyFields(h.sign_pub, h.sig, D.hello, helloFields(h));
260
+
261
+ // The ack names both machines and the hello it answers.
262
+ const ackFields = (a: Omit<Ack, "sig">, hello: Hello) => [
263
+ hello.to,
264
+ hello.from,
265
+ hello.nonce,
266
+ a.request_id,
267
+ a.sign_pub,
268
+ a.box_pub,
269
+ a.b_nonce,
270
+ ];
271
+
272
+ export function signAck(
273
+ key: KeyObject,
274
+ a: Omit<Ack, "sig">,
275
+ hello: Hello
276
+ ): Ack {
277
+ return { ...a, sig: signFields(key, D.ack, ackFields(a, hello)) };
278
+ }
279
+
280
+ export const verifyAck = (a: Ack, hello: Hello, bSign: string) =>
281
+ a.sign_pub === bSign &&
282
+ verifyFields(bSign, a.sig, D.ack, ackFields(a, hello));
283
+
284
+ const revealFields = (r: Omit<Reveal, "sig" | "v">) => [
285
+ r.from,
286
+ r.to,
287
+ r.ts,
288
+ r.nonce,
289
+ r.request_id,
290
+ r.a_nonce,
291
+ ];
292
+
293
+ export function signReveal(
294
+ key: KeyObject,
295
+ r: Omit<Reveal, "sig" | "v">
296
+ ): Reveal {
297
+ return { v: 1, ...r, sig: signFields(key, D.reveal, revealFields(r)) };
298
+ }
299
+
300
+ export const verifyReveal = (r: Reveal, aSign: string) =>
301
+ verifyFields(aSign, r.sig, D.reveal, revealFields(r));
302
+
303
+ const statusFields = (s: Omit<StatusAsk, "sig" | "v">) => [
304
+ s.from,
305
+ s.to,
306
+ s.ts,
307
+ s.nonce,
308
+ s.request_id,
309
+ ];
310
+
311
+ export function signStatusAsk(
312
+ key: KeyObject,
313
+ s: Omit<StatusAsk, "sig" | "v">
314
+ ): StatusAsk {
315
+ return { v: 1, ...s, sig: signFields(key, D.status, statusFields(s)) };
316
+ }
317
+
318
+ export const verifyStatusAsk = (s: StatusAsk, aSign: string) =>
319
+ verifyFields(aSign, s.sig, D.status, statusFields(s));
320
+
321
+ /** B's answer to a reveal or a status call, bound to that call. */
322
+ const answerFields = (state: LinkState, ask: Reveal | StatusAsk) => [
323
+ ask.to,
324
+ ask.from,
325
+ ask.request_id,
326
+ ask.nonce,
327
+ state,
328
+ ];
329
+
330
+ export const signAnswer = (
331
+ key: KeyObject,
332
+ state: LinkState,
333
+ ask: Reveal | StatusAsk
334
+ ): Answer => ({
335
+ state,
336
+ sig: signFields(key, D.answer, answerFields(state, ask)),
337
+ });
338
+
339
+ export const verifyAnswer = (
340
+ a: Answer,
341
+ ask: Reveal | StatusAsk,
342
+ bSign: string
343
+ ) => verifyFields(bSign, a.sig, D.answer, answerFields(a.state, ask));
344
+
345
+ export const withinSkew = (ts: number, now: number) =>
346
+ Math.abs(now - ts) <= SKEW_MS;
@@ -0,0 +1,233 @@
1
+ /**
2
+ * agent-os link: link machines on your AgentOS Connect account from a
3
+ * terminal, for boxes with no browser. Talks to the AgentOS running here
4
+ * over loopback, which counts as this machine (it may accept a code).
5
+ *
6
+ * agent-os link the account's machines and requests waiting here
7
+ * agent-os link <machine> start a link; prints the code to type there,
8
+ * then asks for the one it shows back
9
+ * agent-os link accept <code> [--from <machine>] prints the code to type back
10
+ * agent-os link decline [<machine>]
11
+ * agent-os unlink <machine>
12
+ */
13
+
14
+ import type { AccountMachines } from "./machines";
15
+ import type { PendingView } from "./pairing";
16
+ import type { Outgoing } from "./pairing-out";
17
+ import { showCode } from "./code";
18
+
19
+ export interface CliIo {
20
+ base: string;
21
+ fetch: typeof fetch;
22
+ out: (line: string) => void;
23
+ sleep: (ms: number) => Promise<void>;
24
+ /** One line typed in the terminal, or null when there's none to read. */
25
+ ask: (prompt: string) => Promise<string | null>;
26
+ }
27
+
28
+ class CliError extends Error {}
29
+
30
+ async function call<T>(
31
+ io: CliIo,
32
+ path: string,
33
+ init?: RequestInit
34
+ ): Promise<T> {
35
+ let res: Response;
36
+ try {
37
+ res = await io.fetch(`${io.base}${path}`, {
38
+ ...init,
39
+ headers: { "Content-Type": "application/json" },
40
+ });
41
+ } catch {
42
+ throw new CliError(
43
+ `Can't reach AgentOS at ${io.base}. Is it running? (agent-os status)`
44
+ );
45
+ }
46
+ const data = (await res.json().catch(() => ({}))) as T & { error?: string };
47
+ if (!res.ok) throw new CliError(data.error ?? `HTTP ${res.status}`);
48
+ return data;
49
+ }
50
+
51
+ const same = (a: string, b: string) =>
52
+ a.trim().toLowerCase() === b.trim().toLowerCase();
53
+
54
+ function pick<T extends { name: string }>(
55
+ items: T[],
56
+ ref: string | undefined,
57
+ idOf: (t: T) => string,
58
+ what: string
59
+ ): T {
60
+ if (ref) {
61
+ const hit = items.filter((i) => same(i.name, ref) || idOf(i) === ref);
62
+ if (hit.length === 1) return hit[0];
63
+ throw new CliError(
64
+ hit.length
65
+ ? `More than one ${what} is called ${ref}; use its id.`
66
+ : `No ${what} called ${ref}.`
67
+ );
68
+ }
69
+ if (items.length === 1) return items[0];
70
+ throw new CliError(
71
+ items.length
72
+ ? `Several ${what}s: say which with --from (${items.map((i) => i.name).join(", ")}).`
73
+ : `No ${what} is waiting.`
74
+ );
75
+ }
76
+
77
+ async function list(io: CliIo) {
78
+ const account = await call<AccountMachines>(
79
+ io,
80
+ "/api/peers/account?refresh=1"
81
+ );
82
+ const { requests } = await call<{ requests: PendingView[] }>(
83
+ io,
84
+ "/api/peers/pending"
85
+ );
86
+ if (!account.enrolled) {
87
+ io.out(
88
+ "This machine isn't on AgentOS Connect yet: run agent-os connect first."
89
+ );
90
+ return;
91
+ }
92
+ if (account.entitlements && !account.entitlements.linkMachines)
93
+ io.out("Linking machines needs Pro: upgrade at runagentos.com.\n");
94
+ if (account.error) io.out(`${account.error}\n`);
95
+ if (account.machines.length === 0)
96
+ io.out("No other machines on your account yet.");
97
+ for (const m of account.machines)
98
+ io.out(
99
+ ` ${m.linkState === "active" ? "linked " : m.linkState === "suspended" ? "paused " : " "} ${m.name} (${m.online ? "online" : "offline"}, ${m.id})`
100
+ );
101
+ for (const r of requests)
102
+ io.out(
103
+ `\n${r.name} wants to link here: agent-os link accept <the code it shows>`
104
+ );
105
+ if (account.machines.some((m) => !m.linkState))
106
+ io.out("\nLink one with: agent-os link <name>");
107
+ }
108
+
109
+ async function start(io: CliIo, ref: string) {
110
+ const account = await call<AccountMachines>(
111
+ io,
112
+ "/api/peers/account?refresh=1"
113
+ );
114
+ const target = pick(
115
+ account.machines,
116
+ ref,
117
+ (m) => m.id,
118
+ "machine on your account"
119
+ );
120
+ const { link } = await call<{ link: Outgoing }>(io, "/api/peers/link", {
121
+ method: "POST",
122
+ body: JSON.stringify({ machineId: target.id }),
123
+ });
124
+ io.out(
125
+ `\n Type this code on ${link.name}:\n\n ${showCode(link.code)}\n`
126
+ );
127
+ io.out(
128
+ ` (its Machines screen, its banner, or agent-os link accept there)\n Waiting...`
129
+ );
130
+ let state: Outgoing = link;
131
+ while (state.state === "pending") {
132
+ await io.sleep(1500);
133
+ state = (await call<{ link: Outgoing }>(io, `/api/peers/link/${link.id}`))
134
+ .link;
135
+ }
136
+ while (state.state === "confirm") {
137
+ const typed = await io.ask(
138
+ `\n The code matched on ${state.name}. Type the code it shows now: `
139
+ );
140
+ if (typed === null)
141
+ throw new CliError("No code typed: nothing was linked.");
142
+ const res = await io.fetch(`${io.base}/api/peers/link/${link.id}/confirm`, {
143
+ method: "POST",
144
+ headers: { "Content-Type": "application/json" },
145
+ body: JSON.stringify({ code: typed }),
146
+ });
147
+ const data = (await res.json().catch(() => ({}))) as {
148
+ link?: Outgoing;
149
+ error?: string;
150
+ attemptsLeft?: number;
151
+ };
152
+ if (data.link) state = data.link;
153
+ else if (data.attemptsLeft)
154
+ io.out(` ${data.error} ${data.attemptsLeft} left.`);
155
+ else throw new CliError(data.error ?? `HTTP ${res.status}`);
156
+ }
157
+ if (state.state !== "accepted")
158
+ throw new CliError(
159
+ state.state === "expired"
160
+ ? "The code expired. Run agent-os link again."
161
+ : `Not linked (${state.state}${state.error ? `: ${state.error}` : ""}).`
162
+ );
163
+ io.out(`\n Linked with ${state.name}.\n`);
164
+ }
165
+
166
+ async function answer(io: CliIo, verb: "accept" | "decline", rest: string[]) {
167
+ const i = rest.indexOf("--from");
168
+ const from = i > -1 ? rest[i + 1] : verb === "decline" ? rest[0] : undefined;
169
+ // "482 913" arrives as two words.
170
+ const code =
171
+ verb === "accept"
172
+ ? rest.filter((_, j) => i < 0 || (j !== i && j !== i + 1)).join("")
173
+ : undefined;
174
+ if (verb === "accept" && !code)
175
+ throw new CliError("Usage: agent-os link accept <code> [--from <machine>]");
176
+ const { requests } = await call<{ requests: PendingView[] }>(
177
+ io,
178
+ "/api/peers/pending"
179
+ );
180
+ const req = pick(requests, from, (r) => r.machineId, "link request");
181
+ if (verb === "decline") {
182
+ await call(io, `/api/peers/pending/${req.id}/decline`, { method: "POST" });
183
+ io.out(`Declined ${req.name}.`);
184
+ return;
185
+ }
186
+ const r = await call<{ name: string; confirmCode: string }>(
187
+ io,
188
+ `/api/peers/pending/${req.id}/accept`,
189
+ {
190
+ method: "POST",
191
+ body: JSON.stringify({ code }),
192
+ }
193
+ );
194
+ io.out(
195
+ `Linked with ${r.name}. It can start agents and run code here; unlink with agent-os unlink "${r.name}".`
196
+ );
197
+ io.out(
198
+ `\n To finish, type this code on ${r.name}:\n\n ${showCode(r.confirmCode)}\n`
199
+ );
200
+ }
201
+
202
+ async function unlink(io: CliIo, ref: string | undefined) {
203
+ if (!ref) throw new CliError("Usage: agent-os unlink <machine>");
204
+ const { hosts } = await call<{
205
+ hosts: { id: string; name: string; kind?: string }[];
206
+ }>(io, "/api/hosts");
207
+ const host = pick(
208
+ hosts.filter((h) => h.kind === "connect"),
209
+ ref,
210
+ (h) => h.id,
211
+ "linked machine"
212
+ );
213
+ await call(io, `/api/hosts/${host.id}`, { method: "DELETE" });
214
+ io.out(
215
+ `Unlinked ${host.name} here. Remove this machine from its Machines too.`
216
+ );
217
+ }
218
+
219
+ /** Runs one command; returns the exit code. */
220
+ export async function runLinkCli(args: string[], io: CliIo): Promise<number> {
221
+ try {
222
+ const [first, ...rest] = args;
223
+ if (first === "--unlink") await unlink(io, rest[0]);
224
+ else if (!first) await list(io);
225
+ else if (first === "accept" || first === "decline")
226
+ await answer(io, first, rest);
227
+ else await start(io, args.join(" "));
228
+ return 0;
229
+ } catch (err) {
230
+ io.out(` link: ${err instanceof Error ? err.message : String(err)}`);
231
+ return 1;
232
+ }
233
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * What Machines shows under "On your AgentOS account": the account's other
3
+ * machines, each with whether it's linked here, and the plan.
4
+ */
5
+
6
+ import { getDb } from "../db";
7
+ import { accountView } from "./account";
8
+ import type { Entitlements } from "../connect/control-api";
9
+
10
+ export interface AccountMachine {
11
+ id: string;
12
+ name: string;
13
+ hostname: string;
14
+ online: boolean;
15
+ lastSeenAt: string | null;
16
+ version: string | null;
17
+ /** The Machines host it's linked as, when it is. */
18
+ hostId: string | null;
19
+ linkState: "active" | "suspended" | null;
20
+ }
21
+
22
+ export interface AccountMachines {
23
+ enrolled: boolean;
24
+ entitlements: Entitlements | null;
25
+ fetchedAt: number;
26
+ error: string | null;
27
+ machines: AccountMachine[];
28
+ }
29
+
30
+ // The service shows a machine online for three missed beats.
31
+ const ONLINE_MS = 3 * 60_000;
32
+
33
+ export async function accountMachines(
34
+ refresh = false,
35
+ now = Date.now()
36
+ ): Promise<AccountMachines> {
37
+ const view = await accountView(refresh ? 0 : undefined);
38
+ const pins = new Map(
39
+ (
40
+ getDb()
41
+ .prepare(
42
+ `SELECT p.machine_id, p.host_id, p.state FROM peer_links p
43
+ JOIN hosts h ON h.id = p.host_id WHERE h.kind = 'connect'`
44
+ )
45
+ .all() as { machine_id: string; host_id: string; state: string }[]
46
+ ).map((r) => [r.machine_id, r])
47
+ );
48
+ return {
49
+ enrolled: view.enrolled,
50
+ entitlements: view.entitlements,
51
+ fetchedAt: view.fetchedAt,
52
+ error: view.error,
53
+ machines: view.peers.map((p) => {
54
+ const pin = pins.get(p.id);
55
+ const seen = p.lastSeenAt ? Date.parse(p.lastSeenAt) : NaN;
56
+ return {
57
+ id: p.id,
58
+ name: p.name,
59
+ hostname: p.hostname,
60
+ online: Number.isFinite(seen) && now - seen < ONLINE_MS,
61
+ lastSeenAt: p.lastSeenAt,
62
+ version: p.version,
63
+ hostId: pin?.host_id ?? null,
64
+ linkState: pin
65
+ ? pin.state === "active"
66
+ ? "active"
67
+ : "suspended"
68
+ : null,
69
+ };
70
+ }),
71
+ };
72
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * What pairing tells the person: a request waiting for its code here, and a
3
+ * machine linked. Pushed to paired phones (or Telegram); never throws.
4
+ */
5
+
6
+ import { notifyPhone } from "../notify";
7
+
8
+ const short = (name: string) => name.replace(/\s+/g, " ").slice(0, 60);
9
+
10
+ export function notifyLinkRequest(name: string) {
11
+ notifyPhone(
12
+ "peer-link",
13
+ `${short(name)} wants to link to this machine. Type the code it shows in Machines to allow it.`,
14
+ {
15
+ category: "system",
16
+ key: `peer-link:${Date.now()}`,
17
+ collapse: "peer-link",
18
+ }
19
+ );
20
+ }
21
+
22
+ export function notifyLinked(name: string) {
23
+ notifyPhone(
24
+ "peer-link",
25
+ `New machine linked: ${short(name)}. It can start agents and run code here; unlink it in Machines.`,
26
+ {
27
+ category: "system",
28
+ key: `peer-linked:${Date.now()}`,
29
+ collapse: "peer-link",
30
+ }
31
+ );
32
+ }