@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,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
+ }
@@ -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
- export type Trust = "loopback" | "tailnet" | "device" | "open";
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 } | { ok: false };
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, and
68
- // the claim it posts. Exact paths, plus a few static asset folders whose
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 { ok: false };
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) {
@@ -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
- const result = authorize(
60
- {
61
- url: req.url,
62
- headers: req.headers,
63
- remoteAddress: req.socket.remoteAddress,
64
- localAddress: req.socket.localAddress,
65
- tunnel,
66
- },
67
- policy
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
- if (!result.ok) {
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 { cleanRemoteText, hostApi, type HostLink } from "../hosts/remote-api";
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 auto = !!fresh.plan_only && noApprovalsOn();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.694",
3
+ "version": "0.3.698",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -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 use >/dev/null
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
 
@@ -189,7 +189,7 @@ cmd_stop() {
189
189
  while ps -p "$pid" &> /dev/null && [[ $count -lt 10 ]]; do
190
190
  printf "."
191
191
  sleep 1
192
- ((count++))
192
+ count=$((count + 1))
193
193
  done
194
194
  echo ""
195
195