@saadnvd1/agent-os 0.3.697 → 0.3.700

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 (88) hide show
  1. package/app/api/bus/send/route.ts +11 -1
  2. package/app/api/bus/wait/route.ts +22 -0
  3. package/app/api/peer/hello/reveal/route.ts +6 -0
  4. package/app/api/peer/hello/route.ts +9 -0
  5. package/app/api/peer/hello/status/route.ts +6 -0
  6. package/app/api/peer/ping/route.ts +10 -0
  7. package/app/api/peers/account/route.ts +9 -0
  8. package/app/api/peers/gate.test.ts +111 -0
  9. package/app/api/peers/link/[id]/confirm/route.ts +27 -0
  10. package/app/api/peers/link/[id]/route.ts +20 -0
  11. package/app/api/peers/link/route.ts +23 -0
  12. package/app/api/peers/pending/[id]/accept/route.ts +31 -0
  13. package/app/api/peers/pending/[id]/decline/route.ts +13 -0
  14. package/app/api/peers/pending/route.ts +7 -0
  15. package/app/api/sessions/route.ts +18 -1
  16. package/app/page.tsx +2 -0
  17. package/components/Hosts/AccountMachines.tsx +180 -0
  18. package/components/Hosts/HostRow.tsx +44 -35
  19. package/components/Hosts/HostsDialog.tsx +49 -37
  20. package/components/Hosts/IncomingLinkSheet.tsx +228 -0
  21. package/components/Hosts/LinkCodeSheet.tsx +186 -0
  22. package/components/Hosts/sheet.ts +3 -0
  23. package/components/SessionList/RowSubtitle.tsx +11 -1
  24. package/components/Welcome/ConnectBlock.tsx +1 -1
  25. package/data/peers/index.ts +2 -0
  26. package/data/peers/keys.ts +6 -0
  27. package/data/peers/queries.ts +141 -0
  28. package/lib/__fixtures__/connect-transport.ts +93 -0
  29. package/lib/__fixtures__/peer-machine.ts +45 -0
  30. package/lib/__fixtures__/peer-rpc.ts +224 -0
  31. package/lib/agents/brief.ts +3 -1
  32. package/lib/bus/format.ts +25 -4
  33. package/lib/bus/index.ts +113 -106
  34. package/lib/bus/resolve.ts +16 -2
  35. package/lib/bus/targets.ts +122 -0
  36. package/lib/bus/waits/kick.ts +12 -0
  37. package/lib/bus/waits/lines.ts +69 -0
  38. package/lib/bus/waits/register.ts +83 -0
  39. package/lib/bus/waits/store.ts +196 -0
  40. package/lib/bus/waits/tick.ts +192 -0
  41. package/lib/connect/control-api.ts +108 -2
  42. package/lib/connect/heartbeat.ts +11 -3
  43. package/lib/connect/serve.ts +2 -0
  44. package/lib/db/migrations/20261020T1258_peer_links.ts +49 -0
  45. package/lib/db/migrations/20261020T1345_peer_nonces.ts +18 -0
  46. package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
  47. package/lib/db/types.ts +6 -0
  48. package/lib/done/archive.ts +2 -0
  49. package/lib/hosts/index.ts +28 -7
  50. package/lib/hosts/peer-socket.ts +3 -1
  51. package/lib/hosts/read-capped-bytes.ts +23 -0
  52. package/lib/hosts/remote-api.ts +112 -41
  53. package/lib/orchestrator/describe.ts +13 -0
  54. package/lib/orchestrator/facts.ts +17 -0
  55. package/lib/peer/account.ts +198 -0
  56. package/lib/peer/allowlist.ts +136 -0
  57. package/lib/peer/audit.ts +144 -0
  58. package/lib/peer/cli.ts +24 -0
  59. package/lib/peer/client.ts +188 -0
  60. package/lib/peer/code.ts +3 -0
  61. package/lib/peer/envelope.ts +424 -0
  62. package/lib/peer/hello-route.ts +32 -0
  63. package/lib/peer/hello.ts +346 -0
  64. package/lib/peer/internal.ts +22 -0
  65. package/lib/peer/keys.ts +122 -0
  66. package/lib/peer/link-cli.ts +233 -0
  67. package/lib/peer/links.ts +96 -0
  68. package/lib/peer/machines.ts +72 -0
  69. package/lib/peer/nonce-store.ts +23 -0
  70. package/lib/peer/notify.ts +32 -0
  71. package/lib/peer/pairing-out.ts +382 -0
  72. package/lib/peer/pairing.ts +403 -0
  73. package/lib/peer/pin.ts +55 -0
  74. package/lib/peer/replay.ts +81 -0
  75. package/lib/peer/rpc-intake.ts +258 -0
  76. package/lib/peer/rpc-server.ts +369 -0
  77. package/lib/schedules/when.ts +1 -1
  78. package/lib/security/auth.ts +51 -6
  79. package/lib/security/gate.ts +38 -12
  80. package/lib/security/route-guard.ts +3 -1
  81. package/lib/session-artifacts/peer.ts +12 -1
  82. package/lib/sessions/end.ts +3 -0
  83. package/lib/tasks/contract/authorize.ts +4 -0
  84. package/lib/worktree-sweep/testTemp.ts +2 -0
  85. package/package.json +1 -1
  86. package/scripts/agent-os +2 -0
  87. package/scripts/lib/commands.sh +2 -0
  88. package/server.ts +21 -0
