@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jiayunxie/aerial",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Local-only GitHub Copilot proxy for Codex CLI and Claude Code.",
5
5
  "type": "module",
6
6
  "private": false,
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] = key === "port" ? Number(value) : value;
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 loaded = readJsonIfExists(configPath()) || {};
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 REDACT_KEYS = new Set([
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 redact(fields) {
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(fields)) {
157
- if (REDACT_KEYS.has(k)) continue;
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 = redact(fields);
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
- async function readBody(req) {
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
- for await (const chunk of req) chunks.push(chunk);
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
- logEvent("request_end", { method: req.method, path: req.url, status: 500, ms: Date.now() - started, error: error.message });
132
- res.statusCode = error.message?.includes("Missing GitHub token") ? 503 : 500;
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 setTomlString(content, key, value) {
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 re = new RegExp(`^${key}\\s*=.*$`, "m");
42
- return re.test(content) ? content.replace(re, line) : `${content.trimEnd()}\n${line}\n`;
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 = setTomlString(content, "model_provider", "aerial");
92
- content = setTomlString(content, "model", selectedModel);
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
-