@saadnvd1/agent-os 0.3.697 → 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.
- package/app/api/peer/ping/route.ts +10 -0
- package/lib/__fixtures__/connect-transport.ts +93 -0
- package/lib/__fixtures__/peer-machine.ts +45 -0
- package/lib/__fixtures__/peer-rpc.ts +224 -0
- package/lib/db/migrations/20261020T1258_peer_links.ts +49 -0
- package/lib/db/migrations/20261020T1345_peer_nonces.ts +18 -0
- package/lib/db/types.ts +2 -0
- package/lib/hosts/index.ts +28 -7
- package/lib/hosts/peer-socket.ts +3 -1
- package/lib/hosts/read-capped-bytes.ts +23 -0
- package/lib/hosts/remote-api.ts +112 -41
- package/lib/peer/allowlist.ts +136 -0
- package/lib/peer/audit.ts +144 -0
- package/lib/peer/client.ts +188 -0
- package/lib/peer/envelope.ts +424 -0
- package/lib/peer/internal.ts +22 -0
- package/lib/peer/keys.ts +122 -0
- package/lib/peer/links.ts +96 -0
- package/lib/peer/nonce-store.ts +23 -0
- package/lib/peer/replay.ts +81 -0
- package/lib/peer/rpc-intake.ts +229 -0
- package/lib/peer/rpc-server.ts +351 -0
- package/lib/security/auth.ts +42 -5
- package/lib/security/gate.ts +35 -12
- package/lib/security/route-guard.ts +3 -1
- package/lib/session-artifacts/peer.ts +12 -1
- package/lib/tasks/contract/authorize.ts +4 -0
- package/lib/worktree-sweep/testTemp.ts +2 -0
- package/package.json +1 -1
- 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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|
package/lib/hosts/index.ts
CHANGED
|
@@ -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.*,
|
|
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
|
-
|
|
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
|
|
package/lib/hosts/peer-socket.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { WebSocket } from "ws";
|
|
10
|
-
import type
|
|
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
|
+
}
|