@saadnvd1/agent-os 0.3.499 → 0.3.501

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 (47) hide show
  1. package/README.md +70 -0
  2. package/app/api/usage/rollups/route.ts +36 -0
  3. package/components/Devices/ConnectCard.tsx +10 -12
  4. package/components/Tasks/TaskRow.tsx +2 -0
  5. package/components/Usage/RatchetView.tsx +249 -0
  6. package/components/Usage/UsageDialog.tsx +7 -1
  7. package/components/Usage/WindowCard.tsx +2 -1
  8. package/data/usage/index.ts +28 -0
  9. package/lib/chat/drivers/claude-mapper.ts +1 -0
  10. package/lib/chat/drivers/claude.ts +46 -0
  11. package/lib/chat/events.ts +3 -0
  12. package/lib/chat/worker/host.ts +21 -0
  13. package/lib/chat/worker/protocol.ts +8 -1
  14. package/lib/connect/cli.ts +51 -72
  15. package/lib/connect/cloudflared.ts +172 -0
  16. package/lib/connect/config.ts +75 -26
  17. package/lib/connect/control-api.ts +107 -48
  18. package/lib/connect/enrol.ts +33 -107
  19. package/lib/connect/serve.ts +177 -94
  20. package/lib/connect/supervise.ts +281 -0
  21. package/lib/db/migrations.ts +72 -0
  22. package/lib/security/auth.ts +20 -4
  23. package/lib/security/gate.ts +8 -5
  24. package/lib/security/reach.ts +1 -1
  25. package/lib/tasks/index.ts +8 -0
  26. package/lib/usage/calls.ts +284 -0
  27. package/lib/usage/check-runs.ts +40 -19
  28. package/lib/usage/config.ts +56 -0
  29. package/lib/usage/nightly.ts +157 -0
  30. package/lib/usage/quality.ts +239 -0
  31. package/lib/usage/ratchet.ts +275 -0
  32. package/lib/usage/rate-limits.ts +192 -0
  33. package/lib/usage/rollup.ts +90 -0
  34. package/lib/usage/span.ts +152 -0
  35. package/lib/usage/transcript-import.ts +226 -0
  36. package/lib/usage/weights.ts +64 -0
  37. package/lib/usage/windows.ts +27 -2
  38. package/package.json +2 -4
  39. package/server.ts +7 -4
  40. package/stores/usageUi.ts +3 -3
  41. package/lib/connect/cert-check.ts +0 -32
  42. package/lib/connect/cloudflare-dns.ts +0 -56
  43. package/lib/connect/frames.ts +0 -72
  44. package/lib/connect/identity.ts +0 -98
  45. package/lib/connect/mux.ts +0 -110
  46. package/lib/connect/stream.ts +0 -106
  47. package/lib/connect/tunnel.ts +0 -98
