@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.
@@ -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";
@@ -67,6 +67,8 @@ export const TEST_TEMP_PREFIXES: readonly string[] = [
67
67
  "aos-orch-sweep-",
68
68
  "aos-origin-",
69
69
  "aos-path-",
70
+ "aos-peer-connect-",
71
+ "aos-peer-keys-",
70
72
  "aos-plain-",
71
73
  "aos-plan-",
72
74
  "aos-ports-",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.697",
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"
package/server.ts CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  refuseNextInternal,
48
48
  } from "./lib/security/next-internals";
49
49
  import { lanEnabled } from "./lib/security/network-settings";
50
+ import { handlePeerRpc, isPeerRpc, rpcDeps } from "./lib/peer/rpc-server";
50
51
  import { startConnect } from "./lib/connect/serve";
51
52
  import { serverPort } from "./lib/connect/config";
52
53
  import { startUpdateChecks, updateChecksEnabled } from "./lib/updates/check";
@@ -150,6 +151,7 @@ app.prepare().then(async () => {
150
151
  .map((h) => h.trim().toLowerCase())
151
152
  .filter(Boolean);
152
153
  const auth = authPolicy();
154
+ const peerRpc = rpcDeps(port);
153
155
  const header = (v: string | string[] | undefined) =>
154
156
  Array.isArray(v) ? v[0] : v;
155
157
 
@@ -177,6 +179,22 @@ app.prepare().then(async () => {
177
179
  if (refuseNextInternal(req, res)) return;
178
180
  if (!gateRequest(req, res, auth)) return;
179
181
  if (demo && !gateDemoRequest(req, res)) return;
182
+ // A linked machine's envelope: verified, then re-issued to loopback.
183
+ if (isPeerRpc(req)) {
184
+ if (demo) {
185
+ res.statusCode = 404;
186
+ res.end();
187
+ return;
188
+ }
189
+ try {
190
+ await handlePeerRpc(req, res, peerRpc);
191
+ } catch (err) {
192
+ console.error("peer rpc failed:", err);
193
+ if (!res.headersSent) res.statusCode = 500;
194
+ res.end();
195
+ }
196
+ return;
197
+ }
180
198
  try {
181
199
  await compressJson(req, res);
182
200
  const parsedUrl = parse(req.url!, true);