copilot-relay 0.3.2 → 0.3.3

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 CHANGED
@@ -211,7 +211,8 @@ tail -f ~/.copilot-relay/logs/copilot-relay.$(date +%F).log
211
211
  grep -n "Failed to create\\|Startup preflight failed" ~/.copilot-relay/logs/copilot-relay.*.log
212
212
  ```
213
213
 
214
- See [`docs/troubleshooting.md`](docs/troubleshooting.md) for common debugging workflows.
214
+ See [Logs and troubleshooting](wiki/EN-Logging-Troubleshooting.md)
215
+ ([中文](wiki/ZH-Logging-Troubleshooting.md)) for common debugging workflows.
215
216
 
216
217
  ## Running it as a service
217
218
 
@@ -225,11 +226,15 @@ which is generated from `wiki/` on every merge. Edit the folder, not the tab.
225
226
 
226
227
  ## Development
227
228
 
228
- Developer notes live in [`docs/development.md`](docs/development.md).
229
- Architecture notes live in [`docs/architecture.md`](docs/architecture.md).
230
- Logging notes live in [`docs/logging.md`](docs/logging.md).
231
- Troubleshooting notes live in [`docs/troubleshooting.md`](docs/troubleshooting.md).
232
- User-facing guides live in [`wiki/`](wiki/).
229
+ All documentation lives in [`wiki/`](wiki/), in English and 中文:
230
+
231
+ | Page | Covers |
232
+ | --- | --- |
233
+ | [Architecture](wiki/EN-Architecture.md) ([中文](wiki/ZH-Architecture.md)) | Modules, request and startup flow, public API, runtime files |
234
+ | [Internals](wiki/EN-Internals.md) ([中文](wiki/ZH-Internals.md)) | Translation, streaming, prompt caching, lifecycle, logging invariants |
235
+ | [Development](wiki/EN-Development.md) ([中文](wiki/ZH-Development.md)) | Setup, tests, CI matrix, workflow, releasing |
236
+ | [Configuration](wiki/EN-Configuration.md) ([中文](wiki/ZH-Configuration.md)) | Every config key, hot reload vs restart |
237
+ | [Logs and troubleshooting](wiki/EN-Logging-Troubleshooting.md) ([中文](wiki/ZH-Logging-Troubleshooting.md)) | Log format, grep recipes, failure modes |
233
238
 
234
239
  ```sh
235
240
  npm install
package/dist/main.js CHANGED
@@ -119,6 +119,124 @@ async function ensureFile(filePath, options = {}) {
119
119
  }
120
120
  }
121
121
 
