@saadnvd1/agent-os 0.3.694 → 0.3.698

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 (38) hide show
  1. package/app/api/peer/ping/route.ts +10 -0
  2. package/components/Settings/NoApprovalsSection.tsx +1 -1
  3. package/lib/__fixtures__/connect-transport.ts +93 -0
  4. package/lib/__fixtures__/peer-machine.ts +45 -0
  5. package/lib/__fixtures__/peer-rpc.ts +224 -0
  6. package/lib/approvals/enable.ts +1 -21
  7. package/lib/approvals/no-approvals.ts +1 -1
  8. package/lib/db/migrations/20261020T1258_peer_links.ts +49 -0
  9. package/lib/db/migrations/20261020T1345_peer_nonces.ts +18 -0
  10. package/lib/db/types.ts +2 -0
  11. package/lib/hosts/index.ts +28 -7
  12. package/lib/hosts/peer-socket.ts +3 -1
  13. package/lib/hosts/read-capped-bytes.ts +23 -0
  14. package/lib/hosts/remote-api.ts +112 -41
  15. package/lib/models/plan-step.ts +5 -13
  16. package/lib/peer/allowlist.ts +136 -0
  17. package/lib/peer/audit.ts +144 -0
  18. package/lib/peer/client.ts +188 -0
  19. package/lib/peer/envelope.ts +424 -0
  20. package/lib/peer/internal.ts +22 -0
  21. package/lib/peer/keys.ts +122 -0
  22. package/lib/peer/links.ts +96 -0
  23. package/lib/peer/nonce-store.ts +23 -0
  24. package/lib/peer/replay.ts +81 -0
  25. package/lib/peer/rpc-intake.ts +229 -0
  26. package/lib/peer/rpc-server.ts +351 -0
  27. package/lib/security/auth.ts +42 -5
  28. package/lib/security/gate.ts +35 -12
  29. package/lib/security/route-guard.ts +3 -1
  30. package/lib/session-artifacts/peer.ts +12 -1
  31. package/lib/tasks/contract/authorize.ts +4 -0
  32. package/lib/tasks/contract/plan.ts +1 -14
  33. package/lib/worktree-sweep/testTemp.ts +3 -0
  34. package/package.json +1 -1
  35. package/scripts/install-deps +6 -1
  36. package/scripts/lib/commands.sh +1 -1
  37. package/scripts/lib/wsl.test.ts +51 -1
  38. package/server.ts +18 -0
@@ -0,0 +1,10 @@
1
+ import { NextResponse } from "next/server";
2
+ import { appVersion } from "@/lib/connect/heartbeat";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ // What a linked machine asks first: which version answers, and which peer
7
+ // features it speaks. Nothing about this machine's sessions or paths.
8
+ export function GET() {
9
+ return NextResponse.json({ version: appVersion(), capabilities: ["rpc-v1"] });
10
+ }
@@ -9,7 +9,7 @@ import { useApprovalsModeQuery, useSetApprovalsMode } from "@/data/approvals";
9
9
  export const NO_APPROVALS_WARNING = [
10
10
  "Every ask is approved the moment it is raised, hard lines included: public posts and messages, money, irreversible or destructive changes, credentials.",
11
11
  "A PR stuck failing its gates, with no CI, or too big to review still merges.",
12
- "Brakes stop holding new starts, and plan-only tasks go on to implement without waiting for a plan approval.",
12
+ "Brakes stop holding new starts. Plan-only tasks still wait for approve_plan.",
13
13
  "Every agent AgentOS starts runs with its permission prompts off, and prompts already waiting are allowed.",
14
14
  ];
15
15
 
