@saadnvd1/agent-os 0.3.697 → 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 (88) 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/peer/ping/route.ts +10 -0
  7. package/app/api/peers/account/route.ts +9 -0
  8. package/app/api/peers/gate.test.ts +111 -0
  9. package/app/api/peers/link/[id]/confirm/route.ts +27 -0
  10. package/app/api/peers/link/[id]/route.ts +20 -0
  11. package/app/api/peers/link/route.ts +23 -0
  12. package/app/api/peers/pending/[id]/accept/route.ts +31 -0
  13. package/app/api/peers/pending/[id]/decline/route.ts +13 -0
  14. package/app/api/peers/pending/route.ts +7 -0
  15. package/app/api/sessions/route.ts +18 -1
  16. package/app/page.tsx +2 -0
  17. package/components/Hosts/AccountMachines.tsx +180 -0
  18. package/components/Hosts/HostRow.tsx +44 -35
  19. package/components/Hosts/HostsDialog.tsx +49 -37
  20. package/components/Hosts/IncomingLinkSheet.tsx +228 -0
  21. package/components/Hosts/LinkCodeSheet.tsx +186 -0
  22. package/components/Hosts/sheet.ts +3 -0
  23. package/components/SessionList/RowSubtitle.tsx +11 -1
  24. package/components/Welcome/ConnectBlock.tsx +1 -1
  25. package/data/peers/index.ts +2 -0
  26. package/data/peers/keys.ts +6 -0
  27. package/data/peers/queries.ts +141 -0
  28. package/lib/__fixtures__/connect-transport.ts +93 -0
  29. package/lib/__fixtures__/peer-machine.ts +45 -0
  30. package/lib/__fixtures__/peer-rpc.ts +224 -0
  31. package/lib/agents/brief.ts +3 -1
  32. package/lib/bus/format.ts +25 -4
  33. package/lib/bus/index.ts +113 -106
  34. package/lib/bus/resolve.ts +16 -2
  35. package/lib/bus/targets.ts +122 -0
  36. package/lib/bus/waits/kick.ts +12 -0
  37. package/lib/bus/waits/lines.ts +69 -0
  38. package/lib/bus/waits/register.ts +83 -0
  39. package/lib/bus/waits/store.ts +196 -0
  40. package/lib/bus/waits/tick.ts +192 -0
  41. package/lib/connect/control-api.ts +108 -2
  42. package/lib/connect/heartbeat.ts +11 -3
  43. package/lib/connect/serve.ts +2 -0
  44. package/lib/db/migrations/20261020T1258_peer_links.ts +49 -0
  45. package/lib/db/migrations/20261020T1345_peer_nonces.ts +18 -0
  46. package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
  47. package/lib/db/types.ts +6 -0
  48. package/lib/done/archive.ts +2 -0
  49. package/lib/hosts/index.ts +28 -7
  50. package/lib/hosts/peer-socket.ts +3 -1
  51. package/lib/hosts/read-capped-bytes.ts +23 -0
  52. package/lib/hosts/remote-api.ts +112 -41
  53. package/lib/orchestrator/describe.ts +13 -0
  54. package/lib/orchestrator/facts.ts +17 -0
  55. package/lib/peer/account.ts +198 -0
  56. package/lib/peer/allowlist.ts +136 -0
  57. package/lib/peer/audit.ts +144 -0
  58. package/lib/peer/cli.ts +24 -0
  59. package/lib/peer/client.ts +188 -0
  60. package/lib/peer/code.ts +3 -0
  61. package/lib/peer/envelope.ts +424 -0
  62. package/lib/peer/hello-route.ts +32 -0
  63. package/lib/peer/hello.ts +346 -0
  64. package/lib/peer/internal.ts +22 -0
  65. package/lib/peer/keys.ts +122 -0
  66. package/lib/peer/link-cli.ts +233 -0
  67. package/lib/peer/links.ts +96 -0
  68. package/lib/peer/machines.ts +72 -0
  69. package/lib/peer/nonce-store.ts +23 -0
  70. package/lib/peer/notify.ts +32 -0
  71. package/lib/peer/pairing-out.ts +382 -0
  72. package/lib/peer/pairing.ts +403 -0
  73. package/lib/peer/pin.ts +55 -0
  74. package/lib/peer/replay.ts +81 -0
  75. package/lib/peer/rpc-intake.ts +258 -0
  76. package/lib/peer/rpc-server.ts +369 -0
  77. package/lib/schedules/when.ts +1 -1
  78. package/lib/security/auth.ts +51 -6
  79. package/lib/security/gate.ts +38 -12
  80. package/lib/security/route-guard.ts +3 -1
  81. package/lib/session-artifacts/peer.ts +12 -1
  82. package/lib/sessions/end.ts +3 -0
  83. package/lib/tasks/contract/authorize.ts +4 -0
  84. package/lib/worktree-sweep/testTemp.ts +2 -0
  85. package/package.json +1 -1
  86. package/scripts/agent-os +2 -0
  87. package/scripts/lib/commands.sh +2 -0
  88. package/server.ts +21 -0
