@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
|
@@ -80,7 +80,14 @@ export type WorkerEvent =
|
|
|
80
80
|
| Exclude<
|
|
81
81
|
DriverEvent,
|
|
82
82
|
{
|
|
83
|
-
type:
|
|
83
|
+
type:
|
|
84
|
+
| "resume_id"
|
|
85
|
+
| "usage"
|
|
86
|
+
| "usage_start"
|
|
87
|
+
| "call_usage"
|
|
88
|
+
| "rate_limit"
|
|
89
|
+
| "suggestion"
|
|
90
|
+
| "at_rest";
|
|
84
91
|
}
|
|
85
92
|
>
|
|
86
93
|
// The agent's guess at the next message, or null once it's stale.
|
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
|
+
}
|