@@ -0,0 +1,93 @@
1
+ import { createConnectHost } from "../hosts";
2
+ import { RPC_PATH, openRequest, sealResponse } from "../peer/envelope";
3
+ import { signPublic } from "../peer/keys";
4
+ import { savePin } from "../peer/links";
5
+ import { createReplayGuard } from "../peer/replay";
6
+ import { fakeMachine, writeConnectDir, type FakeMachine } from "./peer-machine";
7
+
8
+ type Inner = (url: string, init: RequestInit) => Promise<Response>;
9
+
10
+ /**
11
+ * The other machine's side of the Connect transport, for tests written
12
+ * against the token transport: it opens each envelope as that machine would
13
+ * and hands the inner call to `inner` as a plain `<baseUrl><path>` fetch, so
14
+ * the same mock answers either transport. Anything not a valid envelope to
15
+ * that machine is refused the way rpc-server refuses it; calls to any other
16
+ * machine go to `inner` unchanged.
17
+ */
18
+ export function connectFetch(
19
+ inner: Inner,
20
+ o: { peer: FakeMachine; from: FakeMachine; baseUrl: string }
21
+ ): typeof fetch {
22
+ const replay = createReplayGuard();
23
+ return (async (input: string | URL | Request, init?: RequestInit) => {
24
+ const url = new URL(String(input));
25
+ // Another machine on a token link answers as it always has.
26
+ if (url.hostname !== o.peer.hostname)
27
+ return inner(String(input), init ?? {});
28
+ if (url.pathname !== RPC_PATH) throw new Error(`unexpected fetch ${url}`);
29
+ const opened = openRequest(Buffer.from(init?.body as Uint8Array), {
30
+ self: o.peer.machineId,
31
+ boxKey: o.peer.box.privateKey,
32
+ signKeyFor: (id) =>
33
+ id === o.from.machineId ? signPublic(o.from.signPub) : "unknown_peer",
34
+ replay,
35
+ });
36
+ if (!opened.ok) return new Response(opened.refusal, { status: 401 });
37
+ const r = opened.request;
38
+ const res = await inner(
39
+ `${o.baseUrl}${r.path}${r.query ? `?${r.query}` : ""}`,
40
+ {
41
+ method: r.method,
42
+ headers: r.contentType ? { "Content-Type": r.contentType } : {},
43
+ body: r.body.length ? r.body.toString("utf8") : undefined,
44
+ }
45
+ );
46
+ const sealed = sealResponse({
47
+ resKey: opened.resKey,
48
+ nonce: opened.nonce,
49
+ signKey: o.peer.signKey,
50
+ response: {
51
+ status: res.status,
52
+ contentType: res.headers.get("content-type") ?? "",
53
+ body: Buffer.from(await res.arrayBuffer()),
54
+ },
55
+ });
56
+ return new Response(new Uint8Array(sealed), { status: 200 });
57
+ }) as typeof fetch;
58
+ }
59
+
60
+ /**
61
+ * A machine linked through Connect, named `name`, whose answers come from
62
+ * `inner` as if it were at `baseUrl`. This machine's Connect folder is
63
+ * pointed at a fresh identity.
64
+ */
65
+ export function connectLinkedHost(
66
+ name: string,
67
+ inner: Inner,
68
+ baseUrl: string
69
+ ): {
70
+ hostId: string;
71
+ fetch: typeof fetch;
72
+ /** Every request this machine actually sent, before the peer opened it. */
73
+ sent: { url: string; init: RequestInit }[];
74
+ } {
75
+ const self = fakeMachine("aaaa1111");
76
+ const peer = fakeMachine("bbbb2222");
77
+ process.env.AGENTOS_CONNECT_DIR = writeConnectDir(self);
78
+ const hostId = createConnectHost(name).id;
79
+ savePin({
80
+ hostId,
81
+ machineId: peer.machineId,
82
+ hostname: peer.hostname,
83
+ signPub: peer.signPub,
84
+ boxPub: peer.box.publicRaw,
85
+ });
86
+ const sent: { url: string; init: RequestInit }[] = [];
87
+ const answer = connectFetch(inner, { peer, from: self, baseUrl });
88
+ const outer = ((input: string | URL | Request, init?: RequestInit) => {
89
+ sent.push({ url: String(input), init: init ?? {} });
90
+ return answer(input, init);
91
+ }) as typeof fetch;
92
+ return { hostId, fetch: outer, sent };
93
+ }
@@ -0,0 +1,45 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { tempDir } from "../test-temp";
5
+ import { rawPublic, type BoxKey, type Identity } from "../peer/keys";
6
+
7
+ /** A Connect machine's keys, for tests: never on disk unless asked. */
8
+ export interface FakeMachine extends Identity {
9
+ box: BoxKey;
10
+ hostname: string;
11
+ }
12
+
13
+ export function fakeMachine(machineId: string): FakeMachine {
14
+ const sign = crypto.generateKeyPairSync("ed25519");
15
+ const box = crypto.generateKeyPairSync("x25519");
16
+ return {
17
+ machineId,
18
+ signKey: sign.privateKey,
19
+ signPub: rawPublic(sign.publicKey),
20
+ box: { privateKey: box.privateKey, publicRaw: rawPublic(box.publicKey) },
21
+ hostname: `m-${crypto.randomBytes(8).toString("hex")}.runagentos.com`,
22
+ };
23
+ }
24
+
25
+ /** Writes the machine's Connect folder (connect.json, machine.key, peer.key). */
26
+ export function writeConnectDir(m: FakeMachine): string {
27
+ const dir = tempDir("aos-peer-connect-");
28
+ fs.writeFileSync(
29
+ path.join(dir, "connect.json"),
30
+ JSON.stringify({
31
+ machineId: m.machineId,
32
+ hostname: m.hostname,
33
+ apiUrl: "https://connect.example.test",
34
+ })
35
+ );
36
+ fs.writeFileSync(
37
+ path.join(dir, "machine.key"),
38
+ m.signKey.export({ format: "pem", type: "pkcs8" })
39
+ );
40
+ fs.writeFileSync(
41
+ path.join(dir, "peer.key"),
42
+ m.box.privateKey.export({ format: "pem", type: "pkcs8" })
43
+ );
44
+ return dir;
45
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Two AgentOS machines' rpc front ends for the peer tests: each with a
3
+ * loopback listener and a tunnel listener, a fake Next behind the gate, and
4
+ * an edge that routes https://<m- name>/… to the right tunnel.
5
+ */
6
+
7
+ import http, { type IncomingMessage, type ServerResponse } from "http";
8
+ import type { AddressInfo } from "net";
9
+ import type { FakeMachine } from "@/lib/__fixtures__/peer-machine";
10
+ import { tunnelListener } from "@/lib/connect/serve";
11
+ import { getDb } from "@/lib/db";
12
+ import { gateRequest, gateUpgrade } from "@/lib/security/gate";
13
+ import type { AuthPolicy } from "@/lib/security/auth";
14
+ import { recordPeerCall } from "@/lib/peer/audit";
15
+ import { peerSecret } from "@/lib/peer/internal";
16
+ import type { PeerPin } from "@/lib/peer/links";
17
+ import { createReplayGuard } from "@/lib/peer/replay";
18
+ import {
19
+ createBodyBudget,
20
+ handlePeerRpc,
21
+ isPeerRpc,
22
+ loopbackDispatch,
23
+ type RpcDeps,
24
+ } from "@/lib/peer/rpc-server";
25
+
26
+ export interface Seen {
27
+ method: string;
28
+ url: string;
29
+ headers: IncomingMessage["headers"];
30
+ body: string;
31
+ }
32
+
33
+ /** What Next would be: echoes how the gate saw the call. */
34
+ function fakeNext(seen: Seen[]) {
35
+ return async (req: IncomingMessage, res: ServerResponse) => {
36
+ let body = "";
37
+ for await (const c of req) body += c;
38
+ seen.push({
39
+ method: req.method ?? "",
40
+ url: req.url ?? "",
41
+ headers: { ...req.headers },
42
+ body,
43
+ });
44
+ res.setHeader("Content-Type", "application/json");
45
+ res.setHeader("Set-Cookie", "aos_device=leak");
46
+ if (req.url?.startsWith("/api/tasks/t-9/merge")) {
47
+ res.statusCode = 409;
48
+ return res.end(JSON.stringify({ error: "not mergeable" }));
49
+ }
50
+ res.end(
51
+ JSON.stringify({
52
+ url: req.url,
53
+ method: req.method,
54
+ trust: req.headers["x-agentos-trust"],
55
+ device: req.headers["x-agentos-device"],
56
+ peer: req.headers["x-agentos-peer"],
57
+ internal: req.headers["x-agentos-peer-internal"] ?? null,
58
+ body: body ? JSON.parse(body) : null,
59
+ })
60
+ );
61
+ };
62
+ }
63
+
64
+ export interface Machine {
65
+ hostname: string;
66
+ tunnelPort: number;
67
+ seen: Seen[];
68
+ deps: RpcDeps;
69
+ close: () => Promise<void>;
70
+ }
71
+
72
+ const listen = (s: http.Server) =>
73
+ new Promise<number>((r) =>
74
+ s.listen(0, "127.0.0.1", () => r((s.address() as AddressInfo).port))
75
+ );
76
+
77
+ /** One machine's AgentOS: its loopback listener and its tunnel listener. */
78
+ export async function machine(
79
+ me: FakeMachine,
80
+ pinFor: (machineId: string) => PeerPin | null
81
+ ): Promise<Machine> {
82
+ const seen: Seen[] = [];
83
+ const policy: AuthPolicy = {
84
+ tailnet: [],
85
+ lookup: (t) => (t === "aosd_phone" ? { id: "phone" } : null),
86
+ peerSecret,
87
+ };
88
+ const next = fakeNext(seen);
89
+ let internalPort = 0;
90
+ const deps: RpcDeps = {
91
+ identity: () => me,
92
+ boxKey: () => me.box,
93
+ pinForMachine: pinFor,
94
+ anyActive: () => true,
95
+ replay: createReplayGuard(),
96
+ dispatch: (d: Parameters<ReturnType<typeof loopbackDispatch>>[0]) =>
97
+ loopbackDispatch(internalPort)(d),
98
+ audit: recordPeerCall,
99
+ budget: createBodyBudget(),
100
+ };
101
+ const onRequest = (req: IncomingMessage, res: ServerResponse) => {
102
+ if (!gateRequest(req, res, policy)) return;
103
+ if (isPeerRpc(req)) return void handlePeerRpc(req, res, deps);
104
+ void next(req, res);
105
+ };
106
+ const internal = http.createServer(onRequest);
107
+ internalPort = await listen(internal);
108
+ const tunnel = tunnelListener(() => me.hostname, {
109
+ onRequest,
110
+ onUpgrade: (req, socket) => {
111
+ if (!gateUpgrade(req, socket, policy)) return;
112
+ socket.end("HTTP/1.1 101 Switching Protocols\r\n\r\n");
113
+ },
114
+ });
115
+ const tunnelPort = await listen(tunnel);
116
+ return {
117
+ hostname: me.hostname,
118
+ tunnelPort,
119
+ seen,
120
+ deps,
121
+ close: async () => {
122
+ await new Promise((r) => internal.close(r));
123
+ await new Promise((r) => tunnel.close(r));
124
+ },
125
+ };
126
+ }
127
+
128
+ /** What Cloudflare does: https://<m- name>/… to that machine's tunnel. */
129
+ export function edge(
130
+ machines: Machine[],
131
+ tamper?: (body: Buffer) => Buffer
132
+ ): typeof fetch {
133
+ return (async (input: string | URL | Request, init?: RequestInit) => {
134
+ const url = new URL(String(input));
135
+ const m = machines.find((x) => x.hostname === url.hostname);
136
+ if (url.protocol !== "https:" || !m) throw new Error("no such host");
137
+ const body = init?.body ? Buffer.from(init.body as Uint8Array) : undefined;
138
+ const { status, buf } = await new Promise<{ status: number; buf: Buffer }>(
139
+ (resolve, reject) => {
140
+ const out = http.request(
141
+ {
142
+ host: "127.0.0.1",
143
+ port: m.tunnelPort,
144
+ method: init?.method ?? "GET",
145
+ path: url.pathname + url.search,
146
+ headers: {
147
+ ...(init?.headers as Record<string, string>),
148
+ host: url.hostname,
149
+ ...(body && { "content-length": String(body.length) }),
150
+ },
151
+ },
152
+ (res) => {
153
+ const chunks: Buffer[] = [];
154
+ res.on("data", (c) => chunks.push(c));
155
+ res.on("end", () =>
156
+ resolve({
157
+ status: res.statusCode ?? 0,
158
+ buf: Buffer.concat(chunks),
159
+ })
160
+ );
161
+ }
162
+ );
163
+ out.on("error", reject);
164
+ out.end(body);
165
+ }
166
+ );
167
+ return new Response(new Uint8Array(tamper ? tamper(buf) : buf), { status });
168
+ }) as typeof fetch;
169
+ }
170
+
171
+ /** Inbound audit outcomes on a route, oldest first. */
172
+ export const outcomesOf = (route: string) =>
173
+ (
174
+ getDb()
175
+ .prepare(`SELECT outcome FROM peer_audit WHERE route = ? ORDER BY id`)
176
+ .all(route) as { outcome: string }[]
177
+ ).map((r) => r.outcome);
178
+
179
+ /** A raw POST to a machine's rpc through the edge. */
180
+ export async function post(m: Machine, body: Buffer) {
181
+ const res = await fetch(`https://${m.hostname}/api/peer/rpc`, {
182
+ method: "POST",
183
+ body: new Uint8Array(body),
184
+ });
185
+ return { status: res.status, text: await res.text() };
186
+ }
187
+
188
+ /** An envelope sent to the tunnel only up to `upTo` bytes, the rest held back. */
189
+ export function partial(m: Machine, envelope: Buffer, upTo: number) {
190
+ const out = http.request({
191
+ host: "127.0.0.1",
192
+ port: m.tunnelPort,
193
+ method: "POST",
194
+ path: "/api/peer/rpc",
195
+ headers: { host: m.hostname, "content-length": String(envelope.length) },
196
+ });
197
+ let answered = false;
198
+ const response = new Promise<{ status: number; text: string }>(
199
+ (resolve, reject) => {
200
+ out.on("response", (res) => {
201
+ answered = true;
202
+ let text = "";
203
+ res.on("data", (c) => (text += c));
204
+ res.on("end", () => resolve({ status: res.statusCode ?? 0, text }));
205
+ });
206
+ out.on("error", (err) => (answered ? undefined : reject(err)));
207
+ }
208
+ );
209
+ response.catch(() => {});
210
+ const closed = new Promise((r) => out.on("close", r));
211
+ if (upTo > 0) out.write(envelope.subarray(0, upTo));
212
+ else out.flushHeaders();
213
+ return {
214
+ response,
215
+ closed,
216
+ within: (ms: number) =>
217
+ Promise.race([
218
+ response.then(() => "answered"),
219
+ new Promise((r) => setTimeout(() => r("pending"), ms)),
220
+ ]),
221
+ finish: () => out.end(envelope.subarray(upTo)),
222
+ destroy: () => out.destroy(),
223
+ };
224
+ }
@@ -2,9 +2,7 @@
2
2
  // brings what already exists in line with it.
3
3
 
4
4
  import { db } from "../db";
5
- import { isRemoteHost } from "../hosts";
6
5
  import { reapplyChatModes } from "../chat/settings";
7
- import { approvePlanNow } from "../models/plan-step";
8
6
  import { autoApproveAsk, type AskRow } from "../orchestrator/asks";
9
7
  import { addNote } from "../orchestrator/notes";
10
8
  import { listOrchestrators } from "../orchestrator/home";
@@ -15,7 +13,7 @@ import {
15
13
  } from "./no-approvals";
16
14
 
17
15
  // Everything already waiting when the mode goes on is approved: open asks,
18
- // plan-only tasks at their plan, tool cards in live chats.
16
+ // tool cards in live chats.
19
17
  async function sweep(): Promise<void> {
20
18
  const asks = db
21
19
  .prepare(`SELECT * FROM orchestrator_asks WHERE status = 'open'`)
@@ -27,24 +25,6 @@ async function sweep(): Promise<void> {
27
25
  console.error(`auto-approving ask ${ask.id} failed:`, e);
28
26
  }
29
27
  }
30
- const plans = db
31
- .prepare(
32
- `SELECT id, host_id, plan_waits_for_caller FROM session_rows WHERE plan_phase = 'awaiting' AND task_status = 'running'`
33
- )
34
- .all() as {
35
- id: string;
36
- host_id: string;
37
- plan_waits_for_caller: number | null;
38
- }[];
39
- for (const p of plans) {
40
- // A plan placed here by another machine is that machine's to decide.
41
- if (p.plan_waits_for_caller && !isRemoteHost(p.host_id)) continue;
42
- try {
43
- await approvePlanNow(p.id, undefined, "no-approvals");
44
- } catch (e) {
45
- console.error(`auto-approving plan of ${p.id} failed:`, e);
46
- }
47
- }
48
28
  }
