@saadnvd1/agent-os 0.3.499 → 0.3.502

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 (64) hide show
  1. package/README.md +77 -0
  2. package/app/api/usage/rollups/route.ts +36 -0
  3. package/components/Chat/Activity.test.ts +22 -0
  4. package/components/Chat/Activity.tsx +17 -5
  5. package/components/Chat/ChatPanel.tsx +7 -1
  6. package/components/Devices/ConnectCard.tsx +10 -12
  7. package/components/SessionList/RowParts.tsx +13 -4
  8. package/components/SessionList/SessionList.types.ts +1 -0
  9. package/components/Tasks/TaskRow.tsx +2 -0
  10. package/components/Usage/RatchetView.tsx +249 -0
  11. package/components/Usage/UsageDialog.tsx +7 -1
  12. package/components/Usage/WindowCard.tsx +2 -1
  13. package/components/views/types.ts +2 -0
  14. package/data/chat/useChat.ts +7 -0
  15. package/data/usage/index.ts +28 -0
  16. package/lib/chat/activity.ts +24 -2
  17. package/lib/chat/drivers/claude-mapper.ts +8 -0
  18. package/lib/chat/drivers/claude.ts +46 -0
  19. package/lib/chat/drivers/codex-mapper.ts +5 -1
  20. package/lib/chat/drivers/pi-mapper.ts +4 -0
  21. package/lib/chat/events.ts +15 -1
  22. package/lib/chat/registry.ts +8 -1
  23. package/lib/chat/runner.ts +27 -0
  24. package/lib/chat/worker/host.ts +47 -0
  25. package/lib/chat/worker/main.ts +1 -0
  26. package/lib/chat/worker/protocol.ts +14 -1
  27. package/lib/connect/cli.ts +51 -72
  28. package/lib/connect/cloudflared.ts +172 -0
  29. package/lib/connect/config.ts +75 -26
  30. package/lib/connect/control-api.ts +107 -48
  31. package/lib/connect/enrol.ts +33 -107
  32. package/lib/connect/serve.ts +177 -94
  33. package/lib/connect/supervise.ts +281 -0
  34. package/lib/db/migrations.ts +72 -0
  35. package/lib/orchestrator/describe.ts +1 -1
  36. package/lib/orchestrator/facts.ts +5 -1
  37. package/lib/security/auth.ts +20 -4
  38. package/lib/security/gate.ts +8 -5
  39. package/lib/security/reach.ts +1 -1
  40. package/lib/sidebar/shelves.ts +7 -0
  41. package/lib/status/collect.ts +7 -1
  42. package/lib/tasks/index.ts +8 -0
  43. package/lib/usage/calls.ts +284 -0
  44. package/lib/usage/check-runs.ts +40 -19
  45. package/lib/usage/config.ts +56 -0
  46. package/lib/usage/nightly.ts +157 -0
  47. package/lib/usage/quality.ts +239 -0
  48. package/lib/usage/ratchet.ts +275 -0
  49. package/lib/usage/rate-limits.ts +192 -0
  50. package/lib/usage/rollup.ts +90 -0
  51. package/lib/usage/span.ts +152 -0
  52. package/lib/usage/transcript-import.ts +226 -0
  53. package/lib/usage/weights.ts +64 -0
  54. package/lib/usage/windows.ts +27 -2
  55. package/package.json +2 -4
  56. package/server.ts +7 -4
  57. package/stores/usageUi.ts +3 -3
  58. package/lib/connect/cert-check.ts +0 -32
  59. package/lib/connect/cloudflare-dns.ts +0 -56
  60. package/lib/connect/frames.ts +0 -72
  61. package/lib/connect/identity.ts +0 -98
  62. package/lib/connect/mux.ts +0 -110
  63. package/lib/connect/stream.ts +0 -106
  64. package/lib/connect/tunnel.ts +0 -98
