codex-copilot-dx 0.6.19 → 0.6.21
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/auth-profile.mjs +3 -3
- package/src/auth.mjs +44 -10
- package/src/auto-review-model.mjs +2 -2
- package/src/cli-auth.mjs +2 -2
- package/src/cli-error.mjs +7 -1
- package/src/cli-models.mjs +2 -2
- package/src/cli-options.mjs +10 -8
- package/src/copilot-token-session.mjs +17 -2
- package/src/copilot.mjs +22 -3
- package/src/doctor.mjs +2 -2
- package/src/http-transport.mjs +7 -2
- package/src/pm-studio-bundle-content.mjs +165 -0
- package/src/pm-studio-setup.mjs +4 -154
- package/src/responses-handler.mjs +2 -0
- package/src/runtime-config.mjs +23 -7
- package/src/token-counter-service.mjs +27 -4
- package/src/token-counter-worker.mjs +8 -2
- package/src/usage.mjs +80 -13
package/package.json
CHANGED
package/src/auth-profile.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
normalizeGithubIdentity,
|
|
13
13
|
} from "./github-identity.mjs";
|
|
14
14
|
import { withFileLock } from "./lock.mjs";
|
|
15
|
-
import {
|
|
15
|
+
import { parseSafePositiveInteger, RUNTIME_DEFAULTS } from "./runtime-config.mjs";
|
|
16
16
|
|
|
17
17
|
export const AUTH_PROFILE_CODEX = "codex";
|
|
18
18
|
export const AUTH_PROFILE_CLAUDE = "claude";
|
|
@@ -117,8 +117,8 @@ export function readAuthProfileCredentials(profile, { home = os.homedir() } = {}
|
|
|
117
117
|
|
|
118
118
|
function lockOptions(env = process.env) {
|
|
119
119
|
return {
|
|
120
|
-
timeoutMs:
|
|
121
|
-
staleMs:
|
|
120
|
+
timeoutMs: parseSafePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
|
|
121
|
+
staleMs: parseSafePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
|
|
122
122
|
};
|
|
123
123
|
}
|
|
124
124
|
|
package/src/auth.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
githubTokenFingerprint,
|
|
9
9
|
normalizeGithubIdentity,
|
|
10
10
|
} from "./github-identity.mjs";
|
|
11
|
-
import {
|
|
11
|
+
import { parseSafePositiveInteger, parseTimerMs, RUNTIME_DEFAULTS } from "./runtime-config.mjs";
|
|
12
12
|
import { status } from "./status.mjs";
|
|
13
13
|
import { withFileLock } from "./lock.mjs";
|
|
14
14
|
|
|
@@ -20,6 +20,7 @@ const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
|
20
20
|
const DEFAULT_DEVICE_INTERVAL_SECONDS = 5;
|
|
21
21
|
const DEFAULT_DEVICE_EXPIRES_SECONDS = 900;
|
|
22
22
|
const DEFAULT_DEVICE_CODE_TIMEOUT_MS = 30 * 1000;
|
|
23
|
+
const DEFAULT_GITHUB_IDENTITY_TIMEOUT_MS = 30 * 1000;
|
|
23
24
|
const MAX_DEVICE_FLOW_JSON_BYTES = 64 * 1024;
|
|
24
25
|
const DISABLE_TOKEN_DISCOVERY_VALUES = new Set(["1", "true", "yes"]);
|
|
25
26
|
const MAX_AUTH_JSON_BYTES = 1024 * 1024;
|
|
@@ -258,8 +259,8 @@ function isTokenDiscoveryDisabled(env = process.env) {
|
|
|
258
259
|
|
|
259
260
|
function tokenLockOptions(env = process.env) {
|
|
260
261
|
return {
|
|
261
|
-
timeoutMs:
|
|
262
|
-
staleMs:
|
|
262
|
+
timeoutMs: parseSafePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
|
|
263
|
+
staleMs: parseSafePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
|
|
263
264
|
};
|
|
264
265
|
}
|
|
265
266
|
|
|
@@ -425,19 +426,47 @@ function expectedGithubIdentity(home, env, token) {
|
|
|
425
426
|
return token ? readGithubTokenMetadata(home, token) : null;
|
|
426
427
|
}
|
|
427
428
|
|
|
428
|
-
|
|
429
|
+
function githubIdentityTimeoutError(timeoutMs) {
|
|
430
|
+
const error = new Error(`GitHub identity request timed out after ${timeoutMs}ms`);
|
|
431
|
+
error.code = "CCDX_GITHUB_IDENTITY_TIMEOUT";
|
|
432
|
+
return error;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export async function fetchGithubIdentity(token, {
|
|
436
|
+
fetchImpl = fetch,
|
|
437
|
+
signal,
|
|
438
|
+
timeoutMs = DEFAULT_GITHUB_IDENTITY_TIMEOUT_MS,
|
|
439
|
+
} = {}) {
|
|
429
440
|
if (typeof token !== "string" || !token.trim()) return { ok: false, reason: "empty_token" };
|
|
441
|
+
const deadlineMs = parseTimerMs(timeoutMs, DEFAULT_GITHUB_IDENTITY_TIMEOUT_MS);
|
|
442
|
+
const controller = new AbortController();
|
|
443
|
+
const onCallerAbort = () => controller.abort(signal?.reason);
|
|
444
|
+
signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
445
|
+
if (signal?.aborted) onCallerAbort();
|
|
446
|
+
const timer = setTimeout(() => controller.abort(githubIdentityTimeoutError(deadlineMs)), deadlineMs);
|
|
447
|
+
let resp;
|
|
430
448
|
try {
|
|
431
|
-
|
|
449
|
+
resp = await awaitWithSignal(() => fetchImpl(`${GITHUB_API}/user`, {
|
|
432
450
|
headers: { Authorization: `token ${token.trim()}`, Accept: "application/json" },
|
|
433
|
-
signal,
|
|
434
|
-
});
|
|
451
|
+
signal: controller.signal,
|
|
452
|
+
}), controller.signal);
|
|
435
453
|
if (!resp.ok) return { ok: false, status: resp.status, reason: "github_user_failed" };
|
|
436
|
-
const data = await resp.json();
|
|
454
|
+
const data = await awaitWithSignal(() => resp.json(), controller.signal);
|
|
437
455
|
const identity = normalizeGithubIdentity(data);
|
|
438
456
|
return identity ? { ok: true, ...identity } : { ok: false, reason: "github_identity_missing" };
|
|
439
457
|
} catch (error) {
|
|
440
|
-
|
|
458
|
+
cancelResponseBody(resp);
|
|
459
|
+
const timedOut = controller.signal.aborted && !signal?.aborted
|
|
460
|
+
&& controller.signal.reason?.code === "CCDX_GITHUB_IDENTITY_TIMEOUT";
|
|
461
|
+
return {
|
|
462
|
+
ok: false,
|
|
463
|
+
transient: true,
|
|
464
|
+
reason: timedOut ? "github_user_timeout" : "github_user_request_failed",
|
|
465
|
+
error,
|
|
466
|
+
};
|
|
467
|
+
} finally {
|
|
468
|
+
clearTimeout(timer);
|
|
469
|
+
signal?.removeEventListener("abort", onCallerAbort);
|
|
441
470
|
}
|
|
442
471
|
}
|
|
443
472
|
|
|
@@ -661,6 +690,7 @@ export async function ensureAuth({
|
|
|
661
690
|
log = console.log,
|
|
662
691
|
openAndCopyFn = openAndCopy,
|
|
663
692
|
deviceCodeTimeoutMs = DEFAULT_DEVICE_CODE_TIMEOUT_MS,
|
|
693
|
+
githubIdentityTimeoutMs = DEFAULT_GITHUB_IDENTITY_TIMEOUT_MS,
|
|
664
694
|
sleepImpl = sleep,
|
|
665
695
|
now = Date.now,
|
|
666
696
|
} = {}) {
|
|
@@ -720,7 +750,11 @@ export async function ensureAuth({
|
|
|
720
750
|
now,
|
|
721
751
|
});
|
|
722
752
|
if (result.state === "done") {
|
|
723
|
-
const identity = await fetchGithubIdentity(result.token, {
|
|
753
|
+
const identity = await fetchGithubIdentity(result.token, {
|
|
754
|
+
fetchImpl,
|
|
755
|
+
signal,
|
|
756
|
+
timeoutMs: githubIdentityTimeoutMs,
|
|
757
|
+
});
|
|
724
758
|
writeToken(result.token, home, identity.ok ? identity : null);
|
|
725
759
|
log(status("ok", "Login successful"));
|
|
726
760
|
return;
|
|
@@ -12,12 +12,12 @@ import {
|
|
|
12
12
|
readUserSettings,
|
|
13
13
|
writeAutoReviewModel,
|
|
14
14
|
} from "./user-settings.mjs";
|
|
15
|
+
import { parseTimerMs } from "./runtime-config.mjs";
|
|
15
16
|
|
|
16
17
|
const DEFAULT_MODEL_FETCH_TIMEOUT_MS = 5000;
|
|
17
18
|
|
|
18
19
|
function modelFetchTimeout(env) {
|
|
19
|
-
|
|
20
|
-
return Number.isSafeInteger(value) && value > 0 ? value : DEFAULT_MODEL_FETCH_TIMEOUT_MS;
|
|
20
|
+
return parseTimerMs(env.CCDX_MODEL_REFRESH_TIMEOUT_MS, DEFAULT_MODEL_FETCH_TIMEOUT_MS);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
function sortedModelIds(modelIds, currentModel) {
|
package/src/cli-auth.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
withAuthProfileLock,
|
|
16
16
|
writeClaudeAuthProfile,
|
|
17
17
|
} from "./auth-profile.mjs";
|
|
18
|
-
import { buildHeaders,
|
|
18
|
+
import { buildHeaders, FALLBACK_VSCODE_VERSION, parseApiBase } from "./copilot.mjs";
|
|
19
19
|
import {
|
|
20
20
|
githubIdentitiesEqual,
|
|
21
21
|
githubTokenFingerprint,
|
|
@@ -178,7 +178,7 @@ async function fetchCopilotCatalog(tokenData, {
|
|
|
178
178
|
const timer = setTimeout(() => controller.abort(catalogTimeoutError(label, deadlineMs)), deadlineMs);
|
|
179
179
|
let modelResponse;
|
|
180
180
|
try {
|
|
181
|
-
const apiBase = tokenData
|
|
181
|
+
const apiBase = parseApiBase(tokenData);
|
|
182
182
|
const headers = buildHeaders({
|
|
183
183
|
token: tokenData.token,
|
|
184
184
|
version: vscodeVersion,
|
package/src/cli-error.mjs
CHANGED
|
@@ -22,7 +22,13 @@ function boundedTerminalText(value, fallback, maxCharacters) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export function formatCliError(error) {
|
|
25
|
-
|
|
25
|
+
let rawMessage;
|
|
26
|
+
try {
|
|
27
|
+
rawMessage = error?.message;
|
|
28
|
+
} catch {
|
|
29
|
+
rawMessage = undefined;
|
|
30
|
+
}
|
|
31
|
+
const message = boundedTerminalText(rawMessage, "Command failed", MAX_MESSAGE_CHARACTERS);
|
|
26
32
|
let details;
|
|
27
33
|
try {
|
|
28
34
|
details = error?.details;
|
package/src/cli-models.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
AUTH_PROFILE_CODEX,
|
|
6
6
|
readAuthProfileCredentials,
|
|
7
7
|
} from "./auth-profile.mjs";
|
|
8
|
-
import { buildHeaders,
|
|
8
|
+
import { buildHeaders, FALLBACK_VSCODE_VERSION, parseApiBase } from "./copilot.mjs";
|
|
9
9
|
import {
|
|
10
10
|
isClaudeCopilotCatalogEntry,
|
|
11
11
|
isClaudeCopilotModel,
|
|
@@ -134,7 +134,7 @@ export async function fetchLiveCopilotModels({
|
|
|
134
134
|
if (!validation.ok) throw new Error(validationFailure(validation));
|
|
135
135
|
|
|
136
136
|
const tokenData = validation.copilotTokenData;
|
|
137
|
-
const apiBase = tokenData
|
|
137
|
+
const apiBase = parseApiBase(tokenData);
|
|
138
138
|
const headers = buildHeaders({
|
|
139
139
|
token: tokenData.token,
|
|
140
140
|
version: vscodeVersion,
|
package/src/cli-options.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { MAX_TIMER_DELAY_MS } from "./runtime-config.mjs";
|
|
2
|
+
|
|
1
3
|
const HELP_COMMANDS = new Set(["help", "--help", "-h"]);
|
|
2
4
|
const VERSION_COMMANDS = new Set(["version", "--version", "-v"]);
|
|
3
5
|
const START_OPTIONS = new Map([
|
|
@@ -262,13 +264,13 @@ export function parseRuntimeOptions(env = process.env) {
|
|
|
262
264
|
return {
|
|
263
265
|
adapterPort: integerEnv(env, "ADAPTER_PORT", 2026, { min: 1, max: 65535 }),
|
|
264
266
|
adapterHost: String(env.ADAPTER_HOST || "127.0.0.1").trim() || "127.0.0.1",
|
|
265
|
-
modelRefreshTimeoutMs: integerEnv(env, "CCDX_MODEL_REFRESH_TIMEOUT_MS", 5000, { min: 1 }),
|
|
266
|
-
existingAdapterTimeoutMs: integerEnv(env, "CCDX_EXISTING_ADAPTER_TIMEOUT_MS", 500, { min: 1 }),
|
|
267
|
-
modelRefreshIntervalMs: integerEnv(env, "CCDX_MODEL_REFRESH_INTERVAL_MS", 2 * 60 * 60 * 1000, { min: 0 }),
|
|
268
|
-
upstreamTimeoutMs: integerEnv(env, "CCDX_UPSTREAM_TIMEOUT_MS", 120000, { min: 1 }),
|
|
269
|
-
streamHandshakeTimeoutMs: integerEnv(env, "CCDX_STREAM_HANDSHAKE_TIMEOUT_MS", 120000, { min: 1 }),
|
|
270
|
-
streamIdleTimeoutMs: integerEnv(env, "CCDX_STREAM_IDLE_TIMEOUT_MS", 120000, { min: 1 }),
|
|
271
|
-
shutdownTimeoutMs: integerEnv(env, "CCDX_SHUTDOWN_TIMEOUT_MS", 5000, { min: 1 }),
|
|
267
|
+
modelRefreshTimeoutMs: integerEnv(env, "CCDX_MODEL_REFRESH_TIMEOUT_MS", 5000, { min: 1, max: MAX_TIMER_DELAY_MS }),
|
|
268
|
+
existingAdapterTimeoutMs: integerEnv(env, "CCDX_EXISTING_ADAPTER_TIMEOUT_MS", 500, { min: 1, max: MAX_TIMER_DELAY_MS }),
|
|
269
|
+
modelRefreshIntervalMs: integerEnv(env, "CCDX_MODEL_REFRESH_INTERVAL_MS", 2 * 60 * 60 * 1000, { min: 0, max: MAX_TIMER_DELAY_MS }),
|
|
270
|
+
upstreamTimeoutMs: integerEnv(env, "CCDX_UPSTREAM_TIMEOUT_MS", 120000, { min: 1, max: MAX_TIMER_DELAY_MS }),
|
|
271
|
+
streamHandshakeTimeoutMs: integerEnv(env, "CCDX_STREAM_HANDSHAKE_TIMEOUT_MS", 120000, { min: 1, max: MAX_TIMER_DELAY_MS }),
|
|
272
|
+
streamIdleTimeoutMs: integerEnv(env, "CCDX_STREAM_IDLE_TIMEOUT_MS", 120000, { min: 1, max: MAX_TIMER_DELAY_MS }),
|
|
273
|
+
shutdownTimeoutMs: integerEnv(env, "CCDX_SHUTDOWN_TIMEOUT_MS", 5000, { min: 1, max: MAX_TIMER_DELAY_MS }),
|
|
272
274
|
};
|
|
273
275
|
}
|
|
274
276
|
|
|
@@ -276,7 +278,7 @@ export function parseAdapterProbeOptions(env = process.env) {
|
|
|
276
278
|
return {
|
|
277
279
|
adapterPort: integerEnv(env, "ADAPTER_PORT", 2026, { min: 1, max: 65535 }),
|
|
278
280
|
adapterHost: String(env.ADAPTER_HOST || "127.0.0.1").trim() || "127.0.0.1",
|
|
279
|
-
existingAdapterTimeoutMs: integerEnv(env, "CCDX_EXISTING_ADAPTER_TIMEOUT_MS", 500, { min: 1 }),
|
|
281
|
+
existingAdapterTimeoutMs: integerEnv(env, "CCDX_EXISTING_ADAPTER_TIMEOUT_MS", 500, { min: 1, max: MAX_TIMER_DELAY_MS }),
|
|
280
282
|
};
|
|
281
283
|
}
|
|
282
284
|
|
|
@@ -60,6 +60,20 @@ function refreshError(message, { statusCode, transient = false } = {}) {
|
|
|
60
60
|
return error;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function transientRefreshError(error) {
|
|
64
|
+
let message = "Copilot token request failed";
|
|
65
|
+
try {
|
|
66
|
+
if (typeof error?.message === "string" && error.message) message = error.message;
|
|
67
|
+
} catch {}
|
|
68
|
+
const wrapped = new Error(message, { cause: error });
|
|
69
|
+
try {
|
|
70
|
+
if (typeof error?.code === "string") wrapped.code = error.code;
|
|
71
|
+
if (Number.isInteger(error?.statusCode)) wrapped.statusCode = error.statusCode;
|
|
72
|
+
} catch {}
|
|
73
|
+
wrapped.transient = true;
|
|
74
|
+
return wrapped;
|
|
75
|
+
}
|
|
76
|
+
|
|
63
77
|
function copilotProfileLabel(profile) {
|
|
64
78
|
if (profile === "codex") return "Copilot";
|
|
65
79
|
const name = String(profile || "profile");
|
|
@@ -189,6 +203,7 @@ export function createCopilotTokenSession({
|
|
|
189
203
|
allowAccountSwitch = false,
|
|
190
204
|
}) {
|
|
191
205
|
if (!data.token) throw new Error("Copilot token response missing token field");
|
|
206
|
+
const nextApiBase = parseApiBase(data);
|
|
192
207
|
const fingerprint = githubTokenFingerprint(githubToken);
|
|
193
208
|
const normalizedIdentity = normalizeGithubIdentity(identity);
|
|
194
209
|
const tokenChanged = Boolean(githubFingerprint && fingerprint !== githubFingerprint);
|
|
@@ -204,7 +219,7 @@ export function createCopilotTokenSession({
|
|
|
204
219
|
throw error;
|
|
205
220
|
}
|
|
206
221
|
copilotToken = data.token;
|
|
207
|
-
apiBase =
|
|
222
|
+
apiBase = nextApiBase;
|
|
208
223
|
copilotTokenExpiry = typeof data.expires_at === "number"
|
|
209
224
|
? data.expires_at * 1000
|
|
210
225
|
: Date.now() + 25 * 60 * 1000;
|
|
@@ -232,7 +247,7 @@ export function createCopilotTokenSession({
|
|
|
232
247
|
signal,
|
|
233
248
|
}, { fetchImpl, ...tokenRetryOptions });
|
|
234
249
|
} catch (error) {
|
|
235
|
-
if (!isAbortError(error, signal)) error
|
|
250
|
+
if (!isAbortError(error, signal)) throw transientRefreshError(error);
|
|
236
251
|
throw error;
|
|
237
252
|
}
|
|
238
253
|
if (!response.ok) {
|
package/src/copilot.mjs
CHANGED
|
@@ -33,9 +33,28 @@ export function parseUpstreamRetryDelayMs(value) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export function parseApiBase(data) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
const configured = data?.endpoints?.api;
|
|
37
|
+
if (typeof configured !== "string" || !configured.trim()) return DEFAULT_API_BASE;
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = new URL(configured.trim());
|
|
41
|
+
} catch {
|
|
42
|
+
throw Object.assign(new Error("Copilot API endpoint is invalid"), {
|
|
43
|
+
code: "CCDX_INVALID_COPILOT_API_ENDPOINT",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (parsed.protocol !== "https:"
|
|
47
|
+
|| !parsed.hostname
|
|
48
|
+
|| parsed.username
|
|
49
|
+
|| parsed.password
|
|
50
|
+
|| parsed.search
|
|
51
|
+
|| parsed.hash) {
|
|
52
|
+
throw Object.assign(new Error("Copilot API endpoint is invalid"), {
|
|
53
|
+
code: "CCDX_INVALID_COPILOT_API_ENDPOINT",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const pathname = parsed.pathname.replace(/\/+$/, "");
|
|
57
|
+
return `${parsed.origin}${pathname && pathname !== "/" ? pathname : ""}`;
|
|
39
58
|
}
|
|
40
59
|
|
|
41
60
|
export function responsesEndpointPath() {
|
package/src/doctor.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "./auth-profile.mjs";
|
|
11
11
|
import { claudeDesktopPaths } from "./claude-desktop-config.mjs";
|
|
12
12
|
import { status } from "./status.mjs";
|
|
13
|
-
import { buildHeaders,
|
|
13
|
+
import { buildHeaders, FALLBACK_VSCODE_VERSION, parseApiBase } from "./copilot.mjs";
|
|
14
14
|
import { adapterBaseUrl, checkRunningAdapter } from "./running-adapter.mjs";
|
|
15
15
|
import { CODEX_AUTO_REVIEW_MODEL, isClaudeCopilotModel } from "./models.mjs";
|
|
16
16
|
import { loadModelCache } from "./model-cache.mjs";
|
|
@@ -322,7 +322,7 @@ export async function inspectGitHubTokenOnline({
|
|
|
322
322
|
}
|
|
323
323
|
|
|
324
324
|
const tokenData = validation.copilotTokenData;
|
|
325
|
-
const apiBase = tokenData
|
|
325
|
+
const apiBase = parseApiBase(tokenData);
|
|
326
326
|
const headers = buildHeaders({
|
|
327
327
|
token: tokenData.token,
|
|
328
328
|
version: FALLBACK_VSCODE_VERSION,
|
package/src/http-transport.mjs
CHANGED
|
@@ -407,6 +407,9 @@ export function createRequestAdmission({
|
|
|
407
407
|
continue;
|
|
408
408
|
}
|
|
409
409
|
if (activeBytes + entry.weight > byteLimit) {
|
|
410
|
+
// Unknown-length requests reserve the whole budget. Once one is waiting,
|
|
411
|
+
// do not let later arrivals keep extending its wait indefinitely.
|
|
412
|
+
if (entry.weight === byteLimit) break;
|
|
410
413
|
index += 1;
|
|
411
414
|
continue;
|
|
412
415
|
}
|
|
@@ -422,11 +425,13 @@ export function createRequestAdmission({
|
|
|
422
425
|
return Promise.reject(admissionAbortError(signal));
|
|
423
426
|
}
|
|
424
427
|
const weight = requestAdmissionWeight(req, byteLimit);
|
|
425
|
-
|
|
428
|
+
const blockedByExclusive = queue.some((entry) => !entry.cancelled && entry.weight === byteLimit);
|
|
429
|
+
const mustWait = blockedByExclusive || activeBytes + weight > byteLimit;
|
|
430
|
+
if (queue.length >= queueLimit && mustWait) {
|
|
426
431
|
counters.rejected += 1;
|
|
427
432
|
return Promise.reject(httpError(`Request queue is full (${queueLimit} waiting)`, 503));
|
|
428
433
|
}
|
|
429
|
-
if (
|
|
434
|
+
if (mustWait) counters.queued += 1;
|
|
430
435
|
|
|
431
436
|
return new Promise((resolve, reject) => {
|
|
432
437
|
let timer;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { sha256Hex } from "./pm-studio-asar.mjs";
|
|
5
|
+
|
|
6
|
+
const BUNDLE_CONTENT_SCHEME = "ccdx-bundle-content-v2";
|
|
7
|
+
|
|
8
|
+
function setupError(code, message, details) {
|
|
9
|
+
const error = new Error(message);
|
|
10
|
+
error.code = code;
|
|
11
|
+
if (details !== undefined) error.details = details;
|
|
12
|
+
return error;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function hashRegularFile(filePath, expected, readBuffer) {
|
|
16
|
+
const hash = createHash("sha256");
|
|
17
|
+
const file = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
18
|
+
const buffer = readBuffer || Buffer.allocUnsafe(1024 * 1024);
|
|
19
|
+
try {
|
|
20
|
+
const before = fs.fstatSync(file, { bigint: true });
|
|
21
|
+
if (before.dev !== BigInt(expected.dev)
|
|
22
|
+
|| before.ino !== BigInt(expected.ino)
|
|
23
|
+
|| before.size !== BigInt(expected.size)) {
|
|
24
|
+
throw setupError("PM_STUDIO_BUNDLE_CHANGED", `PM Studio bundle changed while reading ${filePath}`);
|
|
25
|
+
}
|
|
26
|
+
for (;;) {
|
|
27
|
+
const length = fs.readSync(file, buffer, 0, buffer.length, null);
|
|
28
|
+
if (length === 0) break;
|
|
29
|
+
hash.update(buffer.subarray(0, length));
|
|
30
|
+
}
|
|
31
|
+
const after = fs.fstatSync(file, { bigint: true });
|
|
32
|
+
if (before.dev !== after.dev
|
|
33
|
+
|| before.ino !== after.ino
|
|
34
|
+
|| before.size !== after.size
|
|
35
|
+
|| before.mtimeNs !== after.mtimeNs
|
|
36
|
+
|| before.ctimeNs !== after.ctimeNs) {
|
|
37
|
+
throw setupError("PM_STUDIO_BUNDLE_CHANGED", `PM Studio bundle changed while hashing ${filePath}`);
|
|
38
|
+
}
|
|
39
|
+
} finally {
|
|
40
|
+
fs.closeSync(file);
|
|
41
|
+
}
|
|
42
|
+
return hash.digest("hex");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseXattrHex(output, root, ignoredXattrs) {
|
|
46
|
+
const records = [];
|
|
47
|
+
const ignored = new Set(ignoredXattrs);
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
let current = null;
|
|
50
|
+
const flush = () => {
|
|
51
|
+
if (!current) return;
|
|
52
|
+
if (!ignored.has(current.name)) {
|
|
53
|
+
const key = `${current.path}\0${current.name}`;
|
|
54
|
+
if (seen.has(key)) throw setupError("PM_STUDIO_XATTR_INVALID", `Duplicate extended attribute ${current.name}`);
|
|
55
|
+
seen.add(key);
|
|
56
|
+
const value = Buffer.from(current.hex.join(""), "hex");
|
|
57
|
+
records.push(["xattr", current.path, current.name, value.length, sha256Hex(value)]);
|
|
58
|
+
}
|
|
59
|
+
current = null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
for (const line of String(output || "").split(/\r?\n/)) {
|
|
63
|
+
if (!line) continue;
|
|
64
|
+
const separator = line.lastIndexOf(": ");
|
|
65
|
+
if ((line === `${root}:` || line.startsWith(`${root}: `) || line.startsWith(`${root}${path.sep}`))
|
|
66
|
+
&& separator >= root.length && line.endsWith(":")) {
|
|
67
|
+
flush();
|
|
68
|
+
const target = line.slice(0, separator);
|
|
69
|
+
const relative = target === root ? "." : path.relative(root, target).split(path.sep).join("/");
|
|
70
|
+
if (!relative || relative === ".." || relative.startsWith("../") || path.isAbsolute(relative)) {
|
|
71
|
+
throw setupError("PM_STUDIO_XATTR_INVALID", `Extended attribute path escapes PM Studio: ${target}`);
|
|
72
|
+
}
|
|
73
|
+
current = { path: relative, name: line.slice(separator + 2, -1), hex: [] };
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (!current) throw setupError("PM_STUDIO_XATTR_INVALID", "Could not parse PM Studio extended attributes");
|
|
77
|
+
const data = line.includes(" |") ? line.slice(0, line.indexOf(" |")) : line;
|
|
78
|
+
const tokens = data.trim().split(/\s+/);
|
|
79
|
+
if (!/^[0-9a-f]{8}$/i.test(tokens[0]) || tokens.slice(1).some((token) => !/^[0-9a-f]{2}$/i.test(token))) {
|
|
80
|
+
throw setupError("PM_STUDIO_XATTR_INVALID", "Could not parse PM Studio extended attribute bytes");
|
|
81
|
+
}
|
|
82
|
+
current.hex.push(...tokens.slice(1));
|
|
83
|
+
}
|
|
84
|
+
flush();
|
|
85
|
+
return records.sort((left, right) => Buffer.compare(
|
|
86
|
+
Buffer.from(`${left[1]}\0${left[2]}`),
|
|
87
|
+
Buffer.from(`${right[1]}\0${right[2]}`),
|
|
88
|
+
));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function inspectBundleContent(appPath, {
|
|
92
|
+
xattrOutput = "",
|
|
93
|
+
ignoredXattrs = [],
|
|
94
|
+
readBuffer,
|
|
95
|
+
} = {}) {
|
|
96
|
+
const root = path.resolve(appPath);
|
|
97
|
+
const rootStat = fs.lstatSync(root);
|
|
98
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
|
99
|
+
throw setupError("PM_STUDIO_APP_INVALID", `PM Studio path is not a regular app bundle: ${appPath}`);
|
|
100
|
+
}
|
|
101
|
+
const records = [];
|
|
102
|
+
const normalizedPaths = new Map();
|
|
103
|
+
let regularFileCount = 0;
|
|
104
|
+
let regularBytes = 0;
|
|
105
|
+
let symlinkCount = 0;
|
|
106
|
+
|
|
107
|
+
const rememberPath = (relative) => {
|
|
108
|
+
const normalized = relative.normalize("NFC");
|
|
109
|
+
const existing = normalizedPaths.get(normalized);
|
|
110
|
+
if (existing && existing !== relative) {
|
|
111
|
+
throw setupError("PM_STUDIO_BUNDLE_PATH_COLLISION", `PM Studio contains colliding paths: ${existing} and ${relative}`);
|
|
112
|
+
}
|
|
113
|
+
normalizedPaths.set(normalized, relative);
|
|
114
|
+
};
|
|
115
|
+
rememberPath(".");
|
|
116
|
+
records.push(["directory", ".", rootStat.mode & 0o7777]);
|
|
117
|
+
const visit = (directory) => {
|
|
118
|
+
const names = fs.readdirSync(directory).sort((left, right) =>
|
|
119
|
+
Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
|
120
|
+
for (const name of names) {
|
|
121
|
+
const fullPath = path.join(directory, name);
|
|
122
|
+
const stat = fs.lstatSync(fullPath);
|
|
123
|
+
const relative = path.relative(root, fullPath).split(path.sep).join("/");
|
|
124
|
+
const mode = stat.mode & 0o7777;
|
|
125
|
+
rememberPath(relative);
|
|
126
|
+
if (stat.isDirectory() && !stat.isSymbolicLink()) {
|
|
127
|
+
records.push(["directory", relative, mode]);
|
|
128
|
+
visit(fullPath);
|
|
129
|
+
} else if (stat.isSymbolicLink()) {
|
|
130
|
+
const target = fs.readlinkSync(fullPath);
|
|
131
|
+
const resolved = path.resolve(path.dirname(fullPath), target);
|
|
132
|
+
if (path.isAbsolute(target)
|
|
133
|
+
|| (resolved !== root && !resolved.startsWith(`${root}${path.sep}`))
|
|
134
|
+
|| !fs.existsSync(resolved)) {
|
|
135
|
+
throw setupError("PM_STUDIO_BUNDLE_SYMLINK_INVALID", `PM Studio contains an unsafe symlink: ${relative}`);
|
|
136
|
+
}
|
|
137
|
+
symlinkCount += 1;
|
|
138
|
+
records.push(["symlink", relative, mode, target]);
|
|
139
|
+
} else if (stat.isFile()) {
|
|
140
|
+
if (stat.nlink !== 1) {
|
|
141
|
+
throw setupError("PM_STUDIO_BUNDLE_HARDLINK_INVALID", `PM Studio contains a hard-linked file: ${relative}`);
|
|
142
|
+
}
|
|
143
|
+
regularFileCount += 1;
|
|
144
|
+
regularBytes += stat.size;
|
|
145
|
+
records.push(["file", relative, mode, stat.size, hashRegularFile(fullPath, stat, readBuffer)]);
|
|
146
|
+
} else {
|
|
147
|
+
throw setupError("PM_STUDIO_BUNDLE_ENTRY_INVALID", `PM Studio contains an unsupported entry: ${relative}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
visit(root);
|
|
152
|
+
|
|
153
|
+
const xattrRecords = parseXattrHex(xattrOutput, root, ignoredXattrs);
|
|
154
|
+
const hash = createHash("sha256");
|
|
155
|
+
for (const record of [...records, ...xattrRecords]) hash.update(`${JSON.stringify(record)}\n`);
|
|
156
|
+
return {
|
|
157
|
+
scheme: BUNDLE_CONTENT_SCHEME,
|
|
158
|
+
sha256: hash.digest("hex"),
|
|
159
|
+
entryCount: records.length,
|
|
160
|
+
regularFileCount,
|
|
161
|
+
regularBytes,
|
|
162
|
+
symlinkCount,
|
|
163
|
+
xattrCount: xattrRecords.length,
|
|
164
|
+
};
|
|
165
|
+
}
|
package/src/pm-studio-setup.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import {
|
|
7
7
|
AUTH_PROFILE_CLAUDE,
|
|
@@ -20,12 +20,14 @@ import {
|
|
|
20
20
|
patchAsarBuffer,
|
|
21
21
|
sha256Hex,
|
|
22
22
|
} from "./pm-studio-asar.mjs";
|
|
23
|
+
import { inspectBundleContent } from "./pm-studio-bundle-content.mjs";
|
|
23
24
|
import { withPmStudioSetupLock } from "./pm-studio-setup-lock.mjs";
|
|
24
25
|
|
|
26
|
+
export { inspectBundleContent } from "./pm-studio-bundle-content.mjs";
|
|
27
|
+
|
|
25
28
|
export const DEFAULT_PM_STUDIO_APP_PATH = "/Applications/PM Studio.app";
|
|
26
29
|
export const PM_STUDIO_CLAUDE_AUTH_COMMAND = "ccdx auth login claude --reauth --github-login <personal-login>";
|
|
27
30
|
const MIN_FREE_MARGIN_BYTES = 16 * 1024 * 1024;
|
|
28
|
-
const BUNDLE_CONTENT_SCHEME = "ccdx-bundle-content-v2";
|
|
29
31
|
|
|
30
32
|
function setupError(code, message, details) {
|
|
31
33
|
const error = new Error(message);
|
|
@@ -60,158 +62,6 @@ function runChecked(processRunner, command, args, label) {
|
|
|
60
62
|
return result;
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
function hashRegularFile(filePath, expected, readBuffer) {
|
|
64
|
-
const hash = createHash("sha256");
|
|
65
|
-
const file = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
66
|
-
const buffer = readBuffer || Buffer.allocUnsafe(1024 * 1024);
|
|
67
|
-
try {
|
|
68
|
-
const before = fs.fstatSync(file, { bigint: true });
|
|
69
|
-
if (before.dev !== BigInt(expected.dev)
|
|
70
|
-
|| before.ino !== BigInt(expected.ino)
|
|
71
|
-
|| before.size !== BigInt(expected.size)) {
|
|
72
|
-
throw setupError("PM_STUDIO_BUNDLE_CHANGED", `PM Studio bundle changed while reading ${filePath}`);
|
|
73
|
-
}
|
|
74
|
-
for (;;) {
|
|
75
|
-
const length = fs.readSync(file, buffer, 0, buffer.length, null);
|
|
76
|
-
if (length === 0) break;
|
|
77
|
-
hash.update(buffer.subarray(0, length));
|
|
78
|
-
}
|
|
79
|
-
const after = fs.fstatSync(file, { bigint: true });
|
|
80
|
-
if (before.dev !== after.dev
|
|
81
|
-
|| before.ino !== after.ino
|
|
82
|
-
|| before.size !== after.size
|
|
83
|
-
|| before.mtimeNs !== after.mtimeNs
|
|
84
|
-
|| before.ctimeNs !== after.ctimeNs) {
|
|
85
|
-
throw setupError("PM_STUDIO_BUNDLE_CHANGED", `PM Studio bundle changed while hashing ${filePath}`);
|
|
86
|
-
}
|
|
87
|
-
} finally {
|
|
88
|
-
fs.closeSync(file);
|
|
89
|
-
}
|
|
90
|
-
return hash.digest("hex");
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function parseXattrHex(output, root, ignoredXattrs) {
|
|
94
|
-
const records = [];
|
|
95
|
-
const ignored = new Set(ignoredXattrs);
|
|
96
|
-
const seen = new Set();
|
|
97
|
-
let current = null;
|
|
98
|
-
const flush = () => {
|
|
99
|
-
if (!current) return;
|
|
100
|
-
if (!ignored.has(current.name)) {
|
|
101
|
-
const key = `${current.path}\0${current.name}`;
|
|
102
|
-
if (seen.has(key)) throw setupError("PM_STUDIO_XATTR_INVALID", `Duplicate extended attribute ${current.name}`);
|
|
103
|
-
seen.add(key);
|
|
104
|
-
const value = Buffer.from(current.hex.join(""), "hex");
|
|
105
|
-
records.push(["xattr", current.path, current.name, value.length, sha256Hex(value)]);
|
|
106
|
-
}
|
|
107
|
-
current = null;
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
for (const line of String(output || "").split(/\r?\n/)) {
|
|
111
|
-
if (!line) continue;
|
|
112
|
-
const separator = line.lastIndexOf(": ");
|
|
113
|
-
if ((line === `${root}:` || line.startsWith(`${root}: `) || line.startsWith(`${root}${path.sep}`))
|
|
114
|
-
&& separator >= root.length && line.endsWith(":")) {
|
|
115
|
-
flush();
|
|
116
|
-
const target = line.slice(0, separator);
|
|
117
|
-
const relative = target === root ? "." : path.relative(root, target).split(path.sep).join("/");
|
|
118
|
-
if (!relative || relative === ".." || relative.startsWith("../") || path.isAbsolute(relative)) {
|
|
119
|
-
throw setupError("PM_STUDIO_XATTR_INVALID", `Extended attribute path escapes PM Studio: ${target}`);
|
|
120
|
-
}
|
|
121
|
-
current = { path: relative, name: line.slice(separator + 2, -1), hex: [] };
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
if (!current) throw setupError("PM_STUDIO_XATTR_INVALID", "Could not parse PM Studio extended attributes");
|
|
125
|
-
const data = line.includes(" |") ? line.slice(0, line.indexOf(" |")) : line;
|
|
126
|
-
const tokens = data.trim().split(/\s+/);
|
|
127
|
-
if (!/^[0-9a-f]{8}$/i.test(tokens[0]) || tokens.slice(1).some((token) => !/^[0-9a-f]{2}$/i.test(token))) {
|
|
128
|
-
throw setupError("PM_STUDIO_XATTR_INVALID", "Could not parse PM Studio extended attribute bytes");
|
|
129
|
-
}
|
|
130
|
-
current.hex.push(...tokens.slice(1));
|
|
131
|
-
}
|
|
132
|
-
flush();
|
|
133
|
-
return records.sort((left, right) => Buffer.compare(
|
|
134
|
-
Buffer.from(`${left[1]}\0${left[2]}`),
|
|
135
|
-
Buffer.from(`${right[1]}\0${right[2]}`),
|
|
136
|
-
));
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export function inspectBundleContent(appPath, {
|
|
140
|
-
xattrOutput = "",
|
|
141
|
-
ignoredXattrs = [],
|
|
142
|
-
readBuffer,
|
|
143
|
-
} = {}) {
|
|
144
|
-
const root = path.resolve(appPath);
|
|
145
|
-
const rootStat = fs.lstatSync(root);
|
|
146
|
-
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
|
147
|
-
throw setupError("PM_STUDIO_APP_INVALID", `PM Studio path is not a regular app bundle: ${appPath}`);
|
|
148
|
-
}
|
|
149
|
-
const records = [];
|
|
150
|
-
const normalizedPaths = new Map();
|
|
151
|
-
let regularFileCount = 0;
|
|
152
|
-
let regularBytes = 0;
|
|
153
|
-
let symlinkCount = 0;
|
|
154
|
-
|
|
155
|
-
const rememberPath = (relative) => {
|
|
156
|
-
const normalized = relative.normalize("NFC");
|
|
157
|
-
const existing = normalizedPaths.get(normalized);
|
|
158
|
-
if (existing && existing !== relative) {
|
|
159
|
-
throw setupError("PM_STUDIO_BUNDLE_PATH_COLLISION", `PM Studio contains colliding paths: ${existing} and ${relative}`);
|
|
160
|
-
}
|
|
161
|
-
normalizedPaths.set(normalized, relative);
|
|
162
|
-
};
|
|
163
|
-
rememberPath(".");
|
|
164
|
-
records.push(["directory", ".", rootStat.mode & 0o7777]);
|
|
165
|
-
const visit = (directory) => {
|
|
166
|
-
const names = fs.readdirSync(directory).sort((left, right) =>
|
|
167
|
-
Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
|
168
|
-
for (const name of names) {
|
|
169
|
-
const fullPath = path.join(directory, name);
|
|
170
|
-
const stat = fs.lstatSync(fullPath);
|
|
171
|
-
const relative = path.relative(root, fullPath).split(path.sep).join("/");
|
|
172
|
-
const mode = stat.mode & 0o7777;
|
|
173
|
-
rememberPath(relative);
|
|
174
|
-
if (stat.isDirectory() && !stat.isSymbolicLink()) {
|
|
175
|
-
records.push(["directory", relative, mode]);
|
|
176
|
-
visit(fullPath);
|
|
177
|
-
} else if (stat.isSymbolicLink()) {
|
|
178
|
-
const target = fs.readlinkSync(fullPath);
|
|
179
|
-
const resolved = path.resolve(path.dirname(fullPath), target);
|
|
180
|
-
if (path.isAbsolute(target)
|
|
181
|
-
|| (resolved !== root && !resolved.startsWith(`${root}${path.sep}`))
|
|
182
|
-
|| !fs.existsSync(resolved)) {
|
|
183
|
-
throw setupError("PM_STUDIO_BUNDLE_SYMLINK_INVALID", `PM Studio contains an unsafe symlink: ${relative}`);
|
|
184
|
-
}
|
|
185
|
-
symlinkCount += 1;
|
|
186
|
-
records.push(["symlink", relative, mode, target]);
|
|
187
|
-
} else if (stat.isFile()) {
|
|
188
|
-
if (stat.nlink !== 1) {
|
|
189
|
-
throw setupError("PM_STUDIO_BUNDLE_HARDLINK_INVALID", `PM Studio contains a hard-linked file: ${relative}`);
|
|
190
|
-
}
|
|
191
|
-
regularFileCount += 1;
|
|
192
|
-
regularBytes += stat.size;
|
|
193
|
-
records.push(["file", relative, mode, stat.size, hashRegularFile(fullPath, stat, readBuffer)]);
|
|
194
|
-
} else {
|
|
195
|
-
throw setupError("PM_STUDIO_BUNDLE_ENTRY_INVALID", `PM Studio contains an unsupported entry: ${relative}`);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
};
|
|
199
|
-
visit(root);
|
|
200
|
-
|
|
201
|
-
const xattrRecords = parseXattrHex(xattrOutput, root, ignoredXattrs);
|
|
202
|
-
const hash = createHash("sha256");
|
|
203
|
-
for (const record of [...records, ...xattrRecords]) hash.update(`${JSON.stringify(record)}\n`);
|
|
204
|
-
return {
|
|
205
|
-
scheme: BUNDLE_CONTENT_SCHEME,
|
|
206
|
-
sha256: hash.digest("hex"),
|
|
207
|
-
entryCount: records.length,
|
|
208
|
-
regularFileCount,
|
|
209
|
-
regularBytes,
|
|
210
|
-
symlinkCount,
|
|
211
|
-
xattrCount: xattrRecords.length,
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
|
|
215
65
|
function defaultInspectBundleContent({ appPath, recipe, processRunner, readBuffer }) {
|
|
216
66
|
const xattrs = runChecked(processRunner, "/usr/bin/xattr", ["-r", "-s", "-x", "-l", appPath],
|
|
217
67
|
"Reading PM Studio extended attributes");
|
|
@@ -216,6 +216,7 @@ export function createResponsesHandler(options) {
|
|
|
216
216
|
upstreamModel = priorityTierModel;
|
|
217
217
|
delete prepared.body.service_tier;
|
|
218
218
|
}
|
|
219
|
+
if (requestedModel === CODEX_AUTO_REVIEW_MODEL) delete prepared.body.service_tier;
|
|
219
220
|
if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
|
|
220
221
|
const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
|
|
221
222
|
console.log(status("info", `responses model=${requestedModel}${upstreamLog} stream=${streaming}`));
|
|
@@ -464,6 +465,7 @@ export function createResponsesCompactHandler(options) {
|
|
|
464
465
|
upstreamModel = priorityTierModel;
|
|
465
466
|
delete prepared.body.service_tier;
|
|
466
467
|
}
|
|
468
|
+
if (requestedModel === CODEX_AUTO_REVIEW_MODEL) delete prepared.body.service_tier;
|
|
467
469
|
if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
|
|
468
470
|
const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
|
|
469
471
|
console.log(status("info", `responses compact model=${requestedModel}${upstreamLog} stream=false`));
|
package/src/runtime-config.mjs
CHANGED
|
@@ -17,17 +17,33 @@ export const RUNTIME_DEFAULTS = Object.freeze({
|
|
|
17
17
|
tokenLockStaleMs: 15 * 60 * 1000,
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
// Node clamps larger timer delays to 1ms. Keep timer parsing separate from
|
|
21
|
+
// byte/count limits so valid large resource budgets remain configurable.
|
|
22
|
+
export const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
23
|
+
|
|
20
24
|
export function parsePositiveInteger(value, fallback, max = Number.POSITIVE_INFINITY) {
|
|
21
25
|
const parsed = Number.parseInt(value, 10);
|
|
22
26
|
return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, max) : fallback;
|
|
23
27
|
}
|
|
24
28
|
|
|
29
|
+
export function parseSafePositiveInteger(value, fallback) {
|
|
30
|
+
const text = typeof value === "number" ? String(value) : String(value ?? "").trim();
|
|
31
|
+
if (!/^\d+$/.test(text)) return fallback;
|
|
32
|
+
const parsed = Number(text);
|
|
33
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) return fallback;
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseTimerMs(value, fallback) {
|
|
38
|
+
return Math.min(parseSafePositiveInteger(value, fallback), MAX_TIMER_DELAY_MS);
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
export function loadRuntimeConfig(env = process.env) {
|
|
26
42
|
return Object.freeze({
|
|
27
|
-
upstreamTimeoutMs:
|
|
28
|
-
streamHandshakeTimeoutMs:
|
|
29
|
-
streamIdleTimeoutMs:
|
|
30
|
-
requestBodyTimeoutMs:
|
|
43
|
+
upstreamTimeoutMs: parseTimerMs(env.CCDX_UPSTREAM_TIMEOUT_MS, RUNTIME_DEFAULTS.upstreamTimeoutMs),
|
|
44
|
+
streamHandshakeTimeoutMs: parseTimerMs(env.CCDX_STREAM_HANDSHAKE_TIMEOUT_MS, RUNTIME_DEFAULTS.streamHandshakeTimeoutMs),
|
|
45
|
+
streamIdleTimeoutMs: parseTimerMs(env.CCDX_STREAM_IDLE_TIMEOUT_MS, RUNTIME_DEFAULTS.streamIdleTimeoutMs),
|
|
46
|
+
requestBodyTimeoutMs: parseTimerMs(env.CCDX_REQUEST_BODY_TIMEOUT_MS, RUNTIME_DEFAULTS.requestBodyTimeoutMs),
|
|
31
47
|
maxSseBufferBytes: parsePositiveInteger(env.CCDX_MAX_SSE_BUFFER_BYTES, RUNTIME_DEFAULTS.maxSseBufferBytes),
|
|
32
48
|
maxBodyBytes: parsePositiveInteger(env.CCDX_MAX_BODY_BYTES, RUNTIME_DEFAULTS.maxBodyBytes),
|
|
33
49
|
maxDecodedBodyBytes: parsePositiveInteger(env.CCDX_MAX_DECODED_BODY_BYTES, RUNTIME_DEFAULTS.maxDecodedBodyBytes),
|
|
@@ -41,10 +57,10 @@ export function loadRuntimeConfig(env = process.env) {
|
|
|
41
57
|
RUNTIME_DEFAULTS.maxUpstreamResponsesResponseBytes,
|
|
42
58
|
),
|
|
43
59
|
maxQueuedRequests: parsePositiveInteger(env.CCDX_MAX_QUEUED_REQUESTS, RUNTIME_DEFAULTS.maxQueuedRequests),
|
|
44
|
-
requestQueueTimeoutMs:
|
|
60
|
+
requestQueueTimeoutMs: parseTimerMs(env.CCDX_REQUEST_QUEUE_TIMEOUT_MS, RUNTIME_DEFAULTS.requestQueueTimeoutMs),
|
|
45
61
|
responseHistoryMaxBytes: parsePositiveInteger(env.CCDX_RESPONSE_HISTORY_MAX_BYTES, RUNTIME_DEFAULTS.responseHistoryMaxBytes),
|
|
46
62
|
responseHistoryMaxEntries: parsePositiveInteger(env.CCDX_RESPONSE_HISTORY_MAX_ENTRIES, RUNTIME_DEFAULTS.responseHistoryMaxEntries),
|
|
47
|
-
tokenLockTimeoutMs:
|
|
48
|
-
tokenLockStaleMs:
|
|
63
|
+
tokenLockTimeoutMs: parseSafePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
|
|
64
|
+
tokenLockStaleMs: parseSafePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
|
|
49
65
|
});
|
|
50
66
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Worker } from "node:worker_threads";
|
|
2
2
|
import { anthropicTokenText } from "./anthropic.mjs";
|
|
3
|
+
import { debugLog } from "./log.mjs";
|
|
3
4
|
|
|
4
5
|
export const DEFAULT_TOKEN_COUNTER_TIMEOUT_MS = 120 * 1000;
|
|
5
6
|
export const DEFAULT_TOKEN_COUNTER_MAX_QUEUED = 16;
|
|
@@ -8,6 +9,23 @@ function serviceError(message, statusCode) {
|
|
|
8
9
|
return Object.assign(new Error(message), { statusCode });
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
const DIAGNOSTIC_PHASES = new Set(["create", "worker", "error", "exit", "postMessage"]);
|
|
13
|
+
const DIAGNOSTIC_ERROR_NAMES = new Set(["Error", "TypeError", "RangeError", "SyntaxError", "WorkerExit"]);
|
|
14
|
+
|
|
15
|
+
export function tokenCounterWorkerDiagnostic(phase, error) {
|
|
16
|
+
const safePhase = DIAGNOSTIC_PHASES.has(phase) ? phase : "unknown";
|
|
17
|
+
let rawName;
|
|
18
|
+
let rawCode;
|
|
19
|
+
try { rawName = error?.name; } catch {}
|
|
20
|
+
try { rawCode = error?.code; } catch {}
|
|
21
|
+
const safeName = DIAGNOSTIC_ERROR_NAMES.has(rawName) ? rawName : "Error";
|
|
22
|
+
if (typeof rawCode === "number") rawCode = String(rawCode);
|
|
23
|
+
const safeCode = typeof rawCode === "string" && /^(?:\d+|[A-Z][A-Z0-9_]{0,63})$/.test(rawCode)
|
|
24
|
+
? rawCode
|
|
25
|
+
: "unknown";
|
|
26
|
+
return `Token counter worker failure: phase=${safePhase} name=${safeName} code=${safeCode}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
11
29
|
function positiveInteger(value, fallback) {
|
|
12
30
|
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
13
31
|
}
|
|
@@ -74,7 +92,8 @@ export function createTokenCounterService({
|
|
|
74
92
|
if (!worker) {
|
|
75
93
|
try {
|
|
76
94
|
worker = workerFactory();
|
|
77
|
-
} catch {
|
|
95
|
+
} catch (error) {
|
|
96
|
+
debugLog(tokenCounterWorkerDiagnostic("create", error));
|
|
78
97
|
const job = queue.shift();
|
|
79
98
|
if (job) finish(job, job.reject, serviceError("Token counter worker failed", 500));
|
|
80
99
|
queueMicrotask(pump);
|
|
@@ -85,6 +104,7 @@ export function createTokenCounterService({
|
|
|
85
104
|
const job = active;
|
|
86
105
|
active = null;
|
|
87
106
|
if (message.error) {
|
|
107
|
+
debugLog(tokenCounterWorkerDiagnostic("worker", message.error));
|
|
88
108
|
retireWorker();
|
|
89
109
|
finish(job, job.reject, serviceError("Token counter worker failed", 500));
|
|
90
110
|
} else {
|
|
@@ -92,14 +112,16 @@ export function createTokenCounterService({
|
|
|
92
112
|
}
|
|
93
113
|
pump();
|
|
94
114
|
});
|
|
95
|
-
worker.on("error", () => {
|
|
115
|
+
worker.on("error", (error) => {
|
|
116
|
+
debugLog(tokenCounterWorkerDiagnostic("error", error));
|
|
96
117
|
const job = active;
|
|
97
118
|
active = null;
|
|
98
119
|
retireWorker();
|
|
99
120
|
if (job) finish(job, job.reject, serviceError("Token counter worker failed", 500));
|
|
100
121
|
pump();
|
|
101
122
|
});
|
|
102
|
-
worker.on("exit", () => {
|
|
123
|
+
worker.on("exit", (code) => {
|
|
124
|
+
if (active) debugLog(tokenCounterWorkerDiagnostic("exit", { name: "WorkerExit", code }));
|
|
103
125
|
const job = active;
|
|
104
126
|
active = null;
|
|
105
127
|
worker = null;
|
|
@@ -114,7 +136,8 @@ export function createTokenCounterService({
|
|
|
114
136
|
try {
|
|
115
137
|
worker.postMessage({ id: job.id, text: job.text });
|
|
116
138
|
job.text = null;
|
|
117
|
-
} catch {
|
|
139
|
+
} catch (error) {
|
|
140
|
+
debugLog(tokenCounterWorkerDiagnostic("postMessage", error));
|
|
118
141
|
active = null;
|
|
119
142
|
retireWorker();
|
|
120
143
|
finish(job, job.reject, serviceError("Token counter worker failed", 500));
|
|
@@ -4,7 +4,13 @@ import { countTokenText } from "./anthropic.mjs";
|
|
|
4
4
|
parentPort.on("message", async ({ id, text }) => {
|
|
5
5
|
try {
|
|
6
6
|
parentPort.postMessage({ id, result: await countTokenText(text) });
|
|
7
|
-
} catch {
|
|
8
|
-
parentPort.postMessage({
|
|
7
|
+
} catch (error) {
|
|
8
|
+
parentPort.postMessage({
|
|
9
|
+
id,
|
|
10
|
+
error: {
|
|
11
|
+
name: typeof error?.name === "string" ? error.name : "Error",
|
|
12
|
+
code: typeof error?.code === "string" ? error.code : "unknown",
|
|
13
|
+
},
|
|
14
|
+
});
|
|
9
15
|
}
|
|
10
16
|
});
|
package/src/usage.mjs
CHANGED
|
@@ -4,11 +4,17 @@ import path from "node:path";
|
|
|
4
4
|
import readline from "node:readline";
|
|
5
5
|
import { cliOutputFormat, cliOutputWidth, formatResponsiveCliTable, terminalCell } from "./cli-table.mjs";
|
|
6
6
|
import { parseByteLimit, rotateFileIfNeededSync, rotatedFilePath } from "./file-rotation.mjs";
|
|
7
|
+
import { withFileLock } from "./lock.mjs";
|
|
7
8
|
|
|
8
9
|
const DEFAULT_USAGE_PATH = path.join(os.homedir(), ".local", "share", "codex-copilot-dx", "usage.jsonl");
|
|
9
10
|
const DEFAULT_USAGE_MAX_BYTES = 32 * 1024 * 1024;
|
|
11
|
+
const USAGE_WRITE_LOCK_TIMEOUT_MS = 1_000;
|
|
12
|
+
const USAGE_WRITE_LOCK_STALE_MS = 1_000;
|
|
13
|
+
const USAGE_WRITE_LOCK_POLL_MS = 5;
|
|
14
|
+
const USAGE_FLUSH_TIMEOUT_MS = 1_500;
|
|
10
15
|
|
|
11
|
-
|
|
16
|
+
const pendingWrites = [];
|
|
17
|
+
let writeDrain = null;
|
|
12
18
|
|
|
13
19
|
export function usageLogPath() {
|
|
14
20
|
return process.env.CCDX_USAGE_PATH || DEFAULT_USAGE_PATH;
|
|
@@ -119,19 +125,63 @@ export function buildAnthropicUsageRecord({ surface = "messages", mode, model, r
|
|
|
119
125
|
});
|
|
120
126
|
}
|
|
121
127
|
|
|
128
|
+
function takePendingWrites(filePath, maxBytes) {
|
|
129
|
+
const batch = [];
|
|
130
|
+
let retained = 0;
|
|
131
|
+
for (const entry of pendingWrites) {
|
|
132
|
+
if (entry.filePath === filePath && entry.maxBytes === maxBytes) batch.push(entry);
|
|
133
|
+
else pendingWrites[retained++] = entry;
|
|
134
|
+
}
|
|
135
|
+
pendingWrites.length = retained;
|
|
136
|
+
return batch;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function writeUsageBatch(batch) {
|
|
140
|
+
const { filePath, maxBytes } = batch[0];
|
|
141
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
142
|
+
await withFileLock(`${path.resolve(filePath)}.lock`, async () => {
|
|
143
|
+
batch.push(...takePendingWrites(filePath, maxBytes));
|
|
144
|
+
for (const { line } of batch) {
|
|
145
|
+
rotateFileIfNeededSync(filePath, Buffer.byteLength(line), maxBytes);
|
|
146
|
+
await fs.promises.appendFile(filePath, line, { encoding: "utf8", mode: 0o600 });
|
|
147
|
+
}
|
|
148
|
+
}, {
|
|
149
|
+
timeoutMs: USAGE_WRITE_LOCK_TIMEOUT_MS,
|
|
150
|
+
staleMs: USAGE_WRITE_LOCK_STALE_MS,
|
|
151
|
+
pollMs: USAGE_WRITE_LOCK_POLL_MS,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function scheduleUsageWriteDrain() {
|
|
156
|
+
if (writeDrain) return;
|
|
157
|
+
writeDrain = Promise.resolve().then(async () => {
|
|
158
|
+
while (pendingWrites.length > 0) {
|
|
159
|
+
const first = pendingWrites.shift();
|
|
160
|
+
const batch = [first, ...takePendingWrites(first.filePath, first.maxBytes)];
|
|
161
|
+
try {
|
|
162
|
+
await writeUsageBatch(batch);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
batch.push(...takePendingWrites(first.filePath, first.maxBytes));
|
|
165
|
+
console.error(`codex-copilot-dx usage log write failed: ${error.message}`);
|
|
166
|
+
} finally {
|
|
167
|
+
for (const entry of batch) entry.resolve();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}).finally(() => {
|
|
171
|
+
writeDrain = null;
|
|
172
|
+
if (pendingWrites.length > 0) scheduleUsageWriteDrain();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
122
176
|
export function recordUsage(record) {
|
|
123
177
|
if (!record || process.env.CCDX_DISABLE_USAGE === "1") return Promise.resolve();
|
|
124
178
|
const filePath = usageLogPath();
|
|
125
179
|
const line = `${JSON.stringify(record)}\n`;
|
|
126
|
-
|
|
127
|
-
.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
await fs.promises.appendFile(filePath, line, { encoding: "utf8", mode: 0o600 });
|
|
132
|
-
})
|
|
133
|
-
.catch((e) => console.error(`codex-copilot-dx usage log write failed: ${e.message}`));
|
|
134
|
-
return writeQueue;
|
|
180
|
+
const completed = new Promise((resolve) => {
|
|
181
|
+
pendingWrites.push({ filePath, line, maxBytes: usageLogMaxBytes(), resolve });
|
|
182
|
+
});
|
|
183
|
+
scheduleUsageWriteDrain();
|
|
184
|
+
return completed;
|
|
135
185
|
}
|
|
136
186
|
|
|
137
187
|
export function recordResponsesUsage(args) {
|
|
@@ -143,11 +193,28 @@ export function recordAnthropicUsage(args) {
|
|
|
143
193
|
}
|
|
144
194
|
|
|
145
195
|
export async function flushUsageWritesForTests() {
|
|
146
|
-
|
|
196
|
+
while (writeDrain || pendingWrites.length > 0) {
|
|
197
|
+
if (writeDrain) await writeDrain;
|
|
198
|
+
else scheduleUsageWriteDrain();
|
|
199
|
+
}
|
|
147
200
|
}
|
|
148
201
|
|
|
149
|
-
export async function flushUsageWrites(
|
|
150
|
-
|
|
202
|
+
export async function flushUsageWrites({
|
|
203
|
+
timeoutMs = USAGE_FLUSH_TIMEOUT_MS,
|
|
204
|
+
warn = console.error,
|
|
205
|
+
} = {}) {
|
|
206
|
+
const timeout = Number.isFinite(timeoutMs) && timeoutMs >= 0
|
|
207
|
+
? Math.floor(timeoutMs)
|
|
208
|
+
: USAGE_FLUSH_TIMEOUT_MS;
|
|
209
|
+
let timer;
|
|
210
|
+
const completed = await Promise.race([
|
|
211
|
+
flushUsageWritesForTests().then(() => true),
|
|
212
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeout); }),
|
|
213
|
+
]);
|
|
214
|
+
if (timer) clearTimeout(timer);
|
|
215
|
+
if (!completed) {
|
|
216
|
+
try { warn(`codex-copilot-dx usage log flush timed out after ${timeout}ms`); } catch {}
|
|
217
|
+
}
|
|
151
218
|
}
|
|
152
219
|
|
|
153
220
|
async function* iterateUsageRecords(filePath, { warn = console.error } = {}) {
|