49
29
 
50
30
  export async function switchNoApprovals(on: boolean): Promise<void> {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * No approvals mode: while it's on, nothing AgentOS starts or runs waits on
3
3
  * a person. Asks are approved as they're raised, brakes let starts through,
4
- * plan-only tasks go on to implement, and agents launch with their harness's
4
+ * and agents launch with their harness's
5
5
  * permission prompts off. Off by default; app-wide; every point that honours
6
6
  * it reads it here and records what it did with recordAutoApproval.
7
7
  */
@@ -0,0 +1,49 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261020T1258_peer_links",
5
+ up: (db) => {
6
+ // A machine linked through Connect (lib/peer): its pinned keys, never a
7
+ // token. Foreign keys aren't enforced here, so deleteHost removes the pin.
8
+ db.exec(`CREATE TABLE IF NOT EXISTS peer_links (
9
+ host_id TEXT PRIMARY KEY REFERENCES hosts(id) ON DELETE CASCADE,
10
+ machine_id TEXT NOT NULL UNIQUE,
11
+ hostname TEXT NOT NULL,
12
+ sign_pub TEXT NOT NULL,
13
+ box_pub TEXT NOT NULL,
14
+ state TEXT NOT NULL DEFAULT 'active',
15
+ suspended_reason TEXT,
16
+ paired_at TEXT NOT NULL DEFAULT (datetime('now')),
17
+ last_ok_at TEXT,
18
+ last_error TEXT
19
+ )`);
20
+ // Every peer call, both directions. Successful polling reads fold into
21
+ // one row per route per hour (`bucket`, `count`).
22
+ db.exec(`CREATE TABLE IF NOT EXISTS peer_audit (
23
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
24
+ at INTEGER NOT NULL,
25
+ host_id TEXT,
26
+ machine_id TEXT,
27
+ direction TEXT NOT NULL,
28
+ method TEXT NOT NULL,
29
+ route TEXT NOT NULL,
30
+ status INTEGER NOT NULL,
31
+ ms INTEGER NOT NULL DEFAULT 0,
32
+ bytes INTEGER NOT NULL DEFAULT 0,
33
+ outcome TEXT NOT NULL,
34
+ bucket TEXT,
35
+ count INTEGER NOT NULL DEFAULT 1
36
+ )`);
37
+ db.exec(
38
+ `CREATE INDEX IF NOT EXISTS idx_peer_audit_host_at ON peer_audit(host_id, at)`
39
+ );
40
+ db.exec(
41
+ `CREATE INDEX IF NOT EXISTS idx_peer_audit_bucket ON peer_audit(host_id, direction, route, bucket)`
42
+ );
43
+ const cols = db.prepare(`PRAGMA table_info(hosts)`).all() as {
44
+ name: string;
45
+ }[];
46
+ if (!cols.some((c) => c.name === "kind"))
47
+ db.exec(`ALTER TABLE hosts ADD COLUMN kind TEXT NOT NULL DEFAULT 'ssh'`);
48
+ },
49
+ } satisfies Migration;
@@ -0,0 +1,18 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261020T1345_peer_nonces",
5
+ up: (db) => {
6
+ // Nonces of linked machines' envelopes (lib/peer/replay.ts), kept until
7
+ // their timestamp would be stale anyway, so a restart can't replay one.
8
+ db.exec(`CREATE TABLE IF NOT EXISTS peer_nonces (
9
+ machine_id TEXT NOT NULL,
10
+ nonce TEXT NOT NULL,
11
+ until INTEGER NOT NULL,
12
+ PRIMARY KEY (machine_id, nonce)
13
+ )`);
14
+ db.exec(
15
+ `CREATE INDEX IF NOT EXISTS idx_peer_nonces_until ON peer_nonces(until)`
16
+ );
17
+ },
18
+ } satisfies Migration;
package/lib/db/types.ts CHANGED
@@ -306,6 +306,8 @@ export interface Host {
306
306
  ssh_target: string;
307
307
  sort_order: number;
308
308
  created_at: string;
309
+ // 'connect': reached only through Connect (lib/peer), ssh_target is ''.
310
+ kind?: "ssh" | "connect";
309
311
  // Paired with that machine's own AgentOS (lib/hosts/link.ts); 0/1 from SQLite.
310
312
  linked?: boolean | number;
311
313
  }