@@ -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,96 @@
1
+ /**
2
+ * Pinned peers: a machine linked through Connect, by its Connect id, with
3
+ * the keys it paired with. Nothing here talks to the network.
4
+ */
5
+
6
+ import { getDb } from "../db";
7
+
8
+ export type PinState = "active" | "suspended";
9
+
10
+ export interface PeerPin {
11
+ hostId: string;
12
+ machineId: string;
13
+ hostname: string;
14
+ signPub: string;
15
+ boxPub: string;
16
+ state: PinState;
17
+ suspendedReason: string | null;
18
+ }
19
+
20
+ interface Row {
21
+ host_id: string;
22
+ machine_id: string;
23
+ hostname: string;
24
+ sign_pub: string;
25
+ box_pub: string;
26
+ state: string;
27
+ suspended_reason: string | null;
28
+ }
29
+
30
+ const toPin = (r: Row | undefined): PeerPin | null =>
31
+ r
32
+ ? {
33
+ hostId: r.host_id,
34
+ machineId: r.machine_id,
35
+ hostname: r.hostname,
36
+ signPub: r.sign_pub,
37
+ boxPub: r.box_pub,
38
+ state: r.state === "active" ? "active" : "suspended",
39
+ suspendedReason: r.suspended_reason,
40
+ }
41
+ : null;
42
+
43
+ // Only a host created as a Connect machine counts: a pin on any other row
44
+ // would be a second way in that nothing shows.
45
+ const SELECT = `SELECT p.* FROM peer_links p JOIN hosts h ON h.id = p.host_id
46
+ WHERE h.kind = 'connect'`;
47
+
48
+ export const pinForMachine = (machineId: string): PeerPin | null =>
49
+ toPin(
50
+ getDb().prepare(`${SELECT} AND p.machine_id = ?`).get(machineId) as
51
+ Row | undefined
52
+ );
53
+
54
+ /** Whether any linked machine may call in at all. */
55
+ export const hasActivePin = (): boolean =>
56
+ getDb().prepare(`${SELECT} AND p.state = 'active' LIMIT 1`).get() !==
57
+ undefined;
58
+
59
+ export const pinForHost = (hostId: string): PeerPin | null =>
60
+ toPin(
61
+ getDb().prepare(`${SELECT} AND p.host_id = ?`).get(hostId) as
62
+ Row | undefined
63
+ );
64
+
65
+ /** Pins a peer's keys to a Connect host. Pairing (S2) is the only caller. */
66
+ export function savePin(pin: Omit<PeerPin, "state" | "suspendedReason">) {
67
+ getDb()
68
+ .prepare(
69
+ `INSERT INTO peer_links (host_id, machine_id, hostname, sign_pub, box_pub)
70
+ VALUES (?, ?, ?, ?, ?)
71
+ ON CONFLICT(host_id) DO UPDATE SET machine_id = excluded.machine_id,
72
+ hostname = excluded.hostname, sign_pub = excluded.sign_pub,
73
+ box_pub = excluded.box_pub, state = 'active', suspended_reason = NULL,
74
+ paired_at = datetime('now'), last_error = NULL`
75
+ )
76
+ .run(pin.hostId, pin.machineId, pin.hostname, pin.signPub, pin.boxPub);
77
+ }
78
+
79
+ export function suspendPin(hostId: string, reason: string) {
80
+ getDb()
81
+ .prepare(
82
+ `UPDATE peer_links SET state = 'suspended', suspended_reason = ? WHERE host_id = ?`
83
+ )
84
+ .run(reason, hostId);
85
+ }
86
+
87
+ /** Last outcome of a call to that peer, for Machines. */
88
+ export function notePinResult(hostId: string, error: string | null) {
89
+ getDb()
90
+ .prepare(
91
+ error
92
+ ? `UPDATE peer_links SET last_error = ? WHERE host_id = ?`
93
+ : `UPDATE peer_links SET last_ok_at = datetime('now'), last_error = NULL WHERE host_id = ?`
94
+ )
95
+ .run(...(error ? [error.slice(0, 300), hostId] : [hostId]));
96
+ }
@@ -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,23 @@
1
+ /**
2
+ * The replay guard's nonces in SQLite, so they outlive a restart. One
3
+ * statement both checks and records, so two requests carrying the same nonce
4
+ * can't both pass.
5
+ */
6
+
7
+ import { getDb } from "../db";
8
+ import type { NonceStore } from "./replay";
9
+
10
+ export function sqliteNonceStore(db = getDb): NonceStore {
11
+ return {
12
+ claim: (peer, nonce, until, now) =>
13
+ db()
14
+ .prepare(
15
+ `INSERT INTO peer_nonces (machine_id, nonce, until) VALUES (?, ?, ?)
16
+ ON CONFLICT (machine_id, nonce) DO UPDATE SET until = excluded.until
17
+ WHERE peer_nonces.until < ?`
18
+ )
19
+ .run(peer.slice(0, 64), nonce, until, now).changes === 1,
20
+ prune: (now) =>
21
+ void db().prepare(`DELETE FROM peer_nonces WHERE until < ?`).run(now),
22
+ };
23
+ }
@@ -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
+ }