@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
package/lib/hosts/remote-api.ts
CHANGED
|
@@ -4,38 +4,112 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { getDb } from "../db";
|
|
7
|
+
import { PeerCallError, peerCall } from "../peer/client";
|
|
8
|
+
import type { PinState } from "../peer/links";
|
|
7
9
|
import { getHost, isRemoteHost } from "./index";
|
|
10
|
+
import { readCappedBytes } from "./read-capped-bytes";
|
|
8
11
|
|
|
12
|
+
/** A machine linked through Connect: its pinned keys (lib/peer). */
|
|
13
|
+
export interface ConnectPeer {
|
|
14
|
+
machineId: string;
|
|
15
|
+
hostname: string;
|
|
16
|
+
signPub: string;
|
|
17
|
+
boxPub: string;
|
|
18
|
+
state: PinState;
|
|
19
|
+
suspendedReason: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `token` (the default): a device token over Tailscale or ssh-reached URLs.
|
|
24
|
+
* `connect`: signed, encrypted envelopes to the pinned keys; url and token
|
|
25
|
+
* are unused (token is always empty).
|
|
26
|
+
*/
|
|
9
27
|
export interface HostLink {
|
|
10
28
|
hostId: string;
|
|
11
29
|
hostName: string;
|
|
12
30
|
url: string;
|
|
13
31
|
token: string;
|
|
32
|
+
transport?: "token" | "connect";
|
|
33
|
+
connect?: ConnectPeer;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface LinkRow {
|
|
37
|
+
host_id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
kind: string;
|
|
40
|
+
url: string | null;
|
|
41
|
+
token: string | null;
|
|
42
|
+
machine_id: string | null;
|
|
43
|
+
hostname: string | null;
|
|
44
|
+
sign_pub: string | null;
|
|
45
|
+
box_pub: string | null;
|
|
46
|
+
state: string | null;
|
|
47
|
+
suspended_reason: string | null;
|
|
14
48
|
}
|
|
15
49
|
|
|
16
50
|
export function hostLink(hostId: string | null | undefined): HostLink | null {
|
|
17
51
|
if (!isRemoteHost(hostId)) return null;
|
|
18
52
|
const row = getDb()
|
|
19
53
|
.prepare(
|
|
20
|
-
`SELECT
|
|
21
|
-
|
|
54
|
+
`SELECT h.id AS host_id, h.name, h.kind, l.url, l.token, p.machine_id,
|
|
55
|
+
p.hostname, p.sign_pub, p.box_pub, p.state, p.suspended_reason
|
|
56
|
+
FROM hosts h
|
|
57
|
+
LEFT JOIN host_links l ON l.host_id = h.id
|
|
58
|
+
LEFT JOIN peer_links p ON p.host_id = h.id
|
|
59
|
+
WHERE h.id = ?`
|
|
22
60
|
)
|
|
23
|
-
.get(hostId) as
|
|
24
|
-
|
|
25
|
-
|
|
61
|
+
.get(hostId) as LinkRow | undefined;
|
|
62
|
+
if (!row) return null;
|
|
63
|
+
if (row.kind === "connect") {
|
|
64
|
+
if (!row.machine_id || !row.hostname || !row.sign_pub || !row.box_pub)
|
|
65
|
+
return null;
|
|
66
|
+
return {
|
|
67
|
+
hostId: row.host_id,
|
|
68
|
+
hostName: row.name,
|
|
69
|
+
url: `https://${row.hostname}`,
|
|
70
|
+
token: "",
|
|
71
|
+
transport: "connect",
|
|
72
|
+
connect: {
|
|
73
|
+
machineId: row.machine_id,
|
|
74
|
+
hostname: row.hostname,
|
|
75
|
+
signPub: row.sign_pub,
|
|
76
|
+
boxPub: row.box_pub,
|
|
77
|
+
state: row.state === "active" ? "active" : "suspended",
|
|
78
|
+
suspendedReason: row.suspended_reason,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return row.url && row.token
|
|
26
83
|
? {
|
|
27
84
|
hostId: row.host_id,
|
|
28
85
|
hostName: row.name,
|
|
29
86
|
url: row.url,
|
|
30
87
|
token: row.token,
|
|
88
|
+
transport: "token",
|
|
31
89
|
}
|
|
32
90
|
: null;
|
|
33
91
|
}
|
|
34
92
|
|
|
93
|
+
export const isConnectLink = (link: HostLink) => link.transport === "connect";
|
|
94
|
+
|
|
95
|
+
/** For the calls that only work with a token link (sockets, raw fetches). */
|
|
96
|
+
export function requireTokenLink(link: HostLink): HostLink {
|
|
97
|
+
if (isConnectLink(link))
|
|
98
|
+
throw new Error(
|
|
99
|
+
`${link.hostName} is linked through Connect: this needs a Tailscale/ssh link for now`
|
|
100
|
+
);
|
|
101
|
+
return link;
|
|
102
|
+
}
|
|
103
|
+
|
|
35
104
|
export function linkedHostIds(): Set<string> {
|
|
36
|
-
const rows = getDb()
|
|
37
|
-
|
|
38
|
-
|
|
105
|
+
const rows = getDb()
|
|
106
|
+
.prepare(
|
|
107
|
+
`SELECT host_id FROM host_links
|
|
108
|
+
WHERE host_id NOT IN (SELECT id FROM hosts WHERE kind = 'connect')
|
|
109
|
+
UNION SELECT p.host_id FROM peer_links p JOIN hosts h ON h.id = p.host_id
|
|
110
|
+
WHERE h.kind = 'connect'`
|
|
111
|
+
)
|
|
112
|
+
.all() as { host_id: string }[];
|
|
39
113
|
return new Set(rows.map((r) => r.host_id));
|
|
40
114
|
}
|
|
41
115
|
|
|
@@ -103,23 +177,7 @@ export async function readCapped(
|
|
|
103
177
|
res: Response,
|
|
104
178
|
max = MAX_RESPONSE_BYTES
|
|
105
179
|
): Promise<string> {
|
|
106
|
-
|
|
107
|
-
throw new Error("its answer is too large");
|
|
108
|
-
const reader = res.body?.getReader();
|
|
109
|
-
if (!reader) return "";
|
|
110
|
-
const chunks: Uint8Array[] = [];
|
|
111
|
-
let total = 0;
|
|
112
|
-
for (;;) {
|
|
113
|
-
const { done, value } = await reader.read();
|
|
114
|
-
if (done) break;
|
|
115
|
-
total += value.byteLength;
|
|
116
|
-
if (total > max) {
|
|
117
|
-
await reader.cancel().catch(() => undefined);
|
|
118
|
-
throw new Error("its answer is too large");
|
|
119
|
-
}
|
|
120
|
-
chunks.push(value);
|
|
121
|
-
}
|
|
122
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
180
|
+
return (await readCappedBytes(res, max)).toString("utf8");
|
|
123
181
|
}
|
|
124
182
|
|
|
125
183
|
/** A JSON call to the other machine's AgentOS; its error text on failure. */
|
|
@@ -133,21 +191,34 @@ export async function hostApi<T>(
|
|
|
133
191
|
`Can't reach AgentOS on ${link.hostName}: ${cleanRemoteText(err instanceof Error ? err.message : err)}`,
|
|
134
192
|
false
|
|
135
193
|
);
|
|
136
|
-
let
|
|
194
|
+
let status: number;
|
|
137
195
|
let text: string;
|
|
138
196
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
197
|
+
if (isConnectLink(link)) {
|
|
198
|
+
({ status, text } = await peerCall(link, path, opts));
|
|
199
|
+
} else {
|
|
200
|
+
const res = await fetch(`${link.url}${path}`, {
|
|
201
|
+
method: opts.method ?? (opts.body === undefined ? "GET" : "POST"),
|
|
202
|
+
headers: {
|
|
203
|
+
Authorization: `Bearer ${link.token}`,
|
|
204
|
+
...(opts.body !== undefined && {
|
|
205
|
+
"Content-Type": "application/json",
|
|
206
|
+
}),
|
|
207
|
+
},
|
|
208
|
+
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
|
209
|
+
signal: AbortSignal.timeout(opts.timeout ?? 15000),
|
|
210
|
+
redirect: "error",
|
|
211
|
+
});
|
|
212
|
+
status = res.status;
|
|
213
|
+
text = await readCapped(res);
|
|
214
|
+
}
|
|
150
215
|
} catch (err) {
|
|
216
|
+
if (err instanceof PeerCallError && err.refused)
|
|
217
|
+
throw new HostApiError(
|
|
218
|
+
`${link.hostName}: ${cleanRemoteText(err.message)}`,
|
|
219
|
+
true,
|
|
220
|
+
err.status
|
|
221
|
+
);
|
|
151
222
|
throw unknown(err);
|
|
152
223
|
}
|
|
153
224
|
let data: { error?: unknown } | null = null;
|
|
@@ -156,13 +227,13 @@ export async function hostApi<T>(
|
|
|
156
227
|
} catch {
|
|
157
228
|
data = null;
|
|
158
229
|
}
|
|
159
|
-
if (
|
|
230
|
+
if (status < 200 || status > 299) {
|
|
160
231
|
// Only AgentOS's own refusal (a 4xx with its error) means nothing happened.
|
|
161
|
-
const refused =
|
|
232
|
+
const refused = status >= 400 && status < 500 && !!data?.error;
|
|
162
233
|
throw new HostApiError(
|
|
163
|
-
`${link.hostName}: ${cleanRemoteText(data?.error) || `HTTP ${
|
|
234
|
+
`${link.hostName}: ${cleanRemoteText(data?.error) || `HTTP ${status}`}`,
|
|
164
235
|
refused,
|
|
165
|
-
|
|
236
|
+
status
|
|
166
237
|
);
|
|
167
238
|
}
|
|
168
239
|
if (!data) throw unknown(new Error("its answer wasn't JSON"));
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only routes a linked machine may call here, by method and pattern.
|
|
3
|
+
* These are the calls the peer code makes today and nothing else. A path is
|
|
4
|
+
* taken only in its plain form: no encoding, no dot segments, no doubled or
|
|
5
|
+
* back slashes, so what is matched is exactly what the route sees. An id
|
|
6
|
+
* segment is [A-Za-z0-9_.-]{1,128}, and each route names its query keys.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
type Method = "GET" | "POST" | "DELETE";
|
|
10
|
+
|
|
11
|
+
interface PeerRoute {
|
|
12
|
+
method: Method;
|
|
13
|
+
pattern: string;
|
|
14
|
+
query?: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const each = (
|
|
18
|
+
method: Method,
|
|
19
|
+
base: string,
|
|
20
|
+
tails: string[],
|
|
21
|
+
query?: string[]
|
|
22
|
+
): PeerRoute[] =>
|
|
23
|
+
tails.map((t) => ({
|
|
24
|
+
method,
|
|
25
|
+
pattern: `${base}/${t}`,
|
|
26
|
+
...(query && { query }),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
export const PEER_ROUTES: readonly PeerRoute[] = [
|
|
30
|
+
{ method: "GET", pattern: "/api/tasks" },
|
|
31
|
+
{ method: "POST", pattern: "/api/tasks" },
|
|
32
|
+
{ method: "POST", pattern: "/api/tasks/import" },
|
|
33
|
+
{ method: "GET", pattern: "/api/tasks/arrived", query: ["from"] },
|
|
34
|
+
{ method: "GET", pattern: "/api/tasks/held-replies" },
|
|
35
|
+
{ method: "POST", pattern: "/api/tasks/held-replies" },
|
|
36
|
+
...each("POST", "/api/tasks/:id", [
|
|
37
|
+
"export",
|
|
38
|
+
"approve-plan",
|
|
39
|
+
"hold",
|
|
40
|
+
"message",
|
|
41
|
+
"stop",
|
|
42
|
+
"done",
|
|
43
|
+
"drop",
|
|
44
|
+
"resume",
|
|
45
|
+
"moved",
|
|
46
|
+
"merge",
|
|
47
|
+
]),
|
|
48
|
+
...each("GET", "/api/tasks/:id", ["plan", "chat-facts"]),
|
|
49
|
+
{ method: "GET", pattern: "/api/tasks/:id/chat-tail", query: ["lines"] },
|
|
50
|
+
|
|
51
|
+
{ method: "GET", pattern: "/api/sessions" },
|
|
52
|
+
{ method: "GET", pattern: "/api/sessions/status" },
|
|
53
|
+
{ method: "POST", pattern: "/api/sessions" },
|
|
54
|
+
{ method: "DELETE", pattern: "/api/sessions/:id" },
|
|
55
|
+
...each("POST", "/api/sessions/:id", ["done", "unarchive", "resurrect"]),
|
|
56
|
+
...each("GET", "/api/sessions/:id", ["preview", "artifacts", "setup"]),
|
|
57
|
+
|
|
58
|
+
{ method: "GET", pattern: "/api/projects" },
|
|
59
|
+
{ method: "POST", pattern: "/api/projects/availability" },
|
|
60
|
+
{ method: "GET", pattern: "/api/tmux/discover" },
|
|
61
|
+
{ method: "GET", pattern: "/api/load" },
|
|
62
|
+
|
|
63
|
+
{ method: "GET", pattern: "/api/worktree-sweep" },
|
|
64
|
+
{ method: "POST", pattern: "/api/worktree-sweep" },
|
|
65
|
+
{ method: "POST", pattern: "/api/worktree-sweep/run" },
|
|
66
|
+
|
|
67
|
+
{ method: "POST", pattern: "/api/peer/unlink" },
|
|
68
|
+
{ method: "GET", pattern: "/api/peer/ping" },
|
|
69
|
+
|
|
70
|
+
// The work registry's exchange (not built yet: 404 until it is).
|
|
71
|
+
{ method: "POST", pattern: "/api/claims/exchange" },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const PLAIN_PATH = /^\/api(\/[A-Za-z0-9_.-]{1,128}){1,6}$/;
|
|
75
|
+
const ID = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
76
|
+
const MAX_QUERY = 4096;
|
|
77
|
+
|
|
78
|
+
export type RouteMatch =
|
|
79
|
+
| { ok: true; route: string; path: string; query: string }
|
|
80
|
+
| { ok: false; reason: "bad_path" | "bad_query" | "not_allowed" };
|
|
81
|
+
|
|
82
|
+
function segmentsMatch(pattern: string[], path: string[]): boolean {
|
|
83
|
+
return (
|
|
84
|
+
pattern.length === path.length &&
|
|
85
|
+
pattern.every((p, i) => (p === ":id" ? ID.test(path[i]) : p === path[i]))
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function cleanQuery(
|
|
90
|
+
raw: string,
|
|
91
|
+
allowed: readonly string[] = []
|
|
92
|
+
): string | null {
|
|
93
|
+
if (!raw) return "";
|
|
94
|
+
if (raw.length > MAX_QUERY || /[#\u0000-\u001f\u007f]/.test(raw)) return null;
|
|
95
|
+
const params = new URLSearchParams(raw);
|
|
96
|
+
const keys = [...params.keys()];
|
|
97
|
+
if (new Set(keys).size !== keys.length) return null;
|
|
98
|
+
for (const [k, v] of params) {
|
|
99
|
+
if (!allowed.includes(k) || v.length > 1024) return null;
|
|
100
|
+
if (/[\u0000-\u001f\u007f]/.test(v)) return null;
|
|
101
|
+
}
|
|
102
|
+
return params.toString();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The route a peer's call matches, with its path and query as dispatched. */
|
|
106
|
+
export function matchPeerRoute(
|
|
107
|
+
method: string,
|
|
108
|
+
path: string,
|
|
109
|
+
query = ""
|
|
110
|
+
): RouteMatch {
|
|
111
|
+
if (typeof path !== "string" || !PLAIN_PATH.test(path))
|
|
112
|
+
return { ok: false, reason: "bad_path" };
|
|
113
|
+
const parts = path.split("/").slice(1);
|
|
114
|
+
if (parts.some((p) => p === "." || p === ".."))
|
|
115
|
+
return { ok: false, reason: "bad_path" };
|
|
116
|
+
const route = PEER_ROUTES.find(
|
|
117
|
+
(r) =>
|
|
118
|
+
r.method === method && segmentsMatch(r.pattern.split("/").slice(1), parts)
|
|
119
|
+
);
|
|
120
|
+
if (!route) return { ok: false, reason: "not_allowed" };
|
|
121
|
+
const clean = cleanQuery(query, route.query);
|
|
122
|
+
if (clean === null) return { ok: false, reason: "bad_query" };
|
|
123
|
+
return { ok: true, route: route.pattern, path, query: clean };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** For the gate: a request target (path and query) a peer may reach. */
|
|
127
|
+
export function peerTargetAllowed(
|
|
128
|
+
method: string | undefined,
|
|
129
|
+
url: string | undefined
|
|
130
|
+
) {
|
|
131
|
+
const target = url ?? "";
|
|
132
|
+
const q = target.indexOf("?");
|
|
133
|
+
const path = q < 0 ? target : target.slice(0, q);
|
|
134
|
+
const query = q < 0 ? "" : target.slice(q + 1);
|
|
135
|
+
return matchPeerRoute(method ?? "", path, query).ok;
|
|
136
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peer calls, in and out. Successful reads (the mirror polls every few
|
|
3
|
+
* seconds) fold into one row per route per hour. Failures are capped per
|
|
4
|
+
* minute so a flood can't fill the disk, in two budgets: envelopes refused
|
|
5
|
+
* here before their sender was proven (anyone can send those), and
|
|
6
|
+
* everything else (this machine's own failed calls, a linked machine's
|
|
7
|
+
* denied routes), so a forger's flood never hides the second kind. Rows older than 30 days go, checked at most hourly.
|
|
8
|
+
* Bookkeeping: a failure here never fails the call.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getDb } from "../db";
|
|
12
|
+
|
|
13
|
+
export type Outcome =
|
|
14
|
+
| "ok"
|
|
15
|
+
| "denied_route"
|
|
16
|
+
| "bad_envelope"
|
|
17
|
+
| "too_large"
|
|
18
|
+
| "too_slow"
|
|
19
|
+
| "busy"
|
|
20
|
+
| "bad_sig"
|
|
21
|
+
| "stale"
|
|
22
|
+
| "replay"
|
|
23
|
+
| "unknown_peer"
|
|
24
|
+
| "suspended";
|
|
25
|
+
|
|
26
|
+
export interface AuditEntry {
|
|
27
|
+
hostId: string | null;
|
|
28
|
+
machineId?: string | null;
|
|
29
|
+
direction: "in" | "out";
|
|
30
|
+
method: string;
|
|
31
|
+
/** The route's template (/api/tasks/:id/merge), never the raw path. */
|
|
32
|
+
route: string;
|
|
33
|
+
status: number;
|
|
34
|
+
ms: number;
|
|
35
|
+
bytes: number;
|
|
36
|
+
outcome: Outcome;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const HOUR = 3_600_000;
|
|
40
|
+
const RETAIN_MS = 30 * 24 * HOUR;
|
|
41
|
+
const FAILURES_PER_MINUTE = 30;
|
|
42
|
+
|
|
43
|
+
const state = { prunedAt: 0, minute: 0, forged: 0, other: 0 };
|
|
44
|
+
|
|
45
|
+
/** For tests: forget the prune time and the failure budget. */
|
|
46
|
+
export const resetAuditLimits = () => {
|
|
47
|
+
state.prunedAt = 0;
|
|
48
|
+
state.minute = 0;
|
|
49
|
+
state.forged = 0;
|
|
50
|
+
state.other = 0;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Refusals of an inbound envelope that anyone could have sent: a stranger's,
|
|
54
|
+
// a forgery, a malformed or oversized one, or a captured one sent again.
|
|
55
|
+
const UNPROVEN: ReadonlySet<Outcome> = new Set([
|
|
56
|
+
"bad_envelope",
|
|
57
|
+
"too_large",
|
|
58
|
+
"too_slow",
|
|
59
|
+
"busy",
|
|
60
|
+
"bad_sig",
|
|
61
|
+
"unknown_peer",
|
|
62
|
+
"suspended",
|
|
63
|
+
"stale",
|
|
64
|
+
"replay",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
function underFailureCap(e: AuditEntry, now: number): boolean {
|
|
68
|
+
const minute = Math.floor(now / 60_000);
|
|
69
|
+
if (minute !== state.minute) {
|
|
70
|
+
state.minute = minute;
|
|
71
|
+
state.forged = 0;
|
|
72
|
+
state.other = 0;
|
|
73
|
+
}
|
|
74
|
+
const key =
|
|
75
|
+
e.direction === "in" && UNPROVEN.has(e.outcome) ? "forged" : "other";
|
|
76
|
+
return ++state[key] <= FAILURES_PER_MINUTE;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function recordPeerCall(e: AuditEntry, now = Date.now()): void {
|
|
80
|
+
try {
|
|
81
|
+
if (e.outcome !== "ok" && !underFailureCap(e, now)) return;
|
|
82
|
+
const db = getDb();
|
|
83
|
+
if (now - state.prunedAt > HOUR) {
|
|
84
|
+
state.prunedAt = now;
|
|
85
|
+
db.prepare(`DELETE FROM peer_audit WHERE at < ?`).run(now - RETAIN_MS);
|
|
86
|
+
}
|
|
87
|
+
const machineId = e.machineId?.slice(0, 64) ?? null;
|
|
88
|
+
const fold = e.method === "GET" && e.outcome === "ok" && e.status < 400;
|
|
89
|
+
const bucket = fold ? String(Math.floor(now / HOUR)) : null;
|
|
90
|
+
if (bucket) {
|
|
91
|
+
const changed = db
|
|
92
|
+
.prepare(
|
|
93
|
+
`UPDATE peer_audit SET count = count + 1, at = ?, status = ?, ms = ?,
|
|
94
|
+
bytes = bytes + ?
|
|
95
|
+
WHERE host_id IS ? AND direction = ? AND route = ? AND bucket = ?`
|
|
96
|
+
)
|
|
97
|
+
.run(
|
|
98
|
+
now,
|
|
99
|
+
e.status,
|
|
100
|
+
e.ms,
|
|
101
|
+
e.bytes,
|
|
102
|
+
e.hostId,
|
|
103
|
+
e.direction,
|
|
104
|
+
e.route,
|
|
105
|
+
bucket
|
|
106
|
+
);
|
|
107
|
+
if (changed.changes > 0) return;
|
|
108
|
+
}
|
|
109
|
+
db.prepare(
|
|
110
|
+
`INSERT INTO peer_audit (at, host_id, machine_id, direction, method, route,
|
|
111
|
+
status, ms, bytes, outcome, bucket)
|
|
112
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
113
|
+
).run(
|
|
114
|
+
now,
|
|
115
|
+
e.hostId,
|
|
116
|
+
machineId,
|
|
117
|
+
e.direction,
|
|
118
|
+
e.method.slice(0, 10),
|
|
119
|
+
e.route.slice(0, 200),
|
|
120
|
+
e.status,
|
|
121
|
+
Math.round(e.ms),
|
|
122
|
+
e.bytes,
|
|
123
|
+
e.outcome,
|
|
124
|
+
bucket
|
|
125
|
+
);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.error("peer audit:", err);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface AuditRow extends AuditEntry {
|
|
132
|
+
at: number;
|
|
133
|
+
count: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function peerAudit(hostId: string, limit = 200): AuditRow[] {
|
|
137
|
+
return getDb()
|
|
138
|
+
.prepare(
|
|
139
|
+
`SELECT at, host_id AS hostId, machine_id AS machineId, direction, method,
|
|
140
|
+
route, status, ms, bytes, outcome, count
|
|
141
|
+
FROM peer_audit WHERE host_id = ? ORDER BY at DESC, id DESC LIMIT ?`
|
|
142
|
+
)
|
|
143
|
+
.all(hostId, limit) as AuditRow[];
|
|
144
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hostApi's transport for a machine linked through Connect: each call is a
|
|
3
|
+
* fresh envelope (envelope.ts) to POST https://<its m- name>/api/peer/rpc,
|
|
4
|
+
* and the answer only counts if it is signed by the pinned key and bound to
|
|
5
|
+
* this request's nonce. No token is ever sent or stored.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { matchPeerRoute } from "./allowlist";
|
|
9
|
+
import { recordPeerCall, type AuditEntry, type Outcome } from "./audit";
|
|
10
|
+
import {
|
|
11
|
+
MAX_REQUEST_BODY,
|
|
12
|
+
MAX_RESPONSE_ENVELOPE,
|
|
13
|
+
RPC_PATH,
|
|
14
|
+
openResponse,
|
|
15
|
+
sealRequest,
|
|
16
|
+
} from "./envelope";
|
|
17
|
+
import { boxPublic, currentIdentity, signPublic, type Identity } from "./keys";
|
|
18
|
+
import { notePinResult } from "./links";
|
|
19
|
+
import type { HostLink } from "../hosts/remote-api";
|
|
20
|
+
import { readCappedBytes } from "../hosts/read-capped-bytes";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A failed peer call. `refused`: the other machine (or this one, before
|
|
24
|
+
* sending) said no, so nothing happened there.
|
|
25
|
+
*/
|
|
26
|
+
export class PeerCallError extends Error {
|
|
27
|
+
constructor(
|
|
28
|
+
message: string,
|
|
29
|
+
readonly refused: boolean,
|
|
30
|
+
readonly status?: number
|
|
31
|
+
) {
|
|
32
|
+
super(message);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DOMAIN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Only a Connect machine's own name, so a lying account list can't aim this
|
|
40
|
+
* machine at an internal address. AGENTOS_CONNECT_PEER_DOMAIN is for a
|
|
41
|
+
* staging service.
|
|
42
|
+
*/
|
|
43
|
+
export function peerHostAllowed(hostname: string, env = process.env): boolean {
|
|
44
|
+
const configured = env.AGENTOS_CONNECT_PEER_DOMAIN ?? "";
|
|
45
|
+
const domain = DOMAIN.test(configured) ? configured : "runagentos.com";
|
|
46
|
+
const escaped = domain.replace(/\./g, "\\.");
|
|
47
|
+
return new RegExp(`^m-[0-9a-f]{16}\\.${escaped}$`).test(hostname);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The fixed refusals rpc-server.ts sends before anything is decrypted.
|
|
51
|
+
const REFUSALS: Record<string, Outcome> = {
|
|
52
|
+
"bad signature": "bad_sig",
|
|
53
|
+
"bad envelope": "bad_sig",
|
|
54
|
+
stale: "stale",
|
|
55
|
+
replay: "replay",
|
|
56
|
+
busy: "replay",
|
|
57
|
+
"too large": "bad_sig",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const HINTS: Record<string, string> = {
|
|
61
|
+
"bad signature":
|
|
62
|
+
"it doesn't know this machine, the link is suspended there, or the keys don't match",
|
|
63
|
+
stale: "the clocks on the two machines differ by more than 2 minutes",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export interface ClientDeps {
|
|
67
|
+
identity: () => Identity | null;
|
|
68
|
+
audit: (e: AuditEntry) => void;
|
|
69
|
+
noteResult: (hostId: string, error: string | null) => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const realDeps: ClientDeps = {
|
|
73
|
+
identity: () => currentIdentity(),
|
|
74
|
+
audit: recordPeerCall,
|
|
75
|
+
noteResult: notePinResult,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export async function peerCall(
|
|
79
|
+
link: HostLink,
|
|
80
|
+
target: string,
|
|
81
|
+
opts: { method?: string; body?: unknown; timeout?: number },
|
|
82
|
+
deps: ClientDeps = realDeps
|
|
83
|
+
): Promise<{ status: number; text: string }> {
|
|
84
|
+
const peer = link.connect;
|
|
85
|
+
const refuse = (msg: string): never => {
|
|
86
|
+
throw new PeerCallError(msg, true);
|
|
87
|
+
};
|
|
88
|
+
if (!peer) return refuse("isn't linked through Connect");
|
|
89
|
+
if (peer.state !== "active")
|
|
90
|
+
return refuse(
|
|
91
|
+
`the link is suspended (${peer.suspendedReason ?? "no reason given"})`
|
|
92
|
+
);
|
|
93
|
+
if (!peerHostAllowed(peer.hostname))
|
|
94
|
+
return refuse("its address isn't a Connect machine's");
|
|
95
|
+
const method = (
|
|
96
|
+
opts.method ?? (opts.body === undefined ? "GET" : "POST")
|
|
97
|
+
).toUpperCase();
|
|
98
|
+
const q = target.indexOf("?");
|
|
99
|
+
const match = matchPeerRoute(
|
|
100
|
+
method,
|
|
101
|
+
q < 0 ? target : target.slice(0, q),
|
|
102
|
+
q < 0 ? "" : target.slice(q + 1)
|
|
103
|
+
);
|
|
104
|
+
if (!match.ok) return refuse("that call isn't open to linked machines");
|
|
105
|
+
const identity = deps.identity();
|
|
106
|
+
if (!identity)
|
|
107
|
+
return refuse("this machine isn't connected to AgentOS Connect");
|
|
108
|
+
const body =
|
|
109
|
+
opts.body === undefined
|
|
110
|
+
? Buffer.alloc(0)
|
|
111
|
+
: Buffer.from(JSON.stringify(opts.body));
|
|
112
|
+
if (body.length > MAX_REQUEST_BODY) return refuse("the request is too large");
|
|
113
|
+
|
|
114
|
+
const started = Date.now();
|
|
115
|
+
const audit = (outcome: Outcome, status: number, bytes: number) =>
|
|
116
|
+
deps.audit({
|
|
117
|
+
hostId: link.hostId,
|
|
118
|
+
machineId: peer.machineId,
|
|
119
|
+
direction: "out",
|
|
120
|
+
method,
|
|
121
|
+
route: match.route,
|
|
122
|
+
status,
|
|
123
|
+
ms: Date.now() - started,
|
|
124
|
+
bytes,
|
|
125
|
+
outcome,
|
|
126
|
+
});
|
|
127
|
+
const sealed = sealRequest({
|
|
128
|
+
from: identity.machineId,
|
|
129
|
+
to: peer.machineId,
|
|
130
|
+
signKey: identity.signKey,
|
|
131
|
+
peerBox: boxPublic(peer.boxPub),
|
|
132
|
+
request: {
|
|
133
|
+
method,
|
|
134
|
+
path: match.path,
|
|
135
|
+
query: match.query,
|
|
136
|
+
contentType: body.length ? "application/json" : "",
|
|
137
|
+
body,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
let res: Response;
|
|
141
|
+
let raw: Buffer;
|
|
142
|
+
try {
|
|
143
|
+
res = await fetch(`https://${peer.hostname}${RPC_PATH}`, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
146
|
+
body: new Uint8Array(sealed.envelope),
|
|
147
|
+
signal: AbortSignal.timeout(opts.timeout ?? 15000),
|
|
148
|
+
redirect: "error",
|
|
149
|
+
});
|
|
150
|
+
raw = await readCappedBytes(res, MAX_RESPONSE_ENVELOPE);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
153
|
+
deps.noteResult(link.hostId, message);
|
|
154
|
+
throw new PeerCallError(message, false);
|
|
155
|
+
}
|
|
156
|
+
if (res.status !== 200) {
|
|
157
|
+
const text = raw.subarray(0, 64).toString("utf8").trim();
|
|
158
|
+
const outcome = REFUSALS[text];
|
|
159
|
+
if (outcome && res.status >= 400 && res.status < 500) {
|
|
160
|
+
audit(outcome, res.status, sealed.envelope.length);
|
|
161
|
+
const message = HINTS[text] ? `${text}: ${HINTS[text]}` : text;
|
|
162
|
+
deps.noteResult(link.hostId, message);
|
|
163
|
+
// A refusal is unsigned: anyone on the path could forge one. A read
|
|
164
|
+
// changes nothing either way, but a write may have happened.
|
|
165
|
+
throw new PeerCallError(message, method === "GET", res.status);
|
|
166
|
+
}
|
|
167
|
+
deps.noteResult(link.hostId, `HTTP ${res.status}`);
|
|
168
|
+
throw new PeerCallError(`HTTP ${res.status}`, false, res.status);
|
|
169
|
+
}
|
|
170
|
+
let answer;
|
|
171
|
+
try {
|
|
172
|
+
answer = openResponse({
|
|
173
|
+
envelope: raw,
|
|
174
|
+
nonce: sealed.nonce,
|
|
175
|
+
resKey: sealed.resKey,
|
|
176
|
+
peerSign: signPublic(peer.signPub),
|
|
177
|
+
});
|
|
178
|
+
} catch (err) {
|
|
179
|
+
// It may have run there; the answer just can't be trusted.
|
|
180
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
181
|
+
audit("bad_sig", 200, sealed.envelope.length + raw.length);
|
|
182
|
+
deps.noteResult(link.hostId, message);
|
|
183
|
+
throw new PeerCallError(message, false);
|
|
184
|
+
}
|
|
185
|
+
audit("ok", answer.status, body.length + answer.body.length);
|
|
186
|
+
deps.noteResult(link.hostId, null);
|
|
187
|
+
return { status: answer.status, text: answer.body.toString("utf8") };
|
|
188
|
+
}
|