@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.
- package/README.md +77 -0
- package/app/api/usage/rollups/route.ts +36 -0
- package/components/Chat/Activity.test.ts +22 -0
- package/components/Chat/Activity.tsx +17 -5
- package/components/Chat/ChatPanel.tsx +7 -1
- package/components/Devices/ConnectCard.tsx +10 -12
- package/components/SessionList/RowParts.tsx +13 -4
- package/components/SessionList/SessionList.types.ts +1 -0
- 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/components/views/types.ts +2 -0
- package/data/chat/useChat.ts +7 -0
- package/data/usage/index.ts +28 -0
- package/lib/chat/activity.ts +24 -2
- package/lib/chat/drivers/claude-mapper.ts +8 -0
- package/lib/chat/drivers/claude.ts +46 -0
- package/lib/chat/drivers/codex-mapper.ts +5 -1
- package/lib/chat/drivers/pi-mapper.ts +4 -0
- package/lib/chat/events.ts +15 -1
- package/lib/chat/registry.ts +8 -1
- package/lib/chat/runner.ts +27 -0
- package/lib/chat/worker/host.ts +47 -0
- package/lib/chat/worker/main.ts +1 -0
- package/lib/chat/worker/protocol.ts +14 -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/orchestrator/describe.ts +1 -1
- package/lib/orchestrator/facts.ts +5 -1
- package/lib/security/auth.ts +20 -4
- package/lib/security/gate.ts +8 -5
- package/lib/security/reach.ts +1 -1
- package/lib/sidebar/shelves.ts +7 -0
- package/lib/status/collect.ts +7 -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
package/lib/connect/cli.ts
CHANGED
|
@@ -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"]
|
|
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
|
|
7
|
-
* agent-os connect --on | --off
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
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
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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
|
+
}
|
package/lib/connect/config.ts
CHANGED
|
@@ -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,
|
|
6
|
-
* machine.key Ed25519 private key: proves this machine to the
|
|
7
|
-
*
|
|
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
|
|
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
|
|
47
|
+
let raw: Partial<ConnectConfig> & { enabled?: boolean };
|
|
46
48
|
try {
|
|
47
|
-
|
|
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 (
|
|
53
|
+
if (typeof raw.machineId !== "string" || !raw.machineId) {
|
|
52
54
|
return {
|
|
53
55
|
ok: false,
|
|
54
|
-
reason: `${file("connect.json")} is missing machineId
|
|
56
|
+
reason: `${file("connect.json")} is missing machineId`,
|
|
55
57
|
};
|
|
56
58
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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:
|
|
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
|
|
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(
|