copilot-relay 0.3.1 → 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 +31 -7
- package/dist/main.js +290 -11
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -140,9 +140,28 @@ copilot-relay 0.2.5
|
|
|
140
140
|
models gpt-5.6-sol[1m], claude-opus-5
|
|
141
141
|
upstream not checked (use --deep)
|
|
142
142
|
log ~/.copilot-relay/logs/copilot-relay.2026-07-25.log
|
|
143
|
-
config ~/.copilot-relay/config.yaml
|
|
143
|
+
config ~/.copilot-relay/config.yaml
|
|
144
|
+
host 127.0.0.1
|
|
145
|
+
port 4142
|
|
146
|
+
copilotBaseUrl https://api.githubcopilot.com
|
|
147
|
+
claudeSetup true
|
|
148
|
+
logLevel info
|
|
149
|
+
logRetentionDays 3
|
|
150
|
+
thinkEffort max
|
|
151
|
+
upstreamTimeoutSeconds 180
|
|
152
|
+
webSearchBackend (unset — uses gptModel)
|
|
153
|
+
gptModel gpt-5.6-sol
|
|
154
|
+
opusModel claude-opus-5
|
|
155
|
+
host, port and claudeSetup take effect on restart; the rest hot-reload.
|
|
144
156
|
```
|
|
145
157
|
|
|
158
|
+
The `config` block is every key `readAppConfig()` resolved, in
|
|
159
|
+
`config.default.yaml` order. These are the values on disk: eight of them reload
|
|
160
|
+
while the relay runs, while `host`, `port` and `claudeSetup` are read once at
|
|
161
|
+
startup — the block says so rather than leaving you to discover it after an
|
|
162
|
+
edit. `--json` emits the same values under `config`, with `webSearchBackend` as
|
|
163
|
+
`null` when unset so the key set stays the same shape either way.
|
|
164
|
+
|
|
146
165
|
The first line is the CLI you invoked; the `version` row is the build the
|
|
147
166
|
running daemon reports about itself. They differ after an upgrade that has not
|
|
148
167
|
been restarted yet — the new CLI is installed, the old process is still
|
|
@@ -192,7 +211,8 @@ tail -f ~/.copilot-relay/logs/copilot-relay.$(date +%F).log
|
|
|
192
211
|
grep -n "Failed to create\\|Startup preflight failed" ~/.copilot-relay/logs/copilot-relay.*.log
|
|
193
212
|
```
|
|
194
213
|
|
|
195
|
-
See [
|
|
214
|
+
See [Logs and troubleshooting](wiki/EN-Logging-Troubleshooting.md)
|
|
215
|
+
([中文](wiki/ZH-Logging-Troubleshooting.md)) for common debugging workflows.
|
|
196
216
|
|
|
197
217
|
## Running it as a service
|
|
198
218
|
|
|
@@ -206,11 +226,15 @@ which is generated from `wiki/` on every merge. Edit the folder, not the tab.
|
|
|
206
226
|
|
|
207
227
|
## Development
|
|
208
228
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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 |
|
|
214
238
|
|
|
215
239
|
```sh
|
|
216
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({
|
|
@@ -3320,6 +3547,11 @@ const exitCodes = {
|
|
|
3320
3547
|
};
|
|
3321
3548
|
const localProbeTimeoutMs = 5e3;
|
|
3322
3549
|
const deepProbeTimeoutMs = 6e4;
|
|
3550
|
+
const toStatusConfig = (config) => ({
|
|
3551
|
+
...config,
|
|
3552
|
+
copilotBaseUrl: formatUrlForDisplay(config.copilotBaseUrl),
|
|
3553
|
+
webSearchBackend: config.webSearchBackend ?? null
|
|
3554
|
+
});
|
|
3323
3555
|
/**
|
|
3324
3556
|
* True when the daemon is serving a different build than the invoked CLI.
|
|
3325
3557
|
*
|
|
@@ -3354,13 +3586,58 @@ const formatUptime = (startedAt) => {
|
|
|
3354
3586
|
return `${Math.floor(seconds / 86400)}d ${Math.floor(seconds % 86400 / 3600)}h`;
|
|
3355
3587
|
};
|
|
3356
3588
|
/**
|
|
3589
|
+
* Every config key with its position in the block, in config.default.yaml order
|
|
3590
|
+
* rather than alphabetical so it reads like the file it was resolved from.
|
|
3591
|
+
*
|
|
3592
|
+
* A Record keyed by StatusConfig rather than an array of keys: an array only
|
|
3593
|
+
* checks that each entry *is* a key, so a twelfth config key would type-check
|
|
3594
|
+
* while silently never appearing in `status` — the exact failure this command
|
|
3595
|
+
* exists to prevent. Keyed this way, adding one fails the build here.
|
|
3596
|
+
*/
|
|
3597
|
+
const configRowOrder = {
|
|
3598
|
+
host: 0,
|
|
3599
|
+
port: 1,
|
|
3600
|
+
copilotBaseUrl: 2,
|
|
3601
|
+
claudeSetup: 3,
|
|
3602
|
+
logLevel: 4,
|
|
3603
|
+
logRetentionDays: 5,
|
|
3604
|
+
thinkEffort: 6,
|
|
3605
|
+
upstreamTimeoutSeconds: 7,
|
|
3606
|
+
webSearchBackend: 8,
|
|
3607
|
+
gptModel: 9,
|
|
3608
|
+
opusModel: 10
|
|
3609
|
+
};
|
|
3610
|
+
const configRowKeys = Object.keys(configRowOrder).sort((a, b) => configRowOrder[a] - configRowOrder[b]);
|
|
3611
|
+
/**
|
|
3612
|
+
* The resolved config, one key per line.
|
|
3613
|
+
*
|
|
3614
|
+
* These are the values on disk. That is not the same question as "what is the
|
|
3615
|
+
* running daemon honouring", and the footnote says so rather than leaving it
|
|
3616
|
+
* implied: applyRuntimeConfig() in start.ts hot-reloads eight of these keys,
|
|
3617
|
+
* never reads claudeSetup, and deliberately does not rebind the listening
|
|
3618
|
+
* socket when host or port changes. Printing all eleven without that line
|
|
3619
|
+
* would imply a live daemon had read values it has not.
|
|
3620
|
+
*
|
|
3621
|
+
* Only shown when running, because with nothing up every value applies at the
|
|
3622
|
+
* next start and the note would be noise.
|
|
3623
|
+
*/
|
|
3624
|
+
const renderConfig = (config, running) => {
|
|
3625
|
+
const lines = configRowKeys.map((key) => {
|
|
3626
|
+
const value = config[key];
|
|
3627
|
+
const rendered = key === "webSearchBackend" && value === null ? "(unset — uses gptModel)" : String(value);
|
|
3628
|
+
return ` ${key.padEnd(24)}${sanitizeTerminalString(rendered)}`;
|
|
3629
|
+
});
|
|
3630
|
+
if (running) lines.push(" host, port and claudeSetup take effect on restart; the rest hot-reload.");
|
|
3631
|
+
return lines;
|
|
3632
|
+
};
|
|
3633
|
+
/**
|
|
3357
3634
|
* Pure state-to-lines mapping, kept free of IO so it can be tested directly
|
|
3358
3635
|
* without spawning a relay or opening a socket.
|
|
3359
3636
|
*/
|
|
3360
3637
|
const renderStatus = (status$1) => {
|
|
3361
3638
|
const lines = [`copilot-relay ${status$1.version}`];
|
|
3362
3639
|
if (!status$1.running) {
|
|
3363
|
-
lines.push(" process not running", `
|
|
3640
|
+
lines.push(" process not running", ` log ${status$1.logPath}`, ` config ${status$1.configPath}`, ...renderConfig(status$1.config, false), "", " Start it with: copilot-relay start");
|
|
3364
3641
|
return lines;
|
|
3365
3642
|
}
|
|
3366
3643
|
lines.push(` process running (pid ${status$1.pid}, up ${formatUptime(status$1.startedAt)})`);
|
|
@@ -3371,7 +3648,7 @@ const renderStatus = (status$1) => {
|
|
|
3371
3648
|
lines.push(status$1.models?.length ? ` models ${status$1.models.join(", ")}` : " models unavailable");
|
|
3372
3649
|
if (status$1.deep) lines.push(status$1.deep.ok ? ` upstream ok (${status$1.deep.ms}ms) — end-to-end Copilot round trip` : ` upstream FAILED${status$1.deep.detail ? ` — ${status$1.deep.detail}` : ""}`);
|
|
3373
3650
|
else lines.push(" upstream not checked (use --deep)");
|
|
3374
|
-
lines.push(` log ${status$1.logPath}`, ` config ${status$1.configPath} (
|
|
3651
|
+
lines.push(` log ${status$1.logPath}`, ` config ${status$1.configPath}`, ...renderConfig(status$1.config, true));
|
|
3375
3652
|
if (status$1.health?.ok && status$1.deep && !status$1.deep.ok) lines.push("", " The relay is listening but cannot reach Copilot.", " Try: copilot-relay auth");
|
|
3376
3653
|
if (hasVersionMismatch(status$1)) lines.push("", ` The running relay is ${status$1.daemonVersion}; ${status$1.version} is installed.`, " Restart it to serve the installed version: copilot-relay restart");
|
|
3377
3654
|
return lines;
|
|
@@ -3478,7 +3755,9 @@ const checkDeep = async (baseUrl, model) => {
|
|
|
3478
3755
|
const collectStatus = async (options) => {
|
|
3479
3756
|
const appConfig = await readAppConfig();
|
|
3480
3757
|
setLogLevel(appConfig.logLevel);
|
|
3758
|
+
registerSensitiveOrigin(appConfig.copilotBaseUrl);
|
|
3481
3759
|
const base = {
|
|
3760
|
+
config: toStatusConfig(appConfig),
|
|
3482
3761
|
configPath: paths.configPath,
|
|
3483
3762
|
logLevel: appConfig.logLevel,
|
|
3484
3763
|
logPath: getLogPath(),
|
|
@@ -3525,8 +3804,8 @@ const status = defineCommand({
|
|
|
3525
3804
|
},
|
|
3526
3805
|
async run({ args }) {
|
|
3527
3806
|
const result = await collectStatus({ deep: Boolean(args.deep) });
|
|
3528
|
-
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
3529
|
-
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")));
|
|
3530
3809
|
process.exitCode = resolveExitCode(result);
|
|
3531
3810
|
}
|
|
3532
3811
|
});
|