122
+ //#endregion
123
+ //#region src/lib/redact.ts
124
+ /**
125
+ * Origins known to carry a secret somewhere after the origin.
126
+ *
127
+ * Process-lifetime and append-only. Never pruned, because config hot-reload can
128
+ * change copilotBaseUrl while a request against the previous one is still in
129
+ * flight; dropping the old policy would un-redact that request's error on its
130
+ * way to the log.
131
+ */
132
+ const sensitiveOrigins = /* @__PURE__ */ new Set();
133
+ /** Marker written in place of everything after the origin. */
134
+ const redactedMarker = "[redacted]";
135
+ /**
136
+ * ANSI escape sequences: CSI (`ESC [ … final`), OSC (`ESC ] … BEL/ST`), and the
137
+ * two-byte Fe forms. Matched whole rather than stripping the ESC byte alone,
138
+ * which would leave the printable remainder (`[2K`) in the output.
139
+ *
140
+ * Written with \x escapes rather than literal bytes: an invisible ESC in the
141
+ * source survives no round trip through an editor, a diff view, or a paste.
142
+ */
143
+ const ansiEscapePattern = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][\s\S]*?(?:\x07|\x1B\\)|[@-Z\\-_])/g;
144
+ /** C0 controls and DEL, including CR/LF: one config value is one row. */
145
+ const controlBytePattern = /[\x00-\x1F\x7F]/g;
146
+ /**
147
+ * Absolute http(s) URLs inside arbitrary text.
148
+ *
149
+ * Stops at whitespace, quotes, backticks and angle brackets so a URL embedded
150
+ * in inspect() output — `url: 'https://host/x'` — matches without swallowing
151
+ * the surrounding punctuation.
152
+ *
153
+ * Backslashes are consumed rather than terminating the match. WHATWG folds `\`
154
+ * into `/` for http(s), so `https://host\tenant\TOKEN` parses with the tail in
155
+ * the path, carries no userinfo, and is therefore accepted and stored verbatim
156
+ * by normalizeCopilotBaseUrl — and inspect() doubles each backslash on the way
157
+ * to both sinks. Stopping at the first one matched only the origin, which then
158
+ * looked origin-only and left the entire tail in the log.
159
+ */
160
+ const absoluteUrlPattern = /https?:\/\/[^\s'"`<>]+/gi;
161
+ const parseHttpUrl = (value) => {
162
+ let parsed;
163
+ try {
164
+ parsed = new URL(value);
165
+ } catch {
166
+ return;
167
+ }
168
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed : void 0;
169
+ };
170
+ /** True when the URL carries nothing after the origin worth hiding. */
171
+ const isOriginOnly = (parsed) => (parsed.pathname === "" || parsed.pathname === "/") && !parsed.search && !parsed.hash;
172
+ /**
173
+ * Renders a URL for display, keeping the origin and hiding the rest.
174
+ *
175
+ * The origin is the operationally useful half — it answers "which gateway am I
176
+ * talking to" — while path, query and fragment are where a credential ends up.
177
+ * An explicit marker rather than silent truncation, so a reader can tell the
178
+ * difference between a base URL that has no path and one whose path was
179
+ * withheld.
180
+ *
181
+ * Never throws: it also runs on values that failed validation.
182
+ */
183
+ const formatUrlForDisplay = (raw) => {
184
+ const parsed = parseHttpUrl(raw);
185
+ if (!parsed) return "(invalid url)";
186
+ return isOriginOnly(parsed) ? raw : `${parsed.origin} (path/query/fragment hidden)`;
187
+ };
188
+ /**
189
+ * Strips ANSI escapes and control bytes from a string bound for the terminal.
190
+ *
191
+ * `status` prints config values directly to stdout. A config string carrying
192
+ * raw control bytes could clear the line and paint status rows the relay never
193
+ * produced. The config file is user-owned, so this is hardening rather than a
194
+ * vulnerability — but it costs nothing to make the output non-forgeable. #47.
195
+ */
196
+ const sanitizeTerminalString = (text) => text.replace(ansiEscapePattern, "").replace(controlBytePattern, "");
197
+ /**
198
+ * Marks a URL's origin as secret-bearing, if it has anything after the origin.
199
+ *
200
+ * An origin-only URL registers nothing: there is no tail to hide, and marking
201
+ * the origin would redact every unrelated URL on that host for no benefit.
202
+ *
203
+ * Additive and idempotent. Never throws on an unparseable value.
204
+ */
205
+ const registerSensitiveOrigin = (raw) => {
206
+ const parsed = parseHttpUrl(raw);
207
+ if (!parsed || isOriginOnly(parsed)) return;
208
+ sensitiveOrigins.add(parsed.origin);
209
+ };
210
+ /**
211
+ * Rewrites URLs on registered origins to `origin[redacted]`.
212
+ *
213
+ * Anchored on the parsed origin rather than on the configured string, which is
214
+ * what makes the variants match: fetch reports back a canonicalized URL (host
215
+ * case-folded, default port dropped) with the fragment already stripped, and
216
+ * clients append their own endpoint to the base. All of those share an origin.
217
+ *
218
+ * Deliberately not a substring substitution of the secret path. Replacing that
219
+ * text wherever it appeared would also rewrite unrelated prose and detached
220
+ * paths; only an absolute URL on a registered origin is rewritten here.
221
+ */
222
+ const scrubSensitiveUrls = (text) => {
223
+ if (sensitiveOrigins.size === 0) return text;
224
+ return text.replace(absoluteUrlPattern, (match) => {
225
+ const parsed = parseHttpUrl(match);
226
+ if (!parsed || !sensitiveOrigins.has(parsed.origin)) return match;
227
+ const authorityStart = match.indexOf("://") + 3;
228
+ let authorityEnd = match.length;
229
+ for (let index = authorityStart; index < match.length; index += 1) {
230
+ const character = match[index];
231
+ if (character === "/" || character === "\\" || character === "?" || character === "#") {
232
+ authorityEnd = index;
233
+ break;
234
+ }
235
+ }
236
+ return authorityEnd === match.length ? match : `${parsed.origin}${redactedMarker}`;
237
+ });
238
+ };
239
+
122
240
  //#endregion
123
241
  //#region src/lib/log.ts
124
242
  const logCleanupCheckIntervalMs = 3600 * 1e3;
@@ -147,8 +265,9 @@ const setLogLevel = (level) => {
147
265
  * `breakLength: Infinity` matters as much as the depth cap. The previous
148
266
  * `inspect(value, { depth: null })` pretty-printed each payload across
149
267
  * thousands of physical lines, which was both the dominant source of log volume
150
- * and the reason the `grep` recipes in docs/logging.md returned a fragment of an
151
- * object instead of the matching entry. One entry is now one line.
268
+ * and the reason the `grep` recipes in wiki/EN-Logging-Troubleshooting.md
269
+ * returned a fragment of an object instead of the matching entry. One entry is
270
+ * now one line.
152
271
  */
153
272
  const formatLogValue = (value) => typeof value === "string" ? value : inspect(value, {
154
273
  breakLength: Infinity,
@@ -157,13 +276,21 @@ const formatLogValue = (value) => typeof value === "string" ? value : inspect(va
157
276
  maxArrayLength: 100,
158
277
  maxStringLength: 4e3
159
278
  });
279
+ /**
280
+ * Appends one already-rendered entry.
281
+ *
282
+ * Takes strings rather than raw values: rendering happens once in wrapFileLog,
283
+ * so the console and the file are guaranteed to carry the same redacted text.
284
+ * Inspecting again here would reintroduce the gap this closes - the file would
285
+ * be scrubbed while the console showed the original object. See #47.
286
+ */
160
287
  const writeLogFile = async (level, values) => {
161
288
  await fs.mkdir(paths.logsDir, { recursive: true });
162
289
  await cleanupLogsIfDue();
163
290
  const line = [
164
291
  (/* @__PURE__ */ new Date()).toISOString(),
165
292
  level,
166
- values.map(formatLogValue).join(" ")
293
+ values.join(" ")
167
294
  ].join(" ");
168
295
  await fs.appendFile(getLogPath(), `${line}\n`);
169
296
  };
@@ -173,9 +300,27 @@ const cleanupLogsIfDue = async () => {
173
300
  nextLogCleanupCheckAt = now + logCleanupCheckIntervalMs;
174
301
  await cleanupLogs(logRetentionDays);
175
302
  };
303
+ /**
304
+ * The single boundary where every logged value becomes redacted text.
305
+ *
306
+ * Each argument is rendered exactly once and scrubbed once, and the identical
307
+ * array of strings goes to both the file and the console. Scrubbing only on the
308
+ * way to disk would leave the original object on the console - the screen a
309
+ * user screenshots into an issue - so the two sinks must not diverge. See #47.
310
+ *
311
+ * Rendering is skipped only when neither sink would emit, so the cost matches
312
+ * the old behavior at info level. The console gate is read from consola rather
313
+ * than assumed equal to currentLogLevel: if the two ever diverge, rendering
314
+ * must follow whichever sink is still emitting, or that sink gets raw values.
315
+ */
176
316
  const wrapFileLog = (level, fn) => ((...args) => {
177
- if ((fileLevelByMethod[level] ?? consolaLevelByName.info) <= currentLogLevel) writeLogFile(level, args).catch(() => void 0);
178
- return fn(...args);
317
+ const methodLevel = fileLevelByMethod[level] ?? consolaLevelByName.info;
318
+ const writesToFile = methodLevel <= currentLogLevel;
319
+ const writesToConsole = methodLevel <= consola.level;
320
+ if (!writesToFile && !writesToConsole) return fn(...args);
321
+ const rendered = args.map((value) => scrubSensitiveUrls(formatLogValue(value)));
322
+ if (writesToFile) writeLogFile(level, rendered).catch(() => void 0);
323
+ return fn(...rendered);
179
324
  });
180
325
  consola.error = wrapFileLog("error", consola.error.bind(consola));
181
326
  consola.info = wrapFileLog("info", consola.info.bind(consola));
@@ -480,6 +625,87 @@ const normalizePositiveInteger = (value) => {
480
625
  return Number.isInteger(number) && number > 0 ? number : void 0;
481
626
  };
482
627
  const normalizeString = (value) => typeof value === "string" && value.trim() ? value.trim() : void 0;
628
+ /**
629
+ * A conventional http(s) URL: scheme followed by a literal "//" authority.
630
+ *
631
+ * WHATWG is far more permissive than this. `https:host/path`, `https:/host/path`
632
+ * and `https:\\host\path` all parse, all normalize to a real origin with the
633
+ * tail in the path, and all work upstream - so a protocol check alone lets them
634
+ * through. None of them can be found again in arbitrary log text: practical URL
635
+ * detection anchors on a literal "://", and loosening that to chase a bare
636
+ * "https:" would start matching ordinary prose. Requiring the authority prefix
637
+ * is the one boundary where this has a definite answer. See #47.
638
+ */
639
+ const conventionalHttpUrlPattern = /^https?:\/\//i;
640
+ /**
641
+ * Raw characters that cannot be delimited safely in rendered log text.
642
+ *
643
+ * A raw apostrophe is the clearest case. WHATWG accepts it and leaves it raw
644
+ * in the path, so the configured value keeps it - and inspect() renders that
645
+ * value inside quotes, where the apostrophe reads as the closing delimiter.
646
+ * Any URL scan ends there, and the rest of the path prints in full. Quotes,
647
+ * backticks and angle brackets are the same story; whitespace ends a URL for
648
+ * every reader and scanner alike; and tab and newline are worse still,
649
+ * because URL parsing strips them, so the stored value would not even match
650
+ * what is sent upstream.
651
+ *
652
+ * Rejecting the raw byte costs nothing: every one of these has a
653
+ * percent-encoded spelling that is unambiguous in log text and is accepted
654
+ * here unchanged. See #47.
655
+ */
656
+ const unsafeRawDelimiterPattern = /['"`<>\s\u0000-\u001F\u007F]/;
657
+ /**
658
+ * Both fixed strings. The rejected value is never interpolated: these messages
659
+ * reach the terminal and, via the startup failure path, the log file, which is
660
+ * exactly the disclosure being prevented.
661
+ */
662
+ const invalidCopilotBaseUrlMessage = "Invalid copilotBaseUrl: expected an absolute http(s) URL, e.g. https://api.githubcopilot.com";
663
+ const copilotBaseUrlCredentialsMessage = "Invalid copilotBaseUrl: URL credentials (user:password@host) are not supported; remove them and use a plain https URL";
664
+ const copilotBaseUrlDelimiterMessage = "Invalid copilotBaseUrl: quotes, backticks, angle brackets, whitespace and control characters must be percent-encoded";
665
+ /**
666
+ * Validates copilotBaseUrl as a conventional HTTP(S) URL without credentials.
667
+ *
668
+ * Four rules, all learned from #47:
669
+ *
670
+ * The value is concatenated as `${base}${path}` for every upstream call, so it
671
+ * must be something Undici will accept. A relative or non-HTTP value fails at
672
+ * request time as an unrelated-looking fetch error; failing here names the key.
673
+ *
674
+ * It must be written with an explicit `http://` or `https://` authority. See
675
+ * conventionalHttpUrlPattern: the shorthand forms WHATWG also accepts are
676
+ * unfindable in log text, so a configured secret in one of them cannot be
677
+ * redacted by anything downstream. Rejecting is the only safe answer, and this
678
+ * is the only place with enough context to give it.
679
+ *
680
+ * Raw quote-like characters, angle brackets, whitespace and control bytes are
681
+ * rejected for the same reason - see unsafeRawDelimiterPattern. They are what
682
+ * marks the end of a URL in rendered text, so a value containing one cannot be
683
+ * matched whole afterwards. Their percent-encoded forms are accepted.
684
+ *
685
+ * URL userinfo (`https://user:pass@host`) is rejected rather than passed
686
+ * through. Undici refuses it at request time anyway, so accepting it buys only
687
+ * a confusing failure plus a credential written to the log on every start.
688
+ *
689
+ * The accepted value is returned as the trimmed original, never URL-normalized.
690
+ * Round-tripping through URL would add a trailing slash, lowercase the host and
691
+ * re-encode the path, silently changing both the request URL and what gets
692
+ * written back to config.yaml.
693
+ */
694
+ const normalizeCopilotBaseUrl = (value) => {
695
+ const trimmed = normalizeString(value);
696
+ if (trimmed === void 0) return;
697
+ if (!conventionalHttpUrlPattern.test(trimmed)) throw new Error(invalidCopilotBaseUrlMessage);
698
+ if (unsafeRawDelimiterPattern.test(trimmed)) throw new Error(copilotBaseUrlDelimiterMessage);
699
+ let parsed;
700
+ try {
701
+ parsed = new URL(trimmed);
702
+ } catch {
703
+ throw new Error(invalidCopilotBaseUrlMessage);
704
+ }
705
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(invalidCopilotBaseUrlMessage);
706
+ if (parsed.username || parsed.password) throw new Error(copilotBaseUrlCredentialsMessage);
707
+ return trimmed;
708
+ };
483
709
  const normalizeThinkEffort = (value) => {
484
710
  if (typeof value !== "string") return;
485
711
  const normalized = value.toLowerCase() === "minimal" ? "low" : value.toLowerCase();
@@ -640,7 +866,7 @@ async function readAppConfig() {
640
866
  const upstreamTimeoutSeconds = normalizeUpstreamTimeoutSeconds(raw.upstreamTimeoutSeconds);
641
867
  const config = {
642
868
  claudeSetup: claudeSetup ?? defaultConfig.claudeSetup,
643
- copilotBaseUrl: normalizeString(raw.copilotBaseUrl) ?? defaultConfig.copilotBaseUrl,
869
+ copilotBaseUrl: normalizeCopilotBaseUrl(raw.copilotBaseUrl) ?? defaultConfig.copilotBaseUrl,
644
870
  gptModel: normalizeString(raw.gptModel) ?? defaultConfig.gptModel,
645
871
  host: host ?? defaultConfig.host,
646
872
  logLevel: logLevel ?? defaultConfig.logLevel,
@@ -3193,6 +3419,7 @@ async function startRelay(appConfig) {
3193
3419
  const claudeConfigPath$1 = claudeConfigPath;
3194
3420
  const config = readProxyConfig(appConfig);
3195
3421
  const applyRuntimeConfig = (nextConfig) => {
3422
+ registerSensitiveOrigin(nextConfig.copilotBaseUrl);
3196
3423
  setLogLevel(nextConfig.logLevel);
3197
3424
  cleanupLogs(nextConfig.logRetentionDays);
3198
3425
  runtimeState.debug = nextConfig.logLevel === "debug";
@@ -3225,7 +3452,7 @@ async function startRelay(appConfig) {
3225
3452
  if (authSession.githubLogin) log.info(`GitHub user: ${authSession.githubLogin}`);
3226
3453
  else log.error("GitHub user: unavailable");
3227
3454
  log.info(`copilot-relay listening on http://${config.host}:${config.port}`);
3228
- log.info(`copilot base url: ${config.copilotBaseUrl}`);
3455
+ log.info(`copilot base url: ${formatUrlForDisplay(config.copilotBaseUrl)}`);
3229
3456
  const baseUrl = `http://${config.host}:${config.port}`;
3230
3457
  if (appConfig.claudeSetup) try {
3231
3458
  const claudeResult = await applyClaudeConfig({
@@ -3322,6 +3549,7 @@ const localProbeTimeoutMs = 5e3;
3322
3549
  const deepProbeTimeoutMs = 6e4;
3323
3550
  const toStatusConfig = (config) => ({
3324
3551
  ...config,
3552
+ copilotBaseUrl: formatUrlForDisplay(config.copilotBaseUrl),
3325
3553
  webSearchBackend: config.webSearchBackend ?? null
3326
3554
  });
3327
3555
  /**
@@ -3397,7 +3625,7 @@ const renderConfig = (config, running) => {
3397
3625
  const lines = configRowKeys.map((key) => {
3398
3626
  const value = config[key];
3399
3627
  const rendered = key === "webSearchBackend" && value === null ? "(unset — uses gptModel)" : String(value);
3400
- return ` ${key.padEnd(24)}${rendered}`;
3628
+ return ` ${key.padEnd(24)}${sanitizeTerminalString(rendered)}`;
3401
3629
  });
3402
3630
  if (running) lines.push(" host, port and claudeSetup take effect on restart; the rest hot-reload.");
3403
3631
  return lines;
@@ -3527,6 +3755,7 @@ const checkDeep = async (baseUrl, model) => {
3527
3755
  const collectStatus = async (options) => {
3528
3756
  const appConfig = await readAppConfig();
3529
3757
  setLogLevel(appConfig.logLevel);
3758
+ registerSensitiveOrigin(appConfig.copilotBaseUrl);
3530
3759
  const base = {
3531
3760
  config: toStatusConfig(appConfig),
3532
3761
  configPath: paths.configPath,
@@ -3575,8 +3804,8 @@ const status = defineCommand({
3575
3804
  },
3576
3805
  async run({ args }) {
3577
3806
  const result = await collectStatus({ deep: Boolean(args.deep) });
3578
- if (args.json) console.log(JSON.stringify(result, null, 2));
3579
- else console.log(renderStatus(result).join("\n"));
3807
+ if (args.json) console.log(scrubSensitiveUrls(JSON.stringify(result, null, 2)));
3808
+ else console.log(scrubSensitiveUrls(renderStatus(result).join("\n")));
3580
3809
  process.exitCode = resolveExitCode(result);
3581
3810
  }
3582
3811
  });