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 +11 -6
- package/dist/main.js +239 -10
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
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 [
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
151
|
-
* object instead of the matching entry. One entry is
|
|
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.
|
|
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
|
-
|
|
178
|
-
|
|
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:
|
|
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
|
});
|