@jiayunxie/aerial 0.1.10 → 0.1.11
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/package.json +1 -1
- package/src/cli.js +31 -2
- package/src/config.js +16 -1
- package/src/doctor.js +20 -0
- package/src/log.js +41 -17
- package/src/server.js +57 -7
- package/src/setup.js +17 -6
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { startDeviceFlow, pollDeviceFlow, readGitHubToken, gitHubTokenSource } from "./auth.js";
|
|
4
|
-
import { ensureApiKey, loadConfig, saveConfig } from "./config.js";
|
|
4
|
+
import { defaultConfig, ensureApiKey, loadConfig, saveConfig } from "./config.js";
|
|
5
5
|
import { configPath } from "./paths.js";
|
|
6
6
|
import { startServer } from "./server.js";
|
|
7
7
|
import { setupClaude, setupCodex, setupStatus, restoreClient, restoreAllClients } from "./setup.js";
|
|
@@ -70,6 +70,20 @@ function requiredArgValue(args, name) {
|
|
|
70
70
|
return value;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
function parseConfigPort(value) {
|
|
74
|
+
const text = String(value).trim();
|
|
75
|
+
if (!/^\d+$/.test(text)) throw new Error("port must be an integer between 1 and 65535");
|
|
76
|
+
const port = Number(text);
|
|
77
|
+
if (port < 1 || port > 65535) throw new Error("port must be an integer between 1 and 65535");
|
|
78
|
+
return port;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseConfigHost(value) {
|
|
82
|
+
const host = String(value).trim().toLowerCase();
|
|
83
|
+
if (host === "127.0.0.1" || host === "localhost" || host === "::1") return host;
|
|
84
|
+
throw new Error("host must be a loopback address: 127.0.0.1, localhost, or ::1");
|
|
85
|
+
}
|
|
86
|
+
|
|
73
87
|
async function selectSetupOptions(target, route, args) {
|
|
74
88
|
const explicitEffort = requiredArgValue(args, "--effort");
|
|
75
89
|
if (explicitEffort !== undefined) assertValidEffort(explicitEffort);
|
|
@@ -524,6 +538,11 @@ async function main() {
|
|
|
524
538
|
}
|
|
525
539
|
|
|
526
540
|
if (command === "config") {
|
|
541
|
+
if (subcommand === "reset") {
|
|
542
|
+
saveConfig(defaultConfig());
|
|
543
|
+
console.log(`Reset Aerial config: ${configPath()}`);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
527
546
|
const config = loadConfig();
|
|
528
547
|
if (subcommand === "set") {
|
|
529
548
|
const [key, value] = rest;
|
|
@@ -531,12 +550,22 @@ async function main() {
|
|
|
531
550
|
if (!["host", "port", "defaultModel", "defaultEffort", "logLevel", "promptCacheRetention", "promptCacheKey"].includes(key)) throw new Error(`Unsupported config key: ${key}`);
|
|
532
551
|
if (key === "promptCacheRetention" && !["in_memory", "24h", "off"].includes(value)) throw new Error("promptCacheRetention must be one of: in_memory, 24h, off");
|
|
533
552
|
if (key === "promptCacheKey" && !value.trim()) throw new Error("promptCacheKey must be auto, off, or a non-empty string");
|
|
553
|
+
if (key === "host") {
|
|
554
|
+
config.host = parseConfigHost(value);
|
|
555
|
+
saveConfig(config);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (key === "port") {
|
|
559
|
+
config.port = parseConfigPort(value);
|
|
560
|
+
saveConfig(config);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
534
563
|
if (key === "defaultEffort") {
|
|
535
564
|
config.defaultEffort = assertValidEffort(value);
|
|
536
565
|
saveConfig(config);
|
|
537
566
|
return;
|
|
538
567
|
}
|
|
539
|
-
config[key] =
|
|
568
|
+
config[key] = value;
|
|
540
569
|
saveConfig(config);
|
|
541
570
|
return;
|
|
542
571
|
}
|
package/src/config.js
CHANGED
|
@@ -19,8 +19,23 @@ export function defaultConfig() {
|
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
function objectConfig(value) {
|
|
23
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function readConfigFileStatus() {
|
|
27
|
+
const file = configPath();
|
|
28
|
+
if (!fs.existsSync(file)) return { file, exists: false, ok: true, value: undefined };
|
|
29
|
+
try {
|
|
30
|
+
return { file, exists: true, ok: true, value: readJsonIfExists(file) };
|
|
31
|
+
} catch (err) {
|
|
32
|
+
return { file, exists: true, ok: false, error: err.message };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
export function loadConfig() {
|
|
23
|
-
const
|
|
37
|
+
const status = readConfigFileStatus();
|
|
38
|
+
const loaded = status.ok ? objectConfig(status.value) : {};
|
|
24
39
|
const envRetention = process.env.AERIAL_PROMPT_CACHE_RETENTION;
|
|
25
40
|
const envCacheKey = process.env.AERIAL_PROMPT_CACHE_KEY;
|
|
26
41
|
const defaults = defaultConfig();
|
package/src/doctor.js
CHANGED
|
@@ -1,14 +1,32 @@
|
|
|
1
1
|
import { setupStatus } from "./setup.js";
|
|
2
2
|
import { serviceStatus } from "./service.js";
|
|
3
3
|
import { computeAppStatus } from "./app-status.js";
|
|
4
|
+
import { readConfigFileStatus } from "./config.js";
|
|
4
5
|
|
|
5
6
|
const REPAIRS = Object.freeze({
|
|
7
|
+
CONFIG_RESET: Object.freeze({ command: "aerial", args: ["config", "reset"] }),
|
|
6
8
|
LOGIN: Object.freeze({ command: "aerial", args: ["login"] }),
|
|
7
9
|
SETUP_CODEX: Object.freeze({ command: "aerial", args: ["setup", "codex"] }),
|
|
8
10
|
SERVICE_INSTALL: Object.freeze({ command: "aerial", args: ["service", "install"] }),
|
|
9
11
|
SERVICE_STATUS_JSON: Object.freeze({ command: "aerial", args: ["service", "status", "--json"] })
|
|
10
12
|
});
|
|
11
13
|
|
|
14
|
+
function buildConfigChecks(configFile) {
|
|
15
|
+
if (!configFile.ok) {
|
|
16
|
+
return [{
|
|
17
|
+
id: "config.file",
|
|
18
|
+
ok: false,
|
|
19
|
+
severity: "fail",
|
|
20
|
+
message: `Aerial config file is not valid JSON: ${configFile.error}. Reset it to defaults, then rerun setup if needed.`,
|
|
21
|
+
repair: REPAIRS.CONFIG_RESET
|
|
22
|
+
}];
|
|
23
|
+
}
|
|
24
|
+
if (!configFile.exists) {
|
|
25
|
+
return [{ id: "config.file", ok: true, severity: "info", message: "Aerial config file has not been created yet." }];
|
|
26
|
+
}
|
|
27
|
+
return [{ id: "config.file", ok: true, severity: "info", message: "Aerial config file is valid JSON." }];
|
|
28
|
+
}
|
|
29
|
+
|
|
12
30
|
function buildAuthChecks(setup) {
|
|
13
31
|
const checks = [];
|
|
14
32
|
const apiKey = setup.auth.api_key;
|
|
@@ -142,10 +160,12 @@ function summarize(ok, checks) {
|
|
|
142
160
|
}
|
|
143
161
|
|
|
144
162
|
export async function doctor({ run, healthFetch, setup, service } = {}) {
|
|
163
|
+
const configFile = readConfigFileStatus();
|
|
145
164
|
const setupOut = setup ?? setupStatus();
|
|
146
165
|
const serviceOut = service ?? await serviceStatus({ ...(run ? { run } : {}), ...(healthFetch ? { healthFetch } : {}) });
|
|
147
166
|
const app = computeAppStatus(setupOut, serviceOut);
|
|
148
167
|
const checks = [
|
|
168
|
+
...buildConfigChecks(configFile),
|
|
149
169
|
...buildAuthChecks(setupOut),
|
|
150
170
|
...buildClientChecks(setupOut),
|
|
151
171
|
...buildServiceChecks(serviceOut)
|
package/src/log.js
CHANGED
|
@@ -4,17 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
const DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
5
5
|
const DEFAULT_ROTATE_KEEP = 3;
|
|
6
6
|
const MAX_LINE_BYTES = 64 * 1024;
|
|
7
|
-
const
|
|
8
|
-
"authorization",
|
|
9
|
-
"token",
|
|
10
|
-
"apiKey",
|
|
11
|
-
"api_key",
|
|
12
|
-
"githubToken",
|
|
13
|
-
"github_token",
|
|
14
|
-
"body",
|
|
15
|
-
"password",
|
|
16
|
-
"secret"
|
|
17
|
-
]);
|
|
7
|
+
const MAX_REDACT_DEPTH = 8;
|
|
18
8
|
|
|
19
9
|
const state = {
|
|
20
10
|
fd: undefined,
|
|
@@ -151,19 +141,53 @@ function writeBuffer(buf) {
|
|
|
151
141
|
}
|
|
152
142
|
}
|
|
153
143
|
|
|
154
|
-
function
|
|
144
|
+
function sensitiveKey(key) {
|
|
145
|
+
const normalized = String(key).toLowerCase().replace(/[_-]/g, "");
|
|
146
|
+
return normalized === "authorization"
|
|
147
|
+
|| normalized === "token"
|
|
148
|
+
|| (normalized.endsWith("token") && !normalized.endsWith("tokens"))
|
|
149
|
+
|| normalized.includes("apikey")
|
|
150
|
+
|| normalized.includes("secret")
|
|
151
|
+
|| normalized.includes("password")
|
|
152
|
+
|| normalized === "body"
|
|
153
|
+
|| normalized.endsWith("body");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function scrubString(value) {
|
|
157
|
+
return value
|
|
158
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/g, "Bearer [redacted]")
|
|
159
|
+
.replace(/\bgithub_pat_[A-Za-z0-9_]+/g, "[redacted]")
|
|
160
|
+
.replace(/\bgh[opsru]_[A-Za-z0-9_]{20,}\b/g, "[redacted]")
|
|
161
|
+
.replace(/\baerial_[A-Za-z0-9_-]{16,}\b/g, "[redacted]")
|
|
162
|
+
.replace(/\bsk-[A-Za-z0-9_-]{20,}\b/g, "[redacted]")
|
|
163
|
+
.replace(/\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[redacted]");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function scrub(value, seen = new WeakSet(), depth = 0) {
|
|
167
|
+
if (typeof value === "string") return scrubString(value);
|
|
168
|
+
if (value === null || typeof value !== "object") return value;
|
|
169
|
+
if (Buffer.isBuffer(value)) return "[redacted buffer]";
|
|
170
|
+
if (depth >= MAX_REDACT_DEPTH) return "[redacted depth]";
|
|
171
|
+
if (seen.has(value)) return "[redacted circular]";
|
|
172
|
+
seen.add(value);
|
|
173
|
+
if (Array.isArray(value)) {
|
|
174
|
+
const out = value.map((item) => scrub(item, seen, depth + 1));
|
|
175
|
+
seen.delete(value);
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
155
178
|
const out = {};
|
|
156
|
-
for (const [k, v] of Object.entries(
|
|
157
|
-
if (
|
|
158
|
-
out[k] = v;
|
|
179
|
+
for (const [k, v] of Object.entries(value)) {
|
|
180
|
+
if (sensitiveKey(k)) continue;
|
|
181
|
+
out[k] = scrub(v, seen, depth + 1);
|
|
159
182
|
}
|
|
183
|
+
seen.delete(value);
|
|
160
184
|
return out;
|
|
161
185
|
}
|
|
162
186
|
|
|
163
187
|
function lineFor(event, fields) {
|
|
164
|
-
const safe =
|
|
188
|
+
const safe = scrub(fields);
|
|
165
189
|
const ts = new Date().toISOString();
|
|
166
|
-
const obj = { ts, event, ...safe };
|
|
190
|
+
const obj = { ts, event: scrubString(event), ...safe };
|
|
167
191
|
const line = JSON.stringify(obj);
|
|
168
192
|
const buf = Buffer.from(`${line}\n`, "utf8");
|
|
169
193
|
if (buf.length <= MAX_LINE_BYTES) return buf;
|
package/src/server.js
CHANGED
|
@@ -5,6 +5,8 @@ import { proxyChatCompletions, proxyMessages, proxyModels, proxyResponses, local
|
|
|
5
5
|
import { readGitHubToken } from "./auth.js";
|
|
6
6
|
import { logEvent } from "./log.js";
|
|
7
7
|
|
|
8
|
+
const MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
9
|
+
|
|
8
10
|
function json(status, body) {
|
|
9
11
|
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
10
12
|
}
|
|
@@ -53,9 +55,45 @@ function nodeRequestToFetch(req, body, signal) {
|
|
|
53
55
|
return new Request(`http://${req.headers.host}${req.url}`, { method: req.method, headers: req.headers, body: body.length ? body : undefined, duplex: "half", signal });
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
|
|
58
|
+
function nodeHeaderObject(headers) {
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
61
|
+
if (Array.isArray(value)) out[key] = value.join(", ");
|
|
62
|
+
else if (value !== undefined) out[key] = String(value);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function publicRoute(req) {
|
|
68
|
+
const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
69
|
+
return (req.method === "GET" && url.pathname === "/health")
|
|
70
|
+
|| (req.method === "GET" && url.pathname === "/")
|
|
71
|
+
|| (req.method === "GET" && url.pathname === "/v1/models");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function bodyTooLarge(limit) {
|
|
75
|
+
const err = new Error(`Request body too large. Limit is ${limit} bytes.`);
|
|
76
|
+
err.statusCode = 413;
|
|
77
|
+
return err;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function declaredContentLength(req) {
|
|
81
|
+
const raw = Array.isArray(req.headers["content-length"]) ? req.headers["content-length"][0] : req.headers["content-length"];
|
|
82
|
+
if (raw === undefined) return undefined;
|
|
83
|
+
const n = Number(raw);
|
|
84
|
+
return Number.isFinite(n) ? n : undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function readBody(req, maxBytes = MAX_BODY_BYTES) {
|
|
88
|
+
const declared = declaredContentLength(req);
|
|
89
|
+
if (declared !== undefined && declared > maxBytes) throw bodyTooLarge(maxBytes);
|
|
57
90
|
const chunks = [];
|
|
58
|
-
|
|
91
|
+
let bytes = 0;
|
|
92
|
+
for await (const chunk of req) {
|
|
93
|
+
bytes += chunk.length;
|
|
94
|
+
if (bytes > maxBytes) throw bodyTooLarge(maxBytes);
|
|
95
|
+
chunks.push(chunk);
|
|
96
|
+
}
|
|
59
97
|
return Buffer.concat(chunks);
|
|
60
98
|
}
|
|
61
99
|
|
|
@@ -78,7 +116,7 @@ async function handle(fetchRequest, runtime = {}) {
|
|
|
78
116
|
return modelsRouteOrUpstreamAuthFailed(fetchRequest);
|
|
79
117
|
}
|
|
80
118
|
|
|
81
|
-
if (!validateLocalAuth(Object.fromEntries(fetchRequest.headers), config)) {
|
|
119
|
+
if (!runtime.localAuthValidated && !validateLocalAuth(Object.fromEntries(fetchRequest.headers), config)) {
|
|
82
120
|
return json(401, { error: { type: "authentication_error", message: "Invalid or missing Aerial API key" } });
|
|
83
121
|
}
|
|
84
122
|
|
|
@@ -118,9 +156,20 @@ export function createServer(runtime = {}) {
|
|
|
118
156
|
res.on("close", () => controller.abort());
|
|
119
157
|
try {
|
|
120
158
|
logEvent("request_start", { method: req.method, path: req.url });
|
|
159
|
+
let localAuthValidated = false;
|
|
160
|
+
if (!publicRoute(req)) {
|
|
161
|
+
const config = loadConfig();
|
|
162
|
+
if (!validateLocalAuth(nodeHeaderObject(req.headers), config)) {
|
|
163
|
+
const fetchResponse = json(401, { error: { type: "authentication_error", message: "Invalid or missing Aerial API key" } });
|
|
164
|
+
await writeNodeResponse(res, fetchResponse, controller.signal);
|
|
165
|
+
logEvent("request_end", { method: req.method, path: req.url, status: fetchResponse.status, ms: Date.now() - started });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
localAuthValidated = true;
|
|
169
|
+
}
|
|
121
170
|
const body = await readBody(req);
|
|
122
171
|
const fetchRequest = nodeRequestToFetch(req, body, controller.signal);
|
|
123
|
-
const fetchResponse = await handle(fetchRequest, runtime);
|
|
172
|
+
const fetchResponse = await handle(fetchRequest, { ...runtime, localAuthValidated });
|
|
124
173
|
await writeNodeResponse(res, fetchResponse, controller.signal);
|
|
125
174
|
logEvent("request_end", { method: req.method, path: req.url, status: fetchResponse.status, ms: Date.now() - started });
|
|
126
175
|
} catch (error) {
|
|
@@ -128,10 +177,11 @@ export function createServer(runtime = {}) {
|
|
|
128
177
|
logEvent("request_aborted", { method: req.method, path: req.url, ms: Date.now() - started });
|
|
129
178
|
return;
|
|
130
179
|
}
|
|
131
|
-
|
|
132
|
-
|
|
180
|
+
const status = error.statusCode || (error.message?.includes("Missing GitHub token") ? 503 : 500);
|
|
181
|
+
logEvent("request_end", { method: req.method, path: req.url, status, ms: Date.now() - started, error: error.message });
|
|
182
|
+
res.statusCode = status;
|
|
133
183
|
res.setHeader("content-type", "application/json");
|
|
134
|
-
res.end(JSON.stringify({ error: { type: "aerial_error", message: error.message } }));
|
|
184
|
+
res.end(JSON.stringify({ error: { type: status === 413 ? "request_entity_too_large" : "aerial_error", message: error.message } }));
|
|
135
185
|
}
|
|
136
186
|
});
|
|
137
187
|
|
package/src/setup.js
CHANGED
|
@@ -36,10 +36,22 @@ function tomlValue(value) {
|
|
|
36
36
|
return JSON.stringify(String(value));
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
function
|
|
39
|
+
function escapeRegExp(value) {
|
|
40
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function setTomlRootString(content, key, value) {
|
|
40
44
|
const line = `${key} = ${tomlValue(value)}`;
|
|
41
|
-
const
|
|
42
|
-
|
|
45
|
+
const source = content.split(/\r?\n/);
|
|
46
|
+
const firstSection = source.findIndex((sourceLine) => /^\s*\[.*\]\s*(?:#.*)?$/.test(sourceLine));
|
|
47
|
+
const rootLines = firstSection === -1 ? source : source.slice(0, firstSection);
|
|
48
|
+
const restLines = firstSection === -1 ? [] : source.slice(firstSection);
|
|
49
|
+
const root = rootLines.join("\n");
|
|
50
|
+
const rest = restLines.join("\n");
|
|
51
|
+
const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=.*$`, "m");
|
|
52
|
+
const nextRoot = re.test(root) ? root.replace(re, line) : `${root.trimEnd()}${root.trimEnd() ? "\n" : ""}${line}`;
|
|
53
|
+
if (!rest) return `${nextRoot.trimEnd()}\n`;
|
|
54
|
+
return `${nextRoot.trimEnd()}\n${rest}`;
|
|
43
55
|
}
|
|
44
56
|
|
|
45
57
|
function upsertTomlSection(content, section, values) {
|
|
@@ -88,8 +100,8 @@ export function setupCodex({ model, effort, authCommand = DEFAULT_CODEX_AUTH } =
|
|
|
88
100
|
ensureParent(file);
|
|
89
101
|
const backup = backupIfExists(file);
|
|
90
102
|
let content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
91
|
-
content =
|
|
92
|
-
content =
|
|
103
|
+
content = setTomlRootString(content, "model_provider", "aerial");
|
|
104
|
+
content = setTomlRootString(content, "model", selectedModel);
|
|
93
105
|
content = upsertTomlSection(content, "model_providers.aerial", {
|
|
94
106
|
name: "Aerial",
|
|
95
107
|
base_url: `http://${config.host}:${config.port}/v1`,
|
|
@@ -367,4 +379,3 @@ export function restoreAllClients(opts) {
|
|
|
367
379
|
const ok = Object.values(results).every((r) => r.ok);
|
|
368
380
|
return { ok, results };
|
|
369
381
|
}
|
|
370
|
-
|