@@ -1,36 +1,34 @@
1
1
  /**
2
2
  * agent-os connect — turn Connect on for this machine.
3
3
  *
4
- * agent-os connect [--name "Studio Mac"] enrol: asks for the code from runagentos.com
4
+ * agent-os connect [--name "Studio Mac"] link: asks for the code from runagentos.com
5
5
  * (or AGENTOS_CONNECT_CODE, or piped on stdin; --code works but lands in shell history)
6
- * agent-os connect --renew get a fresh certificate
7
- * agent-os connect --on | --off (also: agent-os disconnect)
6
+ * agent-os connect already linked: links its tunnel again
7
+ * agent-os connect --on | --off (also: agent-os disconnect)
8
8
  *
9
- * The machine key and the TLS key are made here and never leave this
10
- * machine. The certificate comes from Let's Encrypt over DNS-01, through
11
- * the Connect service's broker (which only touches this machine's name).
12
- * Operators may instead use a zone-scoped token: CONNECT_DNS_TOKEN with
13
- * AGENTOS_CONNECT_OPERATOR=1.
9
+ * The machine key is made here and never leaves this machine. The Connect
10
+ * service makes the machine's Cloudflare tunnel; a running AgentOS fetches
11
+ * a fresh connector token and runs cloudflared itself.
14
12
  */
15
13
 
16
- import fs from "fs";
17
14
  import os from "os";
18
- import path from "path";
19
15
  import readline from "readline/promises";
20
- import { connectDir, setConnectEnabled } from "./config";
21
- import { certify, enrol, type DnsChallenge } from "./enrol";
22
- import { cloudflareDns } from "./cloudflare-dns";
23
16
  import {
24
- brokerDns,
17
+ connectDir,
18
+ connectTunnelPort,
19
+ readConnect,
20
+ saveConnect,
21
+ serverPort,
22
+ setConnectEnabled,
23
+ } from "./config";
24
+ import { enrol } from "./enrol";
25
+ import {
25
26
  checkApiUrl,
26
27
  DEFAULT_API,
27
28
  registerWithCode,
29
+ requestTunnel,
28
30
  } from "./control-api";
29
31
 