@@ -10,20 +10,36 @@
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
+ // Who sent it, for per-sender limits: the visitor's address on a Connect
22
+ // request (lib/peer/rpc-intake sourceOf), an IPv6 address by its /64.
23
+ export const SOURCE_HEADER = "x-agentos-source";
24
+ // The rpc server's loopback hop (lib/peer/rpc-server): the per-boot secret,
25
+ // and the linked machine it verified. Never trusted from anywhere else.
26
+ export const PEER_INTERNAL_HEADER = "x-agentos-peer-internal";
27
+ export const PEER_HEADER = "x-agentos-peer";
19
28
 
20
- export type Trust = "loopback" | "tailnet" | "device" | "open";
29
+ /**
30
+ * `peer`: a linked machine's verified call, re-issued by this machine. It is
31
+ * never local trust and never an approver, and only reaches its allowlist.
32
+ */
33
+ export type Trust = "loopback" | "tailnet" | "device" | "open" | "peer";
21
34
 
22
35
  export type AuthResult =
23
- { ok: true; via: Trust; deviceId?: string } | { ok: false };
36
+ | { ok: true; via: Trust; deviceId?: string; peerHost?: string }
37
+ // hard: refused outright, even on a path that is otherwise public.
38
+ | { ok: false; hard?: true };
24
39
 