@@ -14,6 +14,7 @@ export const LOCAL_HOST: Host = {
14
14
  id: LOCAL_HOST_ID,
15
15
  name: "This machine",
16
16
  ssh_target: "",
17
+ kind: "ssh",
17
18
  sort_order: -1,
18
19
  created_at: "",
19
20
  };
@@ -25,7 +26,10 @@ export function isRemoteHost(hostId: string | null | undefined): boolean {
25
26
  export function listRemoteHosts(): Host[] {
26
27
  return getDb()
27
28
  .prepare(
28
- `SELECT h.*, EXISTS (SELECT 1 FROM host_links l WHERE l.host_id = h.id) AS linked
29
+ `SELECT h.*, CASE WHEN h.kind = 'connect'
30
+ THEN EXISTS (SELECT 1 FROM peer_links p WHERE p.host_id = h.id)
31
+ ELSE EXISTS (SELECT 1 FROM host_links l WHERE l.host_id = h.id)
32
+ END AS linked
29
33
  FROM hosts h ORDER BY sort_order, created_at`
30
34
  )
31
35
  .all() as Host[];
@@ -54,22 +58,34 @@ export function hostIdNamed(ref: string): string {
54
58
  return host.id;
55
59
  }
56
60
 
57
- export function createHost(name: string, sshTarget: string): Host {
61
+ function insertHost(
62
+ name: string,
63
+ sshTarget: string,
64
+ kind: "ssh" | "connect"
65
+ ): Host {
58
66
  if (!name.trim()) throw new Error("Name is required");
59
- if (!isValidSshTarget(sshTarget)) {
60
- throw new Error("ssh target must look like user@host or an ssh alias");
61
- }
62
67
  const db = getDb();
63
68
  const id = randomUUID();
64
69
  const { max } = db
65
70
  .prepare(`SELECT COALESCE(MAX(sort_order), 0) AS max FROM hosts`)
66
71
  .get() as { max: number };
67
72
  db.prepare(
68
- `INSERT INTO hosts (id, name, ssh_target, sort_order) VALUES (?, ?, ?, ?)`
69
- ).run(id, name.trim(), sshTarget, max + 1);
73
+ `INSERT INTO hosts (id, name, ssh_target, sort_order, kind) VALUES (?, ?, ?, ?, ?)`
74
+ ).run(id, name.trim(), sshTarget, max + 1, kind);
70
75
  return getHost(id)!;
71
76
  }
72
77
 
78
+ export function createHost(name: string, sshTarget: string): Host {
79
+ if (!isValidSshTarget(sshTarget)) {
80
+ throw new Error("ssh target must look like user@host or an ssh alias");
81
+ }
82
+ return insertHost(name, sshTarget, "ssh");
83
+ }
84
+
85
+ /** A machine reached only through Connect; its keys are pinned separately. */
86
+ export const createConnectHost = (name: string): Host =>
87
+ insertHost(name, "", "connect");
88
+
73
89
  export function deleteHost(hostId: string): void {
74
90
  if (!isRemoteHost(hostId)) throw new Error("Cannot remove this machine");
75
91
  const db = getDb();
@@ -83,6 +99,7 @@ export function deleteHost(hostId: string): void {
83
99
  throw new Error("Move or delete its projects and sessions first");
84
100
  }
85
101
  db.prepare(`DELETE FROM host_links WHERE host_id = ?`).run(hostId);
102
+ db.prepare(`DELETE FROM peer_links WHERE host_id = ?`).run(hostId);
86
103
  db.prepare(`DELETE FROM hosts WHERE id = ?`).run(hostId);
87
104
  }
88
105
 
@@ -90,6 +107,10 @@ export function sshTargetFor(hostId: string | null | undefined): string | null {
90
107
  if (!isRemoteHost(hostId)) return null;
91
108
  const host = getHost(hostId);
92
109
  if (!host) throw new Error(`Unknown host: ${hostId}`);
110
+ if (host.kind === "connect")
111
+ throw new Error(
112
+ `${host.name} is linked through Connect: this needs a Tailscale/ssh link`
113
+ );
93
114
  return host.ssh_target;
94
115
  }
95
116
 
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { WebSocket } from "ws";
10
- import type { HostLink } from "./remote-api";
10
+ import { requireTokenLink, type HostLink } from "./remote-api";
11
11
 
12
12
  export const PEER_HANDSHAKE_MS = 10_000;
13
13
  // A long chat's (paged) snapshot is one message.
@@ -30,6 +30,8 @@ export function openPeerSocket(
30
30
  query: Record<string, string> = {},
31
31
  maxPayload = PEER_MAX_PAYLOAD
32
32
  ): WebSocket {
33
+ // A Connect link has no token, and sockets don't ride the envelope yet.
34
+ requireTokenLink(link);
33
35
  return new WebSocket(peerSocketUrl(link, path, query), {
34
36
  headers: { Authorization: `Bearer ${link.token}` },
35
37
  handshakeTimeout: PEER_HANDSHAKE_MS,
@@ -0,0 +1,23 @@
1
+ /** A response body, read no further than max bytes whatever it claims. */
2
+ export async function readCappedBytes(
3
+ res: Response,
4
+ max: number
5
+ ): Promise<Buffer> {
6
+ if (Number(res.headers.get("content-length")) > max)
7
+ throw new Error("its answer is too large");
8
+ const reader = res.body?.getReader();
9
+ if (!reader) return Buffer.alloc(0);
10
+ const chunks: Uint8Array[] = [];
11
+ let total = 0;
12
+ for (;;) {
13
+ const { done, value } = await reader.read();
14
+ if (done) break;
15
+ total += value.byteLength;
16
+ if (total > max) {
17
+ await reader.cancel().catch(() => undefined);
18
+ throw new Error("its answer is too large");
19
+ }
20
+ chunks.push(value);
21
+ }
22
+ return Buffer.concat(chunks);
23
+ }