@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
@@ -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
- return (await res.json()) as { enabled: boolean };
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 {
@@ -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<{ enabled: boolean }>;
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 { enabled } = await opts.beat();
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");
@@ -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;
@@ -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
+ }
@@ -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));
@@ -0,0 +1,3 @@
1
+ /** "482913" as people read it. Pure, for the browser too. */
2
+ export const showCode = (code: string) =>
3
+ `${code.slice(0, 3)} ${code.slice(3)}`;
@@ -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
+ }