25
40
  export interface AuthRequest {
26
41
  url?: string;
42
+ method?: string;
27
43
  headers: IncomingHttpHeaders;
28
44
  remoteAddress?: string;
29
45
  localAddress?: string;
@@ -47,11 +63,14 @@ export interface AuthPolicy {
47
63
  /** AGENTOS_AUTH=off: someone else's proxy does the login. */
48
64
  off?: boolean;
49
65
  lookup: (token: string) => { id: string } | null;
66
+ /** The rpc server's per-boot secret; without it no request is a peer. */
67
+ peerSecret?: () => string;
50
68
  }
51
69
 
52
70
  const TAILSCALE_V4 = /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./;
53
71
  const LOOPBACK = new Set(["127.0.0.1", "::1"]);
54
72
  const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
73
+ const PEER_HOST_ID = /^[A-Za-z0-9_.-]{1,128}$/;
55
74
  // A proxy must send one of these, or it looks like this machine itself.
56
75
  // Proxies that send none need AGENTOS_AUTH, see docs/reference/security.md.
57
76
  const FORWARDING = new Set([
@@ -64,12 +83,18 @@ const FORWARDING = new Set([
64
83
  ]);
65
84
  const FORWARDING_PREFIXES = ["x-forwarded-", "cf-", "tailscale-"];
66
85
 
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
69
- // paths may only use plain characters (no traversal, no encoding).
86
+ // Reached without a token: the pairing page, what it needs to render, the
87
+ // claim it posts, a linked machine's rpc (its envelope is the proof) and
88
+ // the pairing hellos (signed by a key the account lists). Exact paths, plus
89
+ // a few static asset folders whose paths may only use plain characters (no
90
+ // traversal, no encoding).
70
91
  const PUBLIC_EXACT = new Set([
71
92
  "/pair",
72
93
  "/api/pair/claim",
94
+ "/api/peer/rpc",
95
+ "/api/peer/hello",
96
+ "/api/peer/hello/reveal",
97
+ "/api/peer/hello/status",
73
98
  "/manifest.webmanifest",
74
99
  "/manifest.json",
75
100
  "/favicon.ico",
@@ -155,11 +180,31 @@ export function authorize(req: AuthRequest, policy: AuthPolicy): AuthResult {
155
180
  try {
156
181
  return decide(req, policy);
157
182
  } catch {
158
- return { ok: false };
183
+ return req.headers[PEER_INTERNAL_HEADER] !== undefined
184
+ ? { ok: false, hard: true }
185
+ : { ok: false };
159
186
  }
160
187
  }
161
188
 
189
+ /**
190
+ * A request carrying the internal header is a peer call or nothing: it
191
+ * never falls back to loopback trust, whatever its address says.
192
+ */
193
+ function peerTrust(req: AuthRequest, policy: AuthPolicy): AuthResult {
194
+ const refuse = { ok: false, hard: true } as const;
195
+ if (req.tunnel || !policy.peerSecret || proxied(req.headers)) return refuse;
196
+ if (!LOOPBACK.has(plainAddress(req.remoteAddress))) return refuse;
197
+ if (!secretMatches(req.headers[PEER_INTERNAL_HEADER], policy.peerSecret()))
198
+ return refuse;
199
+ const host = req.headers[PEER_HEADER];
200
+ if (typeof host !== "string" || !PEER_HOST_ID.test(host)) return refuse;
201
+ if (!peerTargetAllowed(req.method, req.url)) return refuse;
202
+ return { ok: true, via: "peer", deviceId: `peer:${host}`, peerHost: host };
203
+ }
204
+
162
205
  function decide(req: AuthRequest, policy: AuthPolicy): AuthResult {
206
+ if (req.headers[PEER_INTERNAL_HEADER] !== undefined)
207
+ return peerTrust(req, policy);
163
208
  const trusted = req.tunnel ? null : networkTrust(req, policy);
164
209
  const token = readToken(req.headers);
165
210
  if (trusted) {
@@ -14,6 +14,9 @@ import {
14
14
  DEVICE_HEADER,
15
15
  TRUST_HEADER,
16
16
  REMOTE_HEADER,
17
+ SOURCE_HEADER,
18
+ PEER_HEADER,
19
+ PEER_INTERNAL_HEADER,
17
20
  type AuthPolicy,
18
21
  type AuthResult,
19
22
  } from "./auth";
@@ -22,6 +25,8 @@ import { deviceForToken, touchDevice, trackDeviceSocket } from "./devices";
22
25
  import { tailscaleAddresses } from "./net";
23
26
  import { networkSetting } from "./network-settings";
24
27
  import { pairLocation } from "../session-url";
28
+ import { peerSecret } from "../peer/internal";
29
+ import { sourceOf } from "../peer/rpc-intake";
25
30
 
26
31
  // Interface addresses change rarely; don't re-read them on every request.
27
32
  let tailnetCache = { at: 0, ips: [] as string[] };
@@ -42,6 +47,7 @@ export function authPolicy(env = process.env): AuthPolicy {
42
47
  },
43
48
  off: env.AGENTOS_AUTH === "off",
44
49
  lookup: deviceForToken,
50
+ peerSecret,
45
51
  };
46
52
  }
47
53
 
@@ -54,21 +60,37 @@ function decide(req: IncomingMessage, policy: AuthPolicy): AuthResult {
54
60
  // Never trust a client-supplied verdict.
55
61
  delete req.headers[DEVICE_HEADER];
56
62
  delete req.headers[TRUST_HEADER];
63
+ // A peer's identity only ever comes with the internal secret.
64
+ if (req.headers[PEER_INTERNAL_HEADER] === undefined)
65
+ delete req.headers[PEER_HEADER];
57
66
  const tunnel = isTunnelSocket(req.socket);
58
67
  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
- );
68
+ req.headers[SOURCE_HEADER] = sourceOf(req);
69
+ let result: AuthResult;
70
+ try {
71
+ result = authorize(
72
+ {
73
+ url: req.url,
74
+ method: req.method,
75
+ headers: req.headers,
76
+ remoteAddress: req.socket.remoteAddress,
77
+ localAddress: req.socket.localAddress,
78
+ tunnel,
79
+ },
80
+ policy
81
+ );
82
+ } finally {
83
+ // No route ever sees the secret.
84
+ delete req.headers[PEER_INTERNAL_HEADER];
85
+ }
86
+ delete req.headers[PEER_HEADER];
69
87
  if (result.ok) {
70
88
  req.headers[TRUST_HEADER] = result.via;
71
89
  if (result.deviceId) req.headers[DEVICE_HEADER] = result.deviceId;
90
+ if (result.peerHost) {
91
+ req.headers[PEER_HEADER] = result.peerHost;
92
+ req.headers[REMOTE_HEADER] = "peer";
93
+ }
72
94
  }
73
95
  return result;
74
96
  }
@@ -81,6 +103,8 @@ function check(req: IncomingMessage, policy: AuthPolicy): AuthResult {
81
103
  console.error("access check failed:", err);
82
104
  delete req.headers[TRUST_HEADER];
83
105
  delete req.headers[DEVICE_HEADER];
106
+ delete req.headers[PEER_HEADER];
107
+ delete req.headers[PEER_INTERNAL_HEADER];
84
108
  return { ok: false };
85
109
  }
86
110
  }
@@ -102,6 +126,7 @@ export function gateRequest(
102
126
  ): boolean {
103
127
  const result = check(req, policy);
104
128
  if (result.ok) {
129
+ if (result.via === "peer") return true;
105
130
  // Slide the cookie's expiry forward, at most once a minute per device.
106
131
  const cookie = result.deviceId ? cookieToken(req.headers) : null;
107
132
  if (result.deviceId && touch(req, result.deviceId) && cookie) {
@@ -114,7 +139,7 @@ export function gateRequest(
114
139
  }
115
140
  return true;
116
141
  }
117
- if (isPublicPath(req.url)) return true;
142
+ if (!result.hard && isPublicPath(req.url)) return true;
118
143
  const path = (req.url ?? "/").split("?")[0];
119
144
  const wantsPage =
120
145
  !path.startsWith("/api/") &&
@@ -139,7 +164,8 @@ export function gateUpgrade(
139
164
  policy: AuthPolicy
140
165
  ): boolean {
141
166
  const result = check(req, policy);
142
- if (!result.ok) {
167
+ // A peer reaches its allowlisted routes and never a socket.
168
+ if (!result.ok || result.via === "peer") {
143
169
  socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
144
170
  socket.destroy();
145
171
  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(
@@ -4,6 +4,7 @@ import { waitForExit } from "../chat/worker/client";
4
4
  import { endTmuxSession } from "../tmux/end";
5
5
  import { releaseOnEnd, shutTaskSimulators } from "../simulator";
6
6
  import { forgetPlanWait } from "../models/plan-step";
7
+ import { kickWaits } from "../bus/waits/kick";
7
8
 
8
9
  type Agent = Pick<Session, "id" | "host_id" | "tmux_name"> &
9
10
  Partial<Pick<Session, "worktree_path" | "branch_name">>;
@@ -20,4 +21,6 @@ export async function endSessionAgent(s: Agent): Promise<void> {
20
21
  shutTaskSimulators(s);
21
22
  void waitForExit(s.id).catch(() => {});
22
23
  await endTmuxSession(s.host_id, s.tmux_name);
24
+ // Whoever waits on it is told now, not at the next sweep.
25
+ kickWaits();
23
26
  }
@@ -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.700",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
package/scripts/agent-os CHANGED
@@ -83,6 +83,8 @@ case "${1:-}" in
83
83
  pair) node "$SCRIPT_DIR/pair.mjs" "$PORT" ;;
84
84
  connect) shift; (cd "$(dirname "$SCRIPT_DIR")" && npx tsx lib/connect/cli.ts "$@") ;;
85
85
  disconnect) (cd "$(dirname "$SCRIPT_DIR")" && npx tsx lib/connect/cli.ts --off) ;;
86
+ link) shift; (cd "$(dirname "$SCRIPT_DIR")" && npx tsx lib/peer/cli.ts "$PORT" "$@") ;;
87
+ unlink) shift; (cd "$(dirname "$SCRIPT_DIR")" && npx tsx lib/peer/cli.ts "$PORT" --unlink "$@") ;;
86
88
  passkeys) shift; (cd "$(dirname "$SCRIPT_DIR")" && npx tsx lib/security/passkeys-cli.ts "$@") ;;
87
89
  logs) cmd_logs ;;
88
90
  update) cmd_update ;;
@@ -558,6 +558,8 @@ cmd_help() {
558
558
  echo " pair Add a phone, tablet or laptop (prints a QR code)"
559
559
  echo " connect Turn on AgentOS Connect for this machine (in development)"
560
560
  echo " disconnect Turn Connect off; the tunnel closes within seconds"
561
+ echo " link Link machines on your Connect account (link <name>, link accept <code>)"
562
+ echo " unlink Unlink a machine linked through Connect"
561
563
  echo " passkeys reset Revoke every passkey (recovery; asks you to confirm)"
562
564
  echo " logs Tail server logs"
563
565
  echo " update Update to latest version"
package/server.ts CHANGED
@@ -14,6 +14,7 @@ import { setStartGate } from "./lib/stacks/tick";
14
14
  import { stackStartGate } from "./lib/orchestrator/brakes";
15
15
  import { buildId } from "./lib/build";
16
16
  import { startOrchestratorWatcher } from "./lib/orchestrator/watcher";
17
+ import { startWaitWatcher } from "./lib/bus/waits/tick";
17
18
  import { installErrorCounter } from "./lib/health/errors";
18
19
  import { recordBoot } from "./lib/health/boots";
19
20
  import { realDeps, schedulesEnabled, startScheduler } from "./lib/schedules";
@@ -47,6 +48,7 @@ import {
47
48
  refuseNextInternal,
48
49
  } from "./lib/security/next-internals";
49
50
  import { lanEnabled } from "./lib/security/network-settings";
51
+ import { handlePeerRpc, isPeerRpc, rpcDeps } from "./lib/peer/rpc-server";
50
52
  import { startConnect } from "./lib/connect/serve";
51
53
  import { serverPort } from "./lib/connect/config";
52
54
  import { startUpdateChecks, updateChecksEnabled } from "./lib/updates/check";
@@ -150,6 +152,7 @@ app.prepare().then(async () => {
150
152
  .map((h) => h.trim().toLowerCase())
151
153
  .filter(Boolean);
152
154
  const auth = authPolicy();
155
+ const peerRpc = rpcDeps(port);
153
156
  const header = (v: string | string[] | undefined) =>
154
157
  Array.isArray(v) ? v[0] : v;
155
158
 
@@ -177,6 +180,22 @@ app.prepare().then(async () => {
177
180
  if (refuseNextInternal(req, res)) return;
178
181
  if (!gateRequest(req, res, auth)) return;
179
182
  if (demo && !gateDemoRequest(req, res)) return;
183
+ // A linked machine's envelope: verified, then re-issued to loopback.
184
+ if (isPeerRpc(req)) {
185
+ if (demo) {
186
+ res.statusCode = 404;
187
+ res.end();
188
+ return;
189
+ }
190
+ try {
191
+ await handlePeerRpc(req, res, peerRpc);
192
+ } catch (err) {
193
+ console.error("peer rpc failed:", err);
194
+ if (!res.headersSent) res.statusCode = 500;
195
+ res.end();
196
+ }
197
+ return;
198
+ }
180
199
  try {
181
200
  await compressJson(req, res);
182
201
  const parsedUrl = parse(req.url!, true);
@@ -458,6 +477,8 @@ app.prepare().then(async () => {
458
477
  if (process.env.AGENTOS_STACKS !== "off") startStackWatcher();
459
478
  // Each workspace's orchestrator hears about its sessions as events.
460
479
  if (process.env.AGENTOS_ORCHESTRATOR !== "off") startOrchestratorWatcher();
480
+ // Sessions waiting on each other (aos send --await, aos wait).
481
+ startWaitWatcher();
461
482
  // Schedules tick here, once a minute, and nowhere else.
462
483
  if (schedulesEnabled(process.env)) startScheduler(realDeps);
463
484
  // Looking for a newer version, and running an update only when one is