@@ -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(
@@ -1,25 +1,13 @@
1
1
  /**
2
- * Turning Connect on for this machine. Everything secret is generated here
3
- * and written only to the Connect folder (owner-only): the Ed25519 key that
4
- * proves the machine to the relay, and the TLS key for <id>.<domain> that
5
- * phones complete TLS with. Neither is ever sent anywhere; the certificate
6
- * authority only sees a signing request.
2
+ * Turning Connect on for this machine. The Ed25519 key that proves the
3
+ * machine to the Connect service is generated here and written only to the
4
+ * Connect folder (owner-only); only its public half is ever sent.
7
5
  */
8
6
 
9
7
  import crypto from "crypto";
10
8
  import fs from "fs";
11
9
  import path from "path";
12
- import acme from "acme-client";
13
- import { connectDir, type ConnectConfig } from "./config";
14
- import { generateMachineKey } from "./identity";
15
-
16
- const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
17
-
18
- export function newMachineId(): string {
19
- return Array.from(crypto.randomBytes(8), (b) => ID_ALPHABET[b % 36]).join("");
20
- }
21
-
22
- const SECRETS = ["machine.key", "tls.key", "acme-account.key"];
10
+ import { connectDir, saveConnect, type ConnectConfig } from "./config";
23
11
 
24
12
  const write = (dir: string, file: string, data: string) => {
25
13
  fs.writeFileSync(path.join(dir, file), data, { mode: 0o600 });
@@ -30,110 +18,48 @@ const write = (dir: string, file: string, data: string) => {
30
18
  function lockDown(dir: string) {
31
19
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
32
20
  fs.chmodSync(dir, 0o700);
33
- for (const f of SECRETS) {
34
- if (fs.existsSync(path.join(dir, f)))
35
- fs.chmodSync(path.join(dir, f), 0o600);
36
- }
21
+ if (fs.existsSync(path.join(dir, "machine.key")))
22
+ fs.chmodSync(path.join(dir, "machine.key"), 0o600);
37
23
  }
38
24
 
39
- export interface Enrolment {
40
- config: ConnectConfig;
41
- machinePublicKey: string;
42
- csr: Buffer;
25
+ export interface MachineKeys {
26
+ publicKey: string;
27
+ privateKey: string;
43
28
  }
44
29
 
45
- /** Who names this machine: the Connect service (from a link code), or local (operators). */
46
- export type Register = (publicKey: string) => Promise<ConnectConfig>;
30
+ /** Registers the public key with the Connect service; answers id and name. */
31
+ export type Register = (keys: MachineKeys) => Promise<ConnectConfig>;
47
32
 
48
- /**
49
- * Creates the machine's identity and TLS key, or reuses them if present.
50
- * The machine key comes first: the Connect service registers its public
51
- * half and answers with the machine's id and address.
52
- */
53
- export async function enrol(opts: {
54
- domain: string;
55
- relayUrl: string;
56
- dir?: string;
57
- register?: Register;
58
- }): Promise<Enrolment> {
59
- const dir = opts.dir ?? connectDir();
33
+ /** The machine key, made on first use and reused after. */
34
+ export function machineKeys(dir = connectDir()): MachineKeys {
60
35
  lockDown(dir);
61
36
  if (!fs.existsSync(path.join(dir, "machine.key"))) {
62
- write(dir, "machine.key", generateMachineKey().privateKey);
63
- }
64
- const publicKey = crypto
65
- .createPublicKey(fs.readFileSync(path.join(dir, "machine.key"), "utf8"))
66
- .export({ type: "spki", format: "pem" })
67
- .toString();
68
- const configPath = path.join(dir, "connect.json");
69
- let config: ConnectConfig;
70
- if (fs.existsSync(configPath)) {
71
- config = JSON.parse(fs.readFileSync(configPath, "utf8")) as ConnectConfig;
72
- } else {
73
- if (opts.register) {
74
- config = await opts.register(publicKey);
75
- } else {
76
- const machineId = newMachineId();
77
- config = {
78
- machineId,
79
- hostname: `${machineId}.${opts.domain}`,
80
- relayUrl: opts.relayUrl,
81
- };
82
- }
83
- // Saved before anything else can fail: the link code is single-use, so
84
- // a rerun must resume with this id rather than ask for a new code.
85
- write(dir, "connect.json", JSON.stringify(config, null, 2));
86
- }
87
- if (!fs.existsSync(path.join(dir, "tls.key"))) {
37
+ const { privateKey } = crypto.generateKeyPairSync("ed25519");
88
38
  write(
89
39
  dir,
90
- "tls.key",
91
- (await acme.crypto.createPrivateEcdsaKey()).toString()
40
+ "machine.key",
41
+ privateKey.export({ type: "pkcs8", format: "pem" }).toString()
92
42
  );
93
43
  }
94
- const machinePublicKey = publicKey;
95
- const [, csr] = await acme.crypto.createCsr(
96
- { commonName: config.hostname, altNames: [config.hostname] },
97
- fs.readFileSync(path.join(dir, "tls.key"))
98
- );
99
- return { config, machinePublicKey, csr };
100
- }
101
-
102
- /** Where DNS-01 TXT records get written: the control plane's broker, or (dev) a DNS API. */
103
- export interface DnsChallenge {
104
- set(name: string, value: string): Promise<void>;
105
- clear(name: string, value: string): Promise<void>;
44
+ const privateKey = fs.readFileSync(path.join(dir, "machine.key"), "utf8");
45
+ const publicKey = crypto
46
+ .createPublicKey(privateKey)
47
+ .export({ type: "spki", format: "pem" })
48
+ .toString();
49
+ return { publicKey, privateKey };
106
50
  }
107
51
 
108
- /** Gets a certificate for the CSR over ACME DNS-01 and stores it as tls.crt. */
109
- export async function certify(opts: {
110
- csr: Buffer;
111
- dns: DnsChallenge;
52
+ /**
53
+ * Creates the machine's identity (or reuses it) and registers it. The
54
+ * service's answer is saved before anything else can fail: the link code
55
+ * is single-use, so a rerun must resume with this id.
56
+ */
57
+ export async function enrol(opts: {
58
+ register: Register;
112
59
  dir?: string;
113
- directoryUrl?: string;
114
- }): Promise<void> {
60
+ }): Promise<ConnectConfig> {
115
61
  const dir = opts.dir ?? connectDir();
116
- lockDown(dir);
117
- const accountKeyPath = path.join(dir, "acme-account.key");
118
- if (!fs.existsSync(accountKeyPath)) {
119
- write(
120
- dir,
121
- "acme-account.key",
122
- (await acme.crypto.createPrivateKey()).toString()
123
- );
124
- }
125
- const client = new acme.Client({
126
- directoryUrl: opts.directoryUrl ?? acme.directory.letsencrypt.production,
127
- accountKey: fs.readFileSync(accountKeyPath),
128
- });
129
- const cert = await client.auto({
130
- csr: opts.csr,
131
- termsOfServiceAgreed: true,
132
- challengePriority: ["dns-01"],
133
- challengeCreateFn: async (authz, _c, keyAuth) =>
134
- opts.dns.set(`_acme-challenge.${authz.identifier.value}`, keyAuth),
135
- challengeRemoveFn: async (authz, _c, keyAuth) =>
136
- opts.dns.clear(`_acme-challenge.${authz.identifier.value}`, keyAuth),
137
- });
138
- write(dir, "tls.crt", cert);
62
+ const config = await opts.register(machineKeys(dir));
63
+ saveConnect(config, dir);
64
+ return config;
139
65
  }