30
- const ZONE = "runagentos.com";
31
- const domain = process.env.CONNECT_MACHINE_DOMAIN || `on.${ZONE}`;
32
- const relayUrl = process.env.CONNECT_RELAY_URL || `wss://relay.${ZONE}`;
33
-
34
32
  const arg = (flag: string) => {
35
33
  const i = process.argv.indexOf(flag);
36
34
  return i > -1 ? process.argv[i + 1] : undefined;
@@ -67,65 +65,46 @@ async function main() {
67
65
  }
68
66
 
69
67
  const dir = connectDir();
70
- const token = process.env.CONNECT_DNS_TOKEN;
71
- const isOperator = process.env.AGENTOS_CONNECT_OPERATOR === "1";
72
- const operator = !!token && isOperator;
73
- const apiUrl = checkApiUrl(
74
- process.env.CONNECT_API_URL || DEFAULT_API,
75
- isOperator
76
- );
77
- if (token && !operator) {
78
- // A zone-wide DNS token can rewrite any machine's name. Only the operator's
79
- // own machines may use one; everyone else enrols through the Connect service.
80
- throw new Error(
81
- "CONNECT_DNS_TOKEN needs AGENTOS_CONNECT_OPERATOR=1 (operators only)"
68
+ const operator = process.env.AGENTOS_CONNECT_OPERATOR === "1";
69
+ const port = connectTunnelPort(serverPort());
70
+ if (!port) throw new Error("AGENTOS_CONNECT_PORT isn't a usable port");
71
+
72
+ const read = readConnect(dir);
73
+ let config;
74
+ if (read.ok) {
75
+ // Linked already: the signed call keeps its id, name and tunnel.
76
+ config = read.files.config;
77
+ checkApiUrl(config.apiUrl, operator);
78
+ const { hostname } = await requestTunnel(
79
+ config,
80
+ read.files.machineKey,
81
+ port
82
82
  );
83
- }
84
- const enrolled = fs.existsSync(path.join(dir, "connect.json"));
85
- const code = enrolled
86
- ? undefined
87
- : operator
88
- ? process.env.AGENTOS_CONNECT_CODE || arg("--code")
89
- : await linkCode();
90
- if (!enrolled && !code && !operator) {
91
- throw new Error(
92
- "get a code from runagentos.com (Connect a machine), then run agent-os connect and paste it"
83
+ config = { ...config, hostname };
84
+ saveConnect(config, dir);
85
+ } else {
86
+ const apiUrl = checkApiUrl(
87
+ process.env.CONNECT_API_URL || DEFAULT_API,
88
+ operator
93
89
  );
90
+ const code = await linkCode();
91
+ if (!code)
92
+ throw new Error(
93
+ "get a code from runagentos.com (Connect a machine), then run agent-os connect and paste it"
94
+ );
95
+ config = await enrol({
96
+ dir,
97
+ register: registerWithCode(
98
+ apiUrl,
99
+ code,
100
+ arg("--name") || os.hostname(),
101
+ port
102
+ ),
103
+ });
94
104
  }
95
-
96
- const { config, csr } = await enrol({
97
- domain,
98
- relayUrl,
99
- register: code
100
- ? registerWithCode(apiUrl, code, arg("--name") || os.hostname(), domain)
101
- : undefined,
102
- });
103
105
  console.log(`\n Machine ${config.machineId}`);
104
106
  console.log(` Address https://${config.hostname}`);
105
- console.log(` Keys ${dir} (private keys never leave this machine)`);
106
-
107
- if (
108
- fs.existsSync(path.join(dir, "tls.crt")) &&
109
- !process.argv.includes("--renew")
110
- ) {
111
- console.log(" Cert already present (--renew to get a new one)");
112
- } else {
113
- const dns: DnsChallenge = config.apiUrl
114
- ? brokerDns(
115
- config,
116
- fs.readFileSync(path.join(dir, "machine.key"), "utf8")
117
- )
118
- : operator
119
- ? cloudflareDns(token!, ZONE)
120
- : (() => {
121
- throw new Error(
122
- "no way to prove this name: enrol with a link code"
123
- );
124
- })();
125
- console.log(" Cert asking Let's Encrypt (DNS-01)...");
126
- await certify({ csr, dns });
127
- console.log(" Cert saved");
128
- }
107
+ console.log(` Keys ${dir} (the private key never leaves this machine)`);
129
108
  console.log(
130
109
  "\n AgentOS picks this up within seconds; the Devices page shows Connect.\n"
131
110
  );
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The cloudflared that carries Connect's tunnel: one pinned release,
3
+ * downloaded into ~/.agent-os/connect/bin and checked against its SHA-256
4
+ * before it is ever made executable or run. Never one from PATH.
5
+ */
6
+
7
+ import crypto from "crypto";
8
+ import { execFile } from "child_process";
9
+ import fs from "fs";
10
+ import os from "os";
11
+ import path from "path";
12
+
13
+ export const CLOUDFLARED_VERSION = "2026.10.0";
14
+
15
+ export interface Pin {
16
+ asset: string;
17
+ /** SHA-256 of the release asset as downloaded. */
18
+ assetSha256: string;
19
+ /** SHA-256 of the cloudflared binary (the asset itself unless it's a .tgz). */
20
+ binarySha256: string;
21
+ }
22
+
23
+ // From the release's asset digests, checked by downloading each one; the
24
+ // darwin binaries' hashes are of the file inside each .tgz.
25
+ const PINS: Record<string, Pin> = {
26
+ "darwin-arm64": {
27
+ asset: "cloudflared-darwin-arm64.tgz",
28
+ assetSha256:
29
+ "a2f79ff7b9420aa537d74af239f376da170bbabeb529aec416002adac6a72e70",
30
+ binarySha256:
31
+ "72edfd3eea463aef4d5cb89e2e209cecb048cc756c2b01915de2e0ad7cb39830",
32
+ },
33
+ "darwin-x64": {
34
+ asset: "cloudflared-darwin-amd64.tgz",
35
+ assetSha256:
36
+ "903845b81828c8cb3c5d13d816a2de71c06a3da5785469df8eb0e1b736d92f9f",
37
+ binarySha256:
38
+ "0560c9ab7281ac3f746055323623ed23bc0405b6dab9400474020cba33a978da",
39
+ },
40
+ "linux-x64": {
41
+ asset: "cloudflared-linux-amd64",
42
+ assetSha256:
43
+ "d33ff2d14475178d2012c2c56beba87389ac5ded27649519f198a7d3134a99db",
44
+ binarySha256:
45
+ "d33ff2d14475178d2012c2c56beba87389ac5ded27649519f198a7d3134a99db",
46
+ },
47
+ "linux-arm64": {
48
+ asset: "cloudflared-linux-arm64",
49
+ assetSha256:
50
+ "e6422b9d4f72d3194bc5a38676f13667c06666523217b842a877d72a80b5ac08",
51
+ binarySha256:
52
+ "e6422b9d4f72d3194bc5a38676f13667c06666523217b842a877d72a80b5ac08",
53
+ },
54
+ };
55
+
56
+ export function pinFor(
57
+ platform: string = process.platform,
58
+ arch: string = os.arch()
59
+ ): Pin | null {
60
+ return PINS[`${platform}-${arch}`] ?? null;
61
+ }
62
+
63
+ const RELEASES = "https://github.com/cloudflare/cloudflared/releases/download";
64
+ // The real assets are ~40 MB; nothing legitimate comes near this.
65
+ const MAX_BYTES = 150 * 1024 * 1024;
66
+ const DOWNLOAD_MS = 5 * 60_000;
67
+
68
+ const sha256 = (data: Buffer) =>
69
+ crypto.createHash("sha256").update(data).digest("hex");
70
+
71
+ // Streamed: the binary is ~40 MB and this runs on every Connect start.
72
+ async function sha256File(file: string): Promise<string | null> {
73
+ try {
74
+ const hash = crypto.createHash("sha256");
75
+ for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
76
+ return hash.digest("hex");
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ async function download(url: string, fetchImpl: typeof fetch) {
83
+ // Covers reading the body too, so a stalled download can't hang a start.
84
+ const res = await fetchImpl(url, {
85
+ signal: AbortSignal.timeout(DOWNLOAD_MS),
86
+ });
87
+ if (!res.ok || !res.body)
88
+ throw new Error(`downloading cloudflared failed: ${res.status}`);
89
+ const chunks: Buffer[] = [];
90
+ let size = 0;
91
+ const reader = res.body.getReader();
92
+ for (;;) {
93
+ const { done, value } = await reader.read();
94
+ if (done) break;
95
+ size += value.byteLength;
96
+ if (size > MAX_BYTES) {
97
+ await reader.cancel();
98
+ throw new Error("downloading cloudflared failed: too large");
99
+ }
100
+ chunks.push(Buffer.from(value));
101
+ }
102
+ return Buffer.concat(chunks);
103
+ }
104
+
105
+ const untar = (archive: string, into: string) =>
106
+ new Promise<void>((resolve, reject) =>
107
+ execFile(
108
+ "tar",
109
+ ["-xzf", archive, "-C", into, "cloudflared"],
110
+ { timeout: 60_000 },
111
+ (err) =>
112
+ err
113
+ ? reject(new Error("unpacking cloudflared failed"))
114
+ : resolve(undefined)
115
+ )
116
+ );
117
+
118
+ /**
119
+ * The path of a verified cloudflared, downloading it first if needed. The
120
+ * binary on disk is re-hashed every time, so a swapped file is replaced
121
+ * rather than run.
122
+ */
123
+ export async function ensureCloudflared(opts: {
124
+ dir: string;
125
+ pin?: Pin | null;
126
+ fetchImpl?: typeof fetch;
127
+ baseUrl?: string;
128
+ }): Promise<string> {
129
+ const pin = opts.pin === undefined ? pinFor() : opts.pin;
130
+ if (!pin)
131
+ throw new Error(
132
+ `cloudflared isn't available for ${process.platform}-${os.arch()}`
133
+ );
134
+ const binDir = path.join(opts.dir, "bin");
135
+ const target = path.join(binDir, `cloudflared-${CLOUDFLARED_VERSION}`);
136
+ if ((await sha256File(target)) === pin.binarySha256) {
137
+ fs.chmodSync(target, 0o700);
138
+ return target;
139
+ }
140
+ fs.mkdirSync(binDir, { recursive: true, mode: 0o700 });
141
+ fs.chmodSync(binDir, 0o700);
142
+ const data = await download(
143
+ `${opts.baseUrl ?? RELEASES}/${CLOUDFLARED_VERSION}/${pin.asset}`,
144
+ opts.fetchImpl ?? fetch
145
+ );
146
+ if (sha256(data) !== pin.assetSha256)
147
+ throw new Error("cloudflared download doesn't match its pinned SHA-256");
148
+
149
+ const work = fs.mkdtempSync(path.join(binDir, ".download-"));
150
+ try {
151
+ let binary = data;
152
+ if (pin.asset.endsWith(".tgz")) {
153
+ const archive = path.join(work, "cloudflared.tgz");
154
+ fs.writeFileSync(archive, data, { mode: 0o600 });
155
+ await untar(archive, work);
156
+ binary = fs.readFileSync(path.join(work, "cloudflared"));
157
+ }
158
+ if (sha256(binary) !== pin.binarySha256)
159
+ throw new Error("cloudflared binary doesn't match its pinned SHA-256");
160
+ // Written owner-only and not executable; made executable only once
161
+ // it's known to be the pinned binary, then moved into place.
162
+ const staged = path.join(work, "staged");
163
+ fs.writeFileSync(staged, binary, { mode: 0o600 });
164
+ if ((await sha256File(staged)) !== pin.binarySha256)
165
+ throw new Error("cloudflared binary doesn't match its pinned SHA-256");
166
+ fs.chmodSync(staged, 0o700);
167
+ fs.renameSync(staged, target);
168
+ } finally {
169
+ fs.rmSync(work, { recursive: true, force: true });
170
+ }
171
+ return target;
172
+ }
@@ -2,9 +2,13 @@
2
2
  * Connect's state on this machine, in ~/.agent-os/connect (or
3
3
  * AGENTOS_CONNECT_DIR). No connect.json means Connect is off.
4
4
  *
5
- * connect.json {machineId, hostname, relayUrl, relayServername?, relayCa?}
6
- * machine.key Ed25519 private key: proves this machine to the relay
7
- * tls.key/.crt the certificate phones see; the key never leaves here
5
+ * connect.json {machineId, hostname, apiUrl, enabled?}
6
+ * machine.key Ed25519 private key: proves this machine to the Connect
7
+ * service, and never leaves here
8
+ * bin/ the pinned cloudflared, checked against its SHA-256
9
+ *
10
+ * The tunnel's connector token is never written down: it's fetched fresh
11
+ * each time the tunnel starts.
8
12
  */
9
13
 
10
14
  import fs from "fs";
@@ -13,22 +17,20 @@ import path from "path";
13
17
 
14
18
  export interface ConnectConfig {
15
19
  machineId: string;
20
+ /** m-<16 hex>.runagentos.com; empty until the service names it. */
16
21
  hostname: string;
17
- relayUrl: string;
18
- /** SNI to present to the relay when relayUrl is an IP (tests). */
19
- relayServername?: string;
20
- /** PEM of a private CA the relay's certificate chains to (tests). */
21
- relayCa?: string;
22
22
  /** The Connect service this machine enrolled with (signed API calls). */
23
- apiUrl?: string;
23
+ apiUrl: string;
24
24
  }
25
25
 
26
26
  export interface ConnectFiles {
27
27
  config: ConnectConfig;
28
28
  machineKey: string;
29
- tls: { key: string; cert: string };
30
29
  }
31
30
 
31
+ /** The only shape of name a machine is reached at. */
32
+ export const MACHINE_HOST = /^m-[0-9a-f]{16}\.runagentos\.com$/;
33
+
32
34
  export const connectDir = () =>
33
35
  process.env.AGENTOS_CONNECT_DIR ||
34
36
  path.join(os.homedir(), ".agent-os", "connect");
@@ -42,35 +44,41 @@ export function readConnect(dir = connectDir()): ConnectRead {
42
44
  const file = (f: string) => path.join(dir, f);
43
45
  if (!fs.existsSync(file("connect.json")))
44
46
  return { ok: false, reason: "not enrolled" };
45
- let config: ConnectConfig & { enabled?: boolean };
47
+ let raw: Partial<ConnectConfig> & { enabled?: boolean };
46
48
  try {
47
- config = JSON.parse(fs.readFileSync(file("connect.json"), "utf8"));
49
+ raw = JSON.parse(fs.readFileSync(file("connect.json"), "utf8")) ?? {};
48
50
  } catch {
49
51
  return { ok: false, reason: `${file("connect.json")} is not valid JSON` };
50
52
  }
51
- if (!config.machineId || !config.hostname || !config.relayUrl) {
53
+ if (typeof raw.machineId !== "string" || !raw.machineId) {
52
54
  return {
53
55
  ok: false,
54
- reason: `${file("connect.json")} is missing machineId, hostname or relayUrl`,
56
+ reason: `${file("connect.json")} is missing machineId`,
55
57
  };
56
58
  }
57
- for (const f of ["machine.key", "tls.key", "tls.crt"]) {
58
- if (!fs.existsSync(file(f)))
59
- return {
60
- ok: false,
61
- reason: `${file(f)} is missing (run agent-os connect)`,
62
- };
59
+ if (typeof raw.apiUrl !== "string" || !raw.apiUrl) {
60
+ return {
61
+ ok: false,
62
+ reason: `${file("connect.json")} has no Connect service (run agent-os connect with a link code)`,
63
+ };
63
64
  }
65
+ if (!fs.existsSync(file("machine.key")))
66
+ return {
67
+ ok: false,
68
+ reason: `${file("machine.key")} is missing (run agent-os connect)`,
69
+ };
70
+ // A name from before the tunnel (or anything else) is dropped: the next
71
+ // start asks the service again.
72
+ const hostname =
73
+ typeof raw.hostname === "string" && MACHINE_HOST.test(raw.hostname)
74
+ ? raw.hostname
75
+ : "";
64
76
  return {
65
77
  ok: true,
66
- enabled: config.enabled !== false,
78
+ enabled: raw.enabled !== false,
67
79
  files: {
68
- config,
80
+ config: { machineId: raw.machineId, hostname, apiUrl: raw.apiUrl },
69
81
  machineKey: fs.readFileSync(file("machine.key"), "utf8"),
70
- tls: {
71
- key: fs.readFileSync(file("tls.key"), "utf8"),
72
- cert: fs.readFileSync(file("tls.crt"), "utf8"),
73
- },
74
82
  },
75
83
  };
76
84
  }
@@ -80,6 +88,23 @@ export function loadConnect(dir = connectDir()): ConnectFiles | null {
80
88
  return r.ok && r.enabled ? r.files : null;
81
89
  }
82
90
 
91
+ /** Writes connect.json owner-only, keeping `enabled` as it was. */
92
+ export function saveConnect(config: ConnectConfig, dir = connectDir()) {
93
+ const p = path.join(dir, "connect.json");
94
+ let enabled: boolean | undefined;
95
+ try {
96
+ enabled = JSON.parse(fs.readFileSync(p, "utf8"))?.enabled;
97
+ } catch {}
98
+ const next = {
99
+ machineId: config.machineId,
100
+ hostname: config.hostname,
101
+ apiUrl: config.apiUrl,
102
+ ...(enabled === false && { enabled }),
103
+ };
104
+ fs.writeFileSync(p, JSON.stringify(next, null, 2), { mode: 0o600 });
105
+ fs.chmodSync(p, 0o600);
106
+ }
107
+
83
108
  /** Turns Connect on or off without touching the keys. */
84
109
  export function setConnectEnabled(
85
110
  enabled: boolean,
@@ -93,3 +118,27 @@ export function setConnectEnabled(
93
118
  });
94
119
  return true;
95
120
  }
121
+
122
+ /** AgentOS's own port, read the way server.ts reads it: `-p <port>`, then PORT. */
123
+ export function serverPort(
124
+ argv: string[] = process.argv,
125
+ env: Partial<NodeJS.ProcessEnv> = process.env
126
+ ): number {
127
+ const i = argv.indexOf("-p");
128
+ return parseInt(
129
+ (i !== -1 ? argv[i + 1] : undefined) || env.PORT || "3011",
130
+ 10
131
+ );
132
+ }
133
+
134
+ /**
135
+ * The loopback port cloudflared delivers tunnel traffic to: AgentOS's port
136
+ * + 433 (3444), or AGENTOS_CONNECT_PORT. Null when it isn't usable.
137
+ */
138
+ export function connectTunnelPort(
139
+ port: number,
140
+ env: Record<string, string | undefined> = process.env
141
+ ): number | null {
142
+ const p = Number(env.AGENTOS_CONNECT_PORT || port + 433);
143
+ return Number.isInteger(p) && p > 0 && p < 65536 && p !== port ? p : null;
144
+ }
@@ -5,8 +5,8 @@
5
5
  */
6
6
 
7
7
  import crypto from "crypto";
8
- import type { ConnectConfig } from "./config";
9
- import type { DnsChallenge, Register } from "./enrol";
8
+ import { MACHINE_HOST, type ConnectConfig } from "./config";
9
+ import type { Register } from "./enrol";
10
10
 
11
11
  export const DEFAULT_API = "https://runagentos.com";
12
12
  export const MACHINE_ID = /^[a-z0-9]{8}$/;
@@ -68,51 +68,107 @@ export function signedHeaders(opts: {
68
68
  };
69
69
  }
70
70
 
71
+ /** A refusal from the Connect service, with its status and answer. */
72
+ export class ApiError extends Error {
73
+ constructor(
74
+ message: string,
75
+ readonly status: number,
76
+ readonly body: Record<string, unknown>
77
+ ) {
78
+ super(message);
79
+ }
80
+ }
81
+
71
82
  async function call(url: string, init: RequestInit): Promise<Response> {
72
83
  // Never follow a redirect: it would replay the link code or the signed
73
84
  // headers to whatever host the response names.
74
- const res = await fetch(url, { ...init, redirect: "error" });
85
+ const res = await fetch(url, {
86
+ ...init,
87
+ redirect: "error",
88
+ signal: AbortSignal.timeout(30_000),
89
+ });
75
90
  if (res.ok) return res;
76
- const body = (await res.json().catch(() => ({}))) as { error?: unknown };
77
- throw new Error(
78
- typeof body.error === "string"
91
+ const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
92
+ throw new ApiError(
93
+ typeof body?.error === "string"
79
94
  ? clean(body.error)
80
- : `${init.method} ${new URL(url).pathname}: ${res.status}`
95
+ : `${init.method} ${new URL(url).pathname}: ${res.status}`,
96
+ res.status,
97
+ body && typeof body === "object" ? body : {}
81
98
  );
82
99
  }
83
100
 
84
- /** Enrolment with a link code from "Connect a machine" in the web app. */
85
- export function registerWithCode(
86
- apiUrl: string,
87
- linkCode: string,
88
- name: string,
89
- domain: string
90
- ): Register {
91
- return async (publicKey) => {
92
- const res = await call(`${apiUrl}/api/v1/machines/enrol`, {
93
- method: "POST",
94
- headers: { "Content-Type": "application/json" },
95
- body: JSON.stringify({
96
- link_code: linkCode,
97
- name,
98
- public_key: publicKey,
99
- }),
100
- });
101
- return { ...enrolled(await res.json(), domain), apiUrl };
102
- };
101
+ export interface Tunnel {
102
+ hostname: string;
103
+ /** The connector token: handed to cloudflared, never stored or logged. */
104
+ token: string;
103
105
  }
104
106
 
105
- /** What the service answered, refused unless it names exactly <id>.<domain>. */
106
- export function enrolled(m: unknown, domain: string): ConnectConfig {
107
+ // Base64 of a small JSON object; anything else isn't a connector token.
108
+ const TOKEN = /^[A-Za-z0-9+/=_-]{32,4096}$/;
109
+
110
+ /** The service's answer, refused unless the name is exactly m-<16 hex>.runagentos.com. */
111
+ export function hostnameFrom(r: Record<string, unknown>): string {
112
+ if (typeof r.hostname !== "string" || !MACHINE_HOST.test(r.hostname))
113
+ throw new Error(
114
+ "the Connect service sent a hostname that isn't m-<16 hex>.runagentos.com"
115
+ );
116
+ return r.hostname;
117
+ }
118
+
119
+ function tokenFrom(r: Record<string, unknown>): string {
120
+ if (typeof r.tunnel_token !== "string" || !TOKEN.test(r.tunnel_token))
121
+ throw new Error("the Connect service sent no usable tunnel token");
122
+ return r.tunnel_token;
123
+ }
124
+
125
+ /** What enrolment answered: the machine's id and name (its token is unused). */
126
+ export function enrolled(m: unknown, apiUrl: string): ConnectConfig {
107
127
  const r = (m ?? {}) as Record<string, unknown>;
108
128
  const id = typeof r.machine_id === "string" ? r.machine_id : "";
109
129
  if (!MACHINE_ID.test(id))
110
130
  throw new Error("the Connect service sent an invalid machine id");
111
- if (r.hostname !== `${id}.${domain}`)
112
- throw new Error(`the Connect service sent a hostname outside ${domain}`);
113
- if (typeof r.relay_url !== "string" || !r.relay_url.startsWith("wss://"))
114
- throw new Error("the Connect service sent a relay URL that isn't wss://");
115
- return { machineId: id, hostname: r.hostname, relayUrl: r.relay_url };
131
+ return { machineId: id, hostname: hostnameFrom(r), apiUrl };
132
+ }
133
+
134
+ /**
135
+ * Enrolment with a link code from "Connect a machine" in the web app. A key
136
+ * the account already has is refused (409, naming its machine): this
137
+ * machine then links again by signing for that id, keeping its name.
138
+ */
139
+ export function registerWithCode(
140
+ apiUrl: string,
141
+ linkCode: string,
142
+ name: string,
143
+ port: number
144
+ ): Register {
145
+ return async ({ publicKey, privateKey }) => {
146
+ try {
147
+ const res = await call(`${apiUrl}/api/v1/machines/enrol`, {
148
+ method: "POST",
149
+ headers: { "Content-Type": "application/json" },
150
+ body: JSON.stringify({
151
+ link_code: linkCode,
152
+ name,
153
+ public_key: publicKey,
154
+ port,
155
+ }),
156
+ });
157
+ return enrolled(await res.json(), apiUrl);
158
+ } catch (err) {
159
+ const id = err instanceof ApiError ? err.body.machine_id : undefined;
160
+ if (
161
+ !(err instanceof ApiError) ||
162
+ err.status !== 409 ||
163
+ typeof id !== "string" ||
164
+ !MACHINE_ID.test(id)
165
+ )
166
+ throw err;
167
+ const config = { machineId: id, hostname: "", apiUrl };
168
+ const { hostname } = await requestTunnel(config, privateKey, port);
169
+ return { ...config, hostname };
170
+ }
171
+ };
116
172
  }
117
173
 
118
174
  function signed(config: ConnectConfig, privateKey: string) {
@@ -137,22 +193,25 @@ function signed(config: ConnectConfig, privateKey: string) {
137
193
  };
138
194
  }
139
195
 
140
- /** The Connect service's DNS broker: it writes only this machine's challenge. */
141
- export function brokerDns(
196
+ /**
197
+ * Links (or relinks) this machine's tunnel to `port` and gets a fresh
198
+ * connector token. Signed, so only the holder of machine.key can ask.
199
+ */
200
+ export async function requestTunnel(
142
201
  config: ConnectConfig,
143
- privateKey: string
144
- ): DnsChallenge {
145
- const send = signed(config, privateKey);
146
- const path = `/api/v1/machines/${config.machineId}/acme-challenge`;
147
- return {
148
- async set(name, value) {
149
- await send("POST", path, { name, value });
150
- await new Promise((r) => setTimeout(r, 15_000)); // let DNS catch up
151
- },
152
- async clear(name, value) {
153
- await send("DELETE", path, { name, value });
154
- },
155
- };
202
+ privateKey: string,
203
+ port: number
204
+ ): Promise<Tunnel> {
205
+ const res = await signed(config, privateKey)(
206
+ "POST",
207
+ `/api/v1/machines/${config.machineId}/tunnel`,
208
+ { port }
209
+ );
210
+ const r = ((await res.json().catch(() => null)) ?? {}) as Record<
211
+ string,
212
+ unknown
213
+ >;
214
+ return { hostname: hostnameFrom(r), token: tokenFrom(r) };
156
215
  }
157
216
 
158
217
  export async function heartbeat(