@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.
- package/README.md +70 -0
- package/app/api/usage/rollups/route.ts +36 -0
- package/components/Devices/ConnectCard.tsx +10 -12
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Usage/RatchetView.tsx +249 -0
- package/components/Usage/UsageDialog.tsx +7 -1
- package/components/Usage/WindowCard.tsx +2 -1
- package/data/usage/index.ts +28 -0
- package/lib/chat/drivers/claude-mapper.ts +1 -0
- package/lib/chat/drivers/claude.ts +46 -0
- package/lib/chat/events.ts +3 -0
- package/lib/chat/worker/host.ts +21 -0
- package/lib/chat/worker/protocol.ts +8 -1
- package/lib/connect/cli.ts +51 -72
- package/lib/connect/cloudflared.ts +172 -0
- package/lib/connect/config.ts +75 -26
- package/lib/connect/control-api.ts +107 -48
- package/lib/connect/enrol.ts +33 -107
- package/lib/connect/serve.ts +177 -94
- package/lib/connect/supervise.ts +281 -0
- package/lib/db/migrations.ts +72 -0
- package/lib/security/auth.ts +20 -4
- package/lib/security/gate.ts +8 -5
- package/lib/security/reach.ts +1 -1
- package/lib/tasks/index.ts +8 -0
- package/lib/usage/calls.ts +284 -0
- package/lib/usage/check-runs.ts +40 -19
- package/lib/usage/config.ts +56 -0
- package/lib/usage/nightly.ts +157 -0
- package/lib/usage/quality.ts +239 -0
- package/lib/usage/ratchet.ts +275 -0
- package/lib/usage/rate-limits.ts +192 -0
- package/lib/usage/rollup.ts +90 -0
- package/lib/usage/span.ts +152 -0
- package/lib/usage/transcript-import.ts +226 -0
- package/lib/usage/weights.ts +64 -0
- package/lib/usage/windows.ts +27 -2
- package/package.json +2 -4
- package/server.ts +7 -4
- package/stores/usageUi.ts +3 -3
- package/lib/connect/cert-check.ts +0 -32
- package/lib/connect/cloudflare-dns.ts +0 -56
- package/lib/connect/frames.ts +0 -72
- package/lib/connect/identity.ts +0 -98
- package/lib/connect/mux.ts +0 -110
- package/lib/connect/stream.ts +0 -106
- package/lib/connect/tunnel.ts +0 -98
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import crypto from "crypto";
|
|
8
|
-
import type
|
|
9
|
-
import type {
|
|
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, {
|
|
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
|
|
77
|
-
throw new
|
|
78
|
-
typeof body
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
/**
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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(
|
package/lib/connect/enrol.ts
CHANGED
|
@@ -1,25 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Turning Connect on for this machine.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
csr: Buffer;
|
|
25
|
+
export interface MachineKeys {
|
|
26
|
+
publicKey: string;
|
|
27
|
+
privateKey: string;
|
|
43
28
|
}
|
|
44
29
|
|
|
45
|
-
/**
|
|
46
|
-
export type Register = (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
91
|
-
(
|
|
40
|
+
"machine.key",
|
|
41
|
+
privateKey.export({ type: "pkcs8", format: "pem" }).toString()
|
|
92
42
|
);
|
|
93
43
|
}
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return {
|
|
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
|
-
/**
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
}): Promise<void> {
|
|
60
|
+
}): Promise<ConnectConfig> {
|
|
115
61
|
const dir = opts.dir ?? connectDir();
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
}
|