@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.
- package/app/api/peer/ping/route.ts +10 -0
- package/components/Settings/NoApprovalsSection.tsx +1 -1
- 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/approvals/enable.ts +1 -21
- package/lib/approvals/no-approvals.ts +1 -1
- 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/models/plan-step.ts +5 -13
- 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/tasks/contract/plan.ts +1 -14
- package/lib/worktree-sweep/testTemp.ts +3 -0
- package/package.json +1 -1
- package/scripts/install-deps +6 -1
- package/scripts/lib/commands.sh +1 -1
- package/scripts/lib/wsl.test.ts +51 -1
- package/server.ts +18 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/peer/rpc: a linked machine's call, served before Next on any
|
|
3
|
+
* listener (the envelope is the authentication, not where it came from).
|
|
4
|
+
*
|
|
5
|
+
* 0. with no active link, refuse before reading anything;
|
|
6
|
+
* 1. read the envelope under a byte budget and a minimum transfer rate
|
|
7
|
+
* (rpc-intake.ts), proving the sender from the header's own signature
|
|
8
|
+
* before any of the body is held for it;
|
|
9
|
+
* 2. verify and decrypt it against the sender's pinned keys (envelope.ts);
|
|
10
|
+
* 3. match the inner method, path and query against the allowlist;
|
|
11
|
+
* 4. re-issue it to this machine's own loopback listener with the per-boot
|
|
12
|
+
* secret, so the gate stamps `peer` trust and every route and guard runs
|
|
13
|
+
* exactly as it does for any other caller.
|
|
14
|
+
*
|
|
15
|
+
* Refusals before decryption are plain and fixed (`bad envelope`, `bad
|
|
16
|
+
* signature`, `stale`, `replay`, `busy`). An unknown sender, a suspended one
|
|
17
|
+
* and a bad signature all read `bad signature`, so a stranger can't tell
|
|
18
|
+
* which machines are linked here; the audit keeps the difference. Everything
|
|
19
|
+
* after is inside the envelope,
|
|
20
|
+
* always HTTP 200, so whoever carries the traffic never learns the route or
|
|
21
|
+
* its status.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import crypto from "crypto";
|
|
25
|
+
import http, { type IncomingMessage, type ServerResponse } from "http";
|
|
26
|
+
import { PEER_HEADER, PEER_INTERNAL_HEADER } from "../security/auth";
|
|
27
|
+
import { matchPeerRoute } from "./allowlist";
|
|
28
|
+
import { recordPeerCall, type AuditEntry, type Outcome } from "./audit";
|
|
29
|
+
import {
|
|
30
|
+
MAX_RESPONSE_BODY,
|
|
31
|
+
RPC_PATH,
|
|
32
|
+
SKEW_MS,
|
|
33
|
+
openRequest,
|
|
34
|
+
parseHeaderBytes,
|
|
35
|
+
sealResponse,
|
|
36
|
+
type PeerResponse,
|
|
37
|
+
type Refusal,
|
|
38
|
+
verifyHeader,
|
|
39
|
+
} from "./envelope";
|
|
40
|
+
import { peerSecret } from "./internal";
|
|
41
|
+
import {
|
|
42
|
+
currentIdentity,
|
|
43
|
+
loadBoxKey,
|
|
44
|
+
signPublic,
|
|
45
|
+
type BoxKey,
|
|
46
|
+
type Identity,
|
|
47
|
+
} from "./keys";
|
|
48
|
+
import { hasActivePin, pinForMachine, type PeerPin } from "./links";
|
|
49
|
+
import { sqliteNonceStore } from "./nonce-store";
|
|
50
|
+
import { createReplayGuard, type ReplayGuard } from "./replay";
|
|
51
|
+
import {
|
|
52
|
+
DEFAULT_PACE,
|
|
53
|
+
createBodyBudget,
|
|
54
|
+
readEnvelope,
|
|
55
|
+
sourceOf,
|
|
56
|
+
type BodyBudget,
|
|
57
|
+
type Pace,
|
|
58
|
+
} from "./rpc-intake";
|
|
59
|
+
|
|
60
|
+
export { createBodyBudget };
|
|
61
|
+
|
|
62
|
+
export { RPC_PATH };
|
|
63
|
+
const DISPATCH_TIMEOUT_MS = 10 * 60_000;
|
|
64
|
+
|
|
65
|
+
export interface Dispatch {
|
|
66
|
+
hostId: string;
|
|
67
|
+
method: string;
|
|
68
|
+
path: string;
|
|
69
|
+
query: string;
|
|
70
|
+
contentType: string;
|
|
71
|
+
body: Buffer;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RpcDeps {
|
|
75
|
+
identity: () => Identity | null;
|
|
76
|
+
boxKey: () => BoxKey;
|
|
77
|
+
pinForMachine: (machineId: string) => PeerPin | null;
|
|
78
|
+
/** False while no link is active: the route is closed to everyone. */
|
|
79
|
+
anyActive: () => boolean;
|
|
80
|
+
replay: ReplayGuard;
|
|
81
|
+
dispatch: (d: Dispatch) => Promise<PeerResponse>;
|
|
82
|
+
audit: (e: AuditEntry) => void;
|
|
83
|
+
budget: BodyBudget;
|
|
84
|
+
pace?: Pace;
|
|
85
|
+
now?: () => number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Stands in for an unknown or suspended sender's key, so its envelope takes
|
|
89
|
+
// the same path, and the same time, to the same refusal as a bad signature.
|
|
90
|
+
const DECOY_KEY = crypto.generateKeyPairSync("ed25519").publicKey;
|
|
91
|
+
|
|
92
|
+
export const isPeerRpc = (req: IncomingMessage) =>
|
|
93
|
+
(req.url ?? "").split("?")[0] === RPC_PATH;
|
|
94
|
+
|
|
95
|
+
const REFUSED: Record<Refusal, [number, string, Outcome]> = {
|
|
96
|
+
bad_envelope: [400, "bad envelope", "bad_envelope"],
|
|
97
|
+
wrong_machine: [401, "bad signature", "bad_sig"],
|
|
98
|
+
unknown_peer: [401, "bad signature", "unknown_peer"],
|
|
99
|
+
suspended: [401, "bad signature", "suspended"],
|
|
100
|
+
bad_sig: [401, "bad signature", "bad_sig"],
|
|
101
|
+
stale: [401, "stale", "stale"],
|
|
102
|
+
replay: [401, "replay", "replay"],
|
|
103
|
+
busy: [429, "busy", "busy"],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
function plain(res: ServerResponse, status: number, text: string) {
|
|
107
|
+
if (res.headersSent) return;
|
|
108
|
+
res.statusCode = status;
|
|
109
|
+
res.setHeader("Content-Type", "text/plain");
|
|
110
|
+
res.setHeader("Cache-Control", "no-store");
|
|
111
|
+
res.setHeader("Connection", "close");
|
|
112
|
+
res.end(text);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function handlePeerRpc(
|
|
116
|
+
req: IncomingMessage,
|
|
117
|
+
res: ServerResponse,
|
|
118
|
+
deps: RpcDeps
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
const started = Date.now();
|
|
121
|
+
if (req.method !== "POST") {
|
|
122
|
+
res.setHeader("Allow", "POST");
|
|
123
|
+
return plain(res, 405, "method not allowed");
|
|
124
|
+
}
|
|
125
|
+
const self = deps.identity();
|
|
126
|
+
let fromMachine: string | null = null;
|
|
127
|
+
let pin: PeerPin | null = null;
|
|
128
|
+
const audit = (
|
|
129
|
+
outcome: Outcome,
|
|
130
|
+
status: number,
|
|
131
|
+
route = RPC_PATH,
|
|
132
|
+
method = "POST",
|
|
133
|
+
bytes = 0
|
|
134
|
+
) =>
|
|
135
|
+
deps.audit({
|
|
136
|
+
hostId: pin?.hostId ?? null,
|
|
137
|
+
machineId: fromMachine,
|
|
138
|
+
direction: "in",
|
|
139
|
+
method,
|
|
140
|
+
route,
|
|
141
|
+
status,
|
|
142
|
+
ms: Date.now() - started,
|
|
143
|
+
bytes,
|
|
144
|
+
outcome,
|
|
145
|
+
});
|
|
146
|
+
const refuse = (refusal: Refusal): null => {
|
|
147
|
+
const [status, text, outcome] = REFUSED[refusal];
|
|
148
|
+
audit(outcome, status);
|
|
149
|
+
plain(res, status, text);
|
|
150
|
+
return null;
|
|
151
|
+
};
|
|
152
|
+
// The sender's pinned signing key, or a decoy that verifies nothing for an
|
|
153
|
+
// unknown or suspended one, so its refusal takes the same path and time
|
|
154
|
+
// as a bad signature; `hidden` keeps what the check would have said.
|
|
155
|
+
let hidden: "unknown_peer" | "suspended" | null = null;
|
|
156
|
+
const signKeyFor = (machineId: string) => {
|
|
157
|
+
fromMachine = machineId;
|
|
158
|
+
const found = (pin = deps.pinForMachine(machineId));
|
|
159
|
+
hidden = !found
|
|
160
|
+
? "unknown_peer"
|
|
161
|
+
: found.state !== "active"
|
|
162
|
+
? "suspended"
|
|
163
|
+
: null;
|
|
164
|
+
if (!found || hidden) return DECOY_KEY;
|
|
165
|
+
try {
|
|
166
|
+
return signPublic(found.signPub);
|
|
167
|
+
} catch {
|
|
168
|
+
hidden = "unknown_peer";
|
|
169
|
+
return DECOY_KEY;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const refuseHidden = (refusal: Refusal): null =>
|
|
173
|
+
refuse(refusal === "bad_sig" && hidden ? hidden : refusal);
|
|
174
|
+
const drop = (status: number, text: string, outcome: Outcome): null => {
|
|
175
|
+
audit(outcome, status);
|
|
176
|
+
plain(res, status, text);
|
|
177
|
+
return null;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
if (!deps.anyActive()) return plain(res, 404, "not found");
|
|
181
|
+
const ticket = deps.budget.open(sourceOf(req));
|
|
182
|
+
if (!ticket) return void refuse("busy");
|
|
183
|
+
const readAndOpen = async () => {
|
|
184
|
+
let read;
|
|
185
|
+
try {
|
|
186
|
+
read = await readEnvelope(req, {
|
|
187
|
+
early: (headerBytes, headerSig) => {
|
|
188
|
+
const header = parseHeaderBytes(headerBytes);
|
|
189
|
+
if (!header) return { refusal: "bad_envelope" };
|
|
190
|
+
if (!self || header.to !== self.machineId)
|
|
191
|
+
return { refusal: "wrong_machine" };
|
|
192
|
+
if (!verifyHeader(headerBytes, headerSig, signKeyFor(header.from)))
|
|
193
|
+
return { refusal: "bad_sig" };
|
|
194
|
+
const now = deps.now?.() ?? Date.now();
|
|
195
|
+
if (Math.abs(now - header.ts) > SKEW_MS) return { refusal: "stale" };
|
|
196
|
+
return { peer: header.from };
|
|
197
|
+
},
|
|
198
|
+
ticket,
|
|
199
|
+
pace: deps.pace ?? DEFAULT_PACE,
|
|
200
|
+
});
|
|
201
|
+
} catch {
|
|
202
|
+
return drop(400, "bad envelope", "bad_envelope");
|
|
203
|
+
}
|
|
204
|
+
if ("tooLarge" in read) return drop(413, "too large", "too_large");
|
|
205
|
+
if ("tooSlow" in read) return drop(408, "too slow", "too_slow");
|
|
206
|
+
if ("refusal" in read) return refuseHidden(read.refusal);
|
|
207
|
+
if (!self) return refuse("wrong_machine");
|
|
208
|
+
return openRequest(read.body, {
|
|
209
|
+
self: self.machineId,
|
|
210
|
+
boxKey: deps.boxKey().privateKey,
|
|
211
|
+
signKeyFor,
|
|
212
|
+
replay: deps.replay,
|
|
213
|
+
now: deps.now?.(),
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
let opened;
|
|
217
|
+
try {
|
|
218
|
+
opened = await readAndOpen();
|
|
219
|
+
} finally {
|
|
220
|
+
ticket.close();
|
|
221
|
+
}
|
|
222
|
+
if (!opened || !self) return;
|
|
223
|
+
if (!opened.ok) return void refuseHidden(opened.refusal);
|
|
224
|
+
const host = pin as PeerPin | null;
|
|
225
|
+
if (!host) return void refuse("unknown_peer");
|
|
226
|
+
|
|
227
|
+
const answer = (response: PeerResponse, outcome: Outcome, route: string) => {
|
|
228
|
+
const sealed = sealResponse({
|
|
229
|
+
resKey: opened.resKey,
|
|
230
|
+
nonce: opened.nonce,
|
|
231
|
+
signKey: self.signKey,
|
|
232
|
+
response,
|
|
233
|
+
});
|
|
234
|
+
audit(
|
|
235
|
+
outcome,
|
|
236
|
+
response.status,
|
|
237
|
+
route,
|
|
238
|
+
opened.request.method.slice(0, 10),
|
|
239
|
+
opened.request.body.length + response.body.length
|
|
240
|
+
);
|
|
241
|
+
res.statusCode = 200;
|
|
242
|
+
res.setHeader("Content-Type", "application/octet-stream");
|
|
243
|
+
res.setHeader("Cache-Control", "no-store");
|
|
244
|
+
res.end(sealed);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const { request } = opened;
|
|
248
|
+
const match = matchPeerRoute(request.method, request.path, request.query);
|
|
249
|
+
if (!match.ok)
|
|
250
|
+
return answer(
|
|
251
|
+
jsonResponse(403, { error: "That route isn't open to linked machines." }),
|
|
252
|
+
"denied_route",
|
|
253
|
+
"(denied)"
|
|
254
|
+
);
|
|
255
|
+
let response: PeerResponse;
|
|
256
|
+
try {
|
|
257
|
+
response = await deps.dispatch({
|
|
258
|
+
hostId: host.hostId,
|
|
259
|
+
method: request.method,
|
|
260
|
+
path: match.path,
|
|
261
|
+
query: match.query,
|
|
262
|
+
contentType: request.contentType,
|
|
263
|
+
body: request.body,
|
|
264
|
+
});
|
|
265
|
+
} catch (err) {
|
|
266
|
+
response = jsonResponse(502, {
|
|
267
|
+
error:
|
|
268
|
+
`Couldn't run that here: ${err instanceof Error ? err.message : String(err)}`.slice(
|
|
269
|
+
0,
|
|
270
|
+
300
|
|
271
|
+
),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
answer(response, "ok", match.route);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const jsonResponse = (status: number, body: object): PeerResponse => ({
|
|
278
|
+
status,
|
|
279
|
+
contentType: "application/json",
|
|
280
|
+
body: Buffer.from(JSON.stringify(body)),
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const CONTENT_TYPE = /^[\w.+-]+\/[\w.+-]+(;[ \w.+=\-"]*)?$/;
|
|
284
|
+
|
|
285
|
+
/** Re-issues a verified call to this machine's own loopback listener. */
|
|
286
|
+
export function loopbackDispatch(
|
|
287
|
+
port: number,
|
|
288
|
+
secret: () => string = peerSecret
|
|
289
|
+
): (d: Dispatch) => Promise<PeerResponse> {
|
|
290
|
+
return (d) =>
|
|
291
|
+
new Promise((resolve, reject) => {
|
|
292
|
+
const contentType =
|
|
293
|
+
d.contentType.length <= 200 && CONTENT_TYPE.test(d.contentType)
|
|
294
|
+
? d.contentType
|
|
295
|
+
: "application/octet-stream";
|
|
296
|
+
const out = http.request(
|
|
297
|
+
{
|
|
298
|
+
host: "127.0.0.1",
|
|
299
|
+
port,
|
|
300
|
+
method: d.method,
|
|
301
|
+
path: d.query ? `${d.path}?${d.query}` : d.path,
|
|
302
|
+
headers: {
|
|
303
|
+
host: `127.0.0.1:${port}`,
|
|
304
|
+
...(d.body.length > 0 && { "content-type": contentType }),
|
|
305
|
+
"content-length": String(d.body.length),
|
|
306
|
+
[PEER_INTERNAL_HEADER]: secret(),
|
|
307
|
+
[PEER_HEADER]: d.hostId,
|
|
308
|
+
},
|
|
309
|
+
timeout: DISPATCH_TIMEOUT_MS,
|
|
310
|
+
},
|
|
311
|
+
(res) => {
|
|
312
|
+
const chunks: Buffer[] = [];
|
|
313
|
+
let total = 0;
|
|
314
|
+
res.on("data", (c: Buffer) => {
|
|
315
|
+
total += c.length;
|
|
316
|
+
if (total > MAX_RESPONSE_BODY) {
|
|
317
|
+
res.destroy();
|
|
318
|
+
return reject(new Error("its answer is too large"));
|
|
319
|
+
}
|
|
320
|
+
chunks.push(c);
|
|
321
|
+
});
|
|
322
|
+
res.on("end", () =>
|
|
323
|
+
resolve({
|
|
324
|
+
status: res.statusCode ?? 502,
|
|
325
|
+
contentType: String(res.headers["content-type"] ?? ""),
|
|
326
|
+
body: Buffer.concat(chunks),
|
|
327
|
+
})
|
|
328
|
+
);
|
|
329
|
+
res.on("error", reject);
|
|
330
|
+
}
|
|
331
|
+
);
|
|
332
|
+
out.on("timeout", () => out.destroy(new Error("timed out")));
|
|
333
|
+
out.on("error", reject);
|
|
334
|
+
out.end(d.body);
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The real dependencies: this machine's keys, its pins and its listener. */
|
|
339
|
+
export function rpcDeps(port: number): RpcDeps {
|
|
340
|
+
let box: BoxKey | null = null;
|
|
341
|
+
return {
|
|
342
|
+
identity: () => currentIdentity(),
|
|
343
|
+
boxKey: () => (box ??= loadBoxKey()),
|
|
344
|
+
pinForMachine,
|
|
345
|
+
anyActive: hasActivePin,
|
|
346
|
+
replay: createReplayGuard({ store: sqliteNonceStore() }),
|
|
347
|
+
dispatch: loopbackDispatch(port),
|
|
348
|
+
audit: recordPeerCall,
|
|
349
|
+
budget: createBodyBudget(),
|
|
350
|
+
};
|
|
351
|
+
}
|
package/lib/security/auth.ts
CHANGED
|
@@ -10,20 +10,33 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { IncomingHttpHeaders } from "http";
|
|
13
|
+
import { peerTargetAllowed } from "../peer/allowlist";
|
|
14
|
+
import { secretMatches } from "../peer/internal";
|
|
13
15
|
|
|
14
16
|
export const DEVICE_COOKIE = "aos_device";
|
|
15
17
|
// Set by server.ts on every request after stripping any client-sent copy.
|
|
16
18
|
export const DEVICE_HEADER = "x-agentos-device";
|
|
17
19
|
export const TRUST_HEADER = "x-agentos-trust";
|
|
18
20
|
export const REMOTE_HEADER = "x-agentos-remote";
|
|
21
|
+
// The rpc server's loopback hop (lib/peer/rpc-server): the per-boot secret,
|
|
22
|
+
// and the linked machine it verified. Never trusted from anywhere else.
|
|
23
|
+
export const PEER_INTERNAL_HEADER = "x-agentos-peer-internal";
|
|
24
|
+
export const PEER_HEADER = "x-agentos-peer";
|
|
19
25
|
|
|
20
|
-
|
|
26
|
+
/**
|
|
27
|
+
* `peer`: a linked machine's verified call, re-issued by this machine. It is
|
|
28
|
+
* never local trust and never an approver, and only reaches its allowlist.
|
|
29
|
+
*/
|
|
30
|
+
export type Trust = "loopback" | "tailnet" | "device" | "open" | "peer";
|
|
21
31
|
|
|
22
32
|
export type AuthResult =
|
|
23
|
-
{ ok: true; via: Trust; deviceId?: string
|
|
33
|
+
| { ok: true; via: Trust; deviceId?: string; peerHost?: string }
|
|
34
|
+
// hard: refused outright, even on a path that is otherwise public.
|
|
35
|
+
| { ok: false; hard?: true };
|
|
24
36
|
|
|
25
37
|
export interface AuthRequest {
|
|
26
38
|
url?: string;
|
|
39
|
+
method?: string;
|
|
27
40
|
headers: IncomingHttpHeaders;
|
|
28
41
|
remoteAddress?: string;
|
|
29
42
|
localAddress?: string;
|
|
@@ -47,11 +60,14 @@ export interface AuthPolicy {
|
|
|
47
60
|
/** AGENTOS_AUTH=off: someone else's proxy does the login. */
|
|
48
61
|
off?: boolean;
|
|
49
62
|
lookup: (token: string) => { id: string } | null;
|
|
63
|
+
/** The rpc server's per-boot secret; without it no request is a peer. */
|
|
64
|
+
peerSecret?: () => string;
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
const TAILSCALE_V4 = /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./;
|
|
53
68
|
const LOOPBACK = new Set(["127.0.0.1", "::1"]);
|
|
54
69
|
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
70
|
+
const PEER_HOST_ID = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
55
71
|
// A proxy must send one of these, or it looks like this machine itself.
|
|
56
72
|
// Proxies that send none need AGENTOS_AUTH, see docs/reference/security.md.
|
|
57
73
|
const FORWARDING = new Set([
|
|
@@ -64,12 +80,13 @@ const FORWARDING = new Set([
|
|
|
64
80
|
]);
|
|
65
81
|
const FORWARDING_PREFIXES = ["x-forwarded-", "cf-", "tailscale-"];
|
|
66
82
|
|
|
67
|
-
// Reached without a token: the pairing page, what it needs to render,
|
|
68
|
-
//
|
|
83
|
+
// Reached without a token: the pairing page, what it needs to render, the
|
|
84
|
+
// claim it posts, and a linked machine's rpc (its envelope is the proof). Exact paths, plus a few static asset folders whose
|
|
69
85
|
// paths may only use plain characters (no traversal, no encoding).
|
|
70
86
|
const PUBLIC_EXACT = new Set([
|
|
71
87
|
"/pair",
|
|
72
88
|
"/api/pair/claim",
|
|
89
|
+
"/api/peer/rpc",
|
|
73
90
|
"/manifest.webmanifest",
|
|
74
91
|
"/manifest.json",
|
|
75
92
|
"/favicon.ico",
|
|
@@ -155,11 +172,31 @@ export function authorize(req: AuthRequest, policy: AuthPolicy): AuthResult {
|
|
|
155
172
|
try {
|
|
156
173
|
return decide(req, policy);
|
|
157
174
|
} catch {
|
|
158
|
-
return
|
|
175
|
+
return req.headers[PEER_INTERNAL_HEADER] !== undefined
|
|
176
|
+
? { ok: false, hard: true }
|
|
177
|
+
: { ok: false };
|
|
159
178
|
}
|
|
160
179
|
}
|
|
161
180
|
|
|
181
|
+
/**
|
|
182
|
+
* A request carrying the internal header is a peer call or nothing: it
|
|
183
|
+
* never falls back to loopback trust, whatever its address says.
|
|
184
|
+
*/
|
|
185
|
+
function peerTrust(req: AuthRequest, policy: AuthPolicy): AuthResult {
|
|
186
|
+
const refuse = { ok: false, hard: true } as const;
|
|
187
|
+
if (req.tunnel || !policy.peerSecret || proxied(req.headers)) return refuse;
|
|
188
|
+
if (!LOOPBACK.has(plainAddress(req.remoteAddress))) return refuse;
|
|
189
|
+
if (!secretMatches(req.headers[PEER_INTERNAL_HEADER], policy.peerSecret()))
|
|
190
|
+
return refuse;
|
|
191
|
+
const host = req.headers[PEER_HEADER];
|
|
192
|
+
if (typeof host !== "string" || !PEER_HOST_ID.test(host)) return refuse;
|
|
193
|
+
if (!peerTargetAllowed(req.method, req.url)) return refuse;
|
|
194
|
+
return { ok: true, via: "peer", deviceId: `peer:${host}`, peerHost: host };
|
|
195
|
+
}
|
|
196
|
+
|
|
162
197
|
function decide(req: AuthRequest, policy: AuthPolicy): AuthResult {
|
|
198
|
+
if (req.headers[PEER_INTERNAL_HEADER] !== undefined)
|
|
199
|
+
return peerTrust(req, policy);
|
|
163
200
|
const trusted = req.tunnel ? null : networkTrust(req, policy);
|
|
164
201
|
const token = readToken(req.headers);
|
|
165
202
|
if (trusted) {
|
package/lib/security/gate.ts
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
DEVICE_HEADER,
|
|
15
15
|
TRUST_HEADER,
|
|
16
16
|
REMOTE_HEADER,
|
|
17
|
+
PEER_HEADER,
|
|
18
|
+
PEER_INTERNAL_HEADER,
|
|
17
19
|
type AuthPolicy,
|
|
18
20
|
type AuthResult,
|
|
19
21
|
} from "./auth";
|
|
@@ -22,6 +24,7 @@ import { deviceForToken, touchDevice, trackDeviceSocket } from "./devices";
|
|
|
22
24
|
import { tailscaleAddresses } from "./net";
|
|
23
25
|
import { networkSetting } from "./network-settings";
|
|
24
26
|
import { pairLocation } from "../session-url";
|
|
27
|
+
import { peerSecret } from "../peer/internal";
|
|
25
28
|
|
|
26
29
|
// Interface addresses change rarely; don't re-read them on every request.
|
|
27
30
|
let tailnetCache = { at: 0, ips: [] as string[] };
|
|
@@ -42,6 +45,7 @@ export function authPolicy(env = process.env): AuthPolicy {
|
|
|
42
45
|
},
|
|
43
46
|
off: env.AGENTOS_AUTH === "off",
|
|
44
47
|
lookup: deviceForToken,
|
|
48
|
+
peerSecret,
|
|
45
49
|
};
|
|
46
50
|
}
|
|
47
51
|
|
|
@@ -54,21 +58,36 @@ function decide(req: IncomingMessage, policy: AuthPolicy): AuthResult {
|
|
|
54
58
|
// Never trust a client-supplied verdict.
|
|
55
59
|
delete req.headers[DEVICE_HEADER];
|
|
56
60
|
delete req.headers[TRUST_HEADER];
|
|
61
|
+
// A peer's identity only ever comes with the internal secret.
|
|
62
|
+
if (req.headers[PEER_INTERNAL_HEADER] === undefined)
|
|
63
|
+
delete req.headers[PEER_HEADER];
|
|
57
64
|
const tunnel = isTunnelSocket(req.socket);
|
|
58
65
|
req.headers[REMOTE_HEADER] = sourceLabel(req.socket.remoteAddress, tunnel);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
let result: AuthResult;
|
|
67
|
+
try {
|
|
68
|
+
result = authorize(
|
|
69
|
+
{
|
|
70
|
+
url: req.url,
|
|
71
|
+
method: req.method,
|
|
72
|
+
headers: req.headers,
|
|
73
|
+
remoteAddress: req.socket.remoteAddress,
|
|
74
|
+
localAddress: req.socket.localAddress,
|
|
75
|
+
tunnel,
|
|
76
|
+
},
|
|
77
|
+
policy
|
|
78
|
+
);
|
|
79
|
+
} finally {
|
|
80
|
+
// No route ever sees the secret.
|
|
81
|
+
delete req.headers[PEER_INTERNAL_HEADER];
|
|
82
|
+
}
|
|
83
|
+
delete req.headers[PEER_HEADER];
|
|
69
84
|
if (result.ok) {
|
|
70
85
|
req.headers[TRUST_HEADER] = result.via;
|
|
71
86
|
if (result.deviceId) req.headers[DEVICE_HEADER] = result.deviceId;
|
|
87
|
+
if (result.peerHost) {
|
|
88
|
+
req.headers[PEER_HEADER] = result.peerHost;
|
|
89
|
+
req.headers[REMOTE_HEADER] = "peer";
|
|
90
|
+
}
|
|
72
91
|
}
|
|
73
92
|
return result;
|
|
74
93
|
}
|
|
@@ -81,6 +100,8 @@ function check(req: IncomingMessage, policy: AuthPolicy): AuthResult {
|
|
|
81
100
|
console.error("access check failed:", err);
|
|
82
101
|
delete req.headers[TRUST_HEADER];
|
|
83
102
|
delete req.headers[DEVICE_HEADER];
|
|
103
|
+
delete req.headers[PEER_HEADER];
|
|
104
|
+
delete req.headers[PEER_INTERNAL_HEADER];
|
|
84
105
|
return { ok: false };
|
|
85
106
|
}
|
|
86
107
|
}
|
|
@@ -102,6 +123,7 @@ export function gateRequest(
|
|
|
102
123
|
): boolean {
|
|
103
124
|
const result = check(req, policy);
|
|
104
125
|
if (result.ok) {
|
|
126
|
+
if (result.via === "peer") return true;
|
|
105
127
|
// Slide the cookie's expiry forward, at most once a minute per device.
|
|
106
128
|
const cookie = result.deviceId ? cookieToken(req.headers) : null;
|
|
107
129
|
if (result.deviceId && touch(req, result.deviceId) && cookie) {
|
|
@@ -114,7 +136,7 @@ export function gateRequest(
|
|
|
114
136
|
}
|
|
115
137
|
return true;
|
|
116
138
|
}
|
|
117
|
-
if (isPublicPath(req.url)) return true;
|
|
139
|
+
if (!result.hard && isPublicPath(req.url)) return true;
|
|
118
140
|
const path = (req.url ?? "/").split("?")[0];
|
|
119
141
|
const wantsPage =
|
|
120
142
|
!path.startsWith("/api/") &&
|
|
@@ -139,7 +161,8 @@ export function gateUpgrade(
|
|
|
139
161
|
policy: AuthPolicy
|
|
140
162
|
): boolean {
|
|
141
163
|
const result = check(req, policy);
|
|
142
|
-
|
|
164
|
+
// A peer reaches its allowlisted routes and never a socket.
|
|
165
|
+
if (!result.ok || result.via === "peer") {
|
|
143
166
|
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
144
167
|
socket.destroy();
|
|
145
168
|
return false;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* For actions a stolen device token must not be able to take (adding
|
|
3
|
-
* devices, opening up the network): only this machine or the tailnet.
|
|
3
|
+
* devices, opening up the network): only this machine or the tailnet. A
|
|
4
|
+
* linked machine's call (`peer`) never passes, even though it arrives on
|
|
5
|
+
* loopback.
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
8
|
import { NextResponse, type NextRequest } from "next/server";
|
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
import { ARTIFACT_HEADERS } from "../artifacts/serve";
|
|
9
9
|
import { IMAGE_TYPES } from "../artifacts/image-paths";
|
|
10
10
|
import { IMAGE_CSP } from "../artifacts/serve";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
cleanRemoteText,
|
|
13
|
+
hostApi,
|
|
14
|
+
isConnectLink,
|
|
15
|
+
type HostLink,
|
|
16
|
+
} from "../hosts/remote-api";
|
|
12
17
|
import type {
|
|
13
18
|
ArtifactKind,
|
|
14
19
|
ArtifactPreview,
|
|
@@ -148,6 +153,12 @@ export async function peerContent(
|
|
|
148
153
|
): Promise<Content> {
|
|
149
154
|
if (!ID.test(artifactId))
|
|
150
155
|
return { ok: false, status: 404, error: "Not found" };
|
|
156
|
+
if (isConnectLink(link))
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
status: 409,
|
|
160
|
+
error: `${link.hostName} is linked through Connect: artifacts need a Tailscale/ssh link for now`,
|
|
161
|
+
};
|
|
151
162
|
let res: Response;
|
|
152
163
|
try {
|
|
153
164
|
res = await fetch(
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
* (which sends no device) is never refused on them.
|
|
10
10
|
* - plan: open, and a plan another machine placed here is read and approved
|
|
11
11
|
* by that machine only.
|
|
12
|
+
*
|
|
13
|
+
* The caller is the gate's device id. A machine linked through Connect is
|
|
14
|
+
* `peer:<host id>` (lib/security/gate.ts), with no Can approve: the same
|
|
15
|
+
* standing a linked machine's device token has.
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
18
|
import { NextRequest, NextResponse } from "next/server";
|
|
@@ -16,10 +16,6 @@ import { isRemoteHost } from "../../hosts";
|
|
|
16
16
|
import { peerSource } from "../../hosts/peer-load";
|
|
17
17
|
import { hostLink, requireHostLink } from "../../hosts/remote-api";
|
|
18
18
|
import { LOCAL, machineCapabilities } from "../../load/machines";
|
|
19
|
-
import {
|
|
20
|
-
noApprovalsOn,
|
|
21
|
-
recordAutoApproval,
|
|
22
|
-
} from "../../approvals/no-approvals";
|
|
23
19
|
import { implementArrived, planNoteFor } from "../../models/plan-step";
|
|
24
20
|
import { sessionWorkspace } from "../../models/resolve";
|
|
25
21
|
import { queueEvent } from "../../orchestrator/events";
|
|
@@ -112,8 +108,7 @@ export async function planWritten(
|
|
|
112
108
|
// As for a task planned here: a plan-first task goes on by itself, a
|
|
113
109
|
// plan-only one waits unless no-approvals is on, and pieces that couldn't
|
|
114
110
|
// be registered always hold it.
|
|
115
|
-
const
|
|
116
|
-
const goOn = (!fresh.plan_only || auto) && !errors.length;
|
|
111
|
+
const goOn = !fresh.plan_only && !errors.length;
|
|
117
112
|
const target = goOn ? implementTarget(fresh, split) : null;
|
|
118
113
|
const next =
|
|
119
114
|
target === "there"
|
|
@@ -148,14 +143,6 @@ export async function planWritten(
|
|
|
148
143
|
return;
|
|
149
144
|
}
|
|
150
145
|
await continuePlanThere(fresh);
|
|
151
|
-
if (auto)
|
|
152
|
-
recordAutoApproval({
|
|
153
|
-
source: "plan",
|
|
154
|
-
workspaceId,
|
|
155
|
-
sessionId: s.id,
|
|
156
|
-
subject: `plan for ${s.name}`,
|
|
157
|
-
detail: "implementing without waiting for approve_plan",
|
|
158
|
-
});
|
|
159
146
|
}
|
|
160
147
|
|
|
161
148
|
/**
|
|
@@ -37,6 +37,7 @@ export const TEST_TEMP_PREFIXES: readonly string[] = [
|
|
|
37
37
|
"aos-kind-",
|
|
38
38
|
"aos-launch-plain-",
|
|
39
39
|
"aos-launch-repo-",
|
|
40
|
+
"aos-machine-",
|
|
40
41
|
"aos-limits-",
|
|
41
42
|
"aos-mcp-",
|
|
42
43
|
"aos-merge-policy-",
|
|
@@ -66,6 +67,8 @@ export const TEST_TEMP_PREFIXES: readonly string[] = [
|
|
|
66
67
|
"aos-orch-sweep-",
|
|
67
68
|
"aos-origin-",
|
|
68
69
|
"aos-path-",
|
|
70
|
+
"aos-peer-connect-",
|
|
71
|
+
"aos-peer-keys-",
|
|
69
72
|
"aos-plain-",
|
|
70
73
|
"aos-plan-",
|
|
71
74
|
"aos-ports-",
|
package/package.json
CHANGED
package/scripts/install-deps
CHANGED
|
@@ -13,7 +13,12 @@ source scripts/lib/pnpm.sh
|
|
|
13
13
|
if [ -s "$HOME/.nvm/nvm.sh" ]; then
|
|
14
14
|
# shellcheck disable=SC1091
|
|
15
15
|
source "$HOME/.nvm/nvm.sh" >/dev/null
|
|
16
|
-
nvm
|
|
16
|
+
# A box with nvm but without .nvmrc's version (a CI runner) keeps the node on PATH.
|
|
17
|
+
nvm use >/dev/null 2>&1 || true
|
|
18
|
+
fi
|
|
19
|
+
if [ "$(node -p 'process.versions.node.split(".")[0]')" -lt 22 ]; then
|
|
20
|
+
echo "Node 22 or newer is required (found $(node -v)); run: nvm install" >&2
|
|
21
|
+
exit 1
|
|
17
22
|
fi
|
|
18
23
|
use_pnpm
|
|
19
24
|
|