@bitkyc08/opencodex 2.81.0 → 2.82.0
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/bin/ocx.mjs +166 -73
- package/gui/dist/assets/App-Ds_qL7o8.js +54 -0
- package/gui/dist/assets/{Tray-EwAOsP0S.js → Tray-BqHF5Trd.js} +1 -1
- package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
- package/gui/dist/assets/{index-RsoaAxXY.js → index-D3FBCNiP.js} +13 -13
- package/gui/dist/assets/{quota-summary-D2lN8tDm.js → quota-summary-DTBC8n7-.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
- package/src/adapters/openai-responses/passthrough.ts +9 -4
- package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
- package/src/claude/agents-inject.ts +14 -3
- package/src/claude/context-windows.ts +19 -17
- package/src/claude/desktop-3p.ts +88 -12
- package/src/claude/desktop-profile.ts +2 -1
- package/src/claude/gateway-cache.ts +18 -1
- package/src/claude/inbound.ts +77 -3
- package/src/claude/intercept/cli-catalog.ts +3 -0
- package/src/claude/intercept/picker-ca-persistence.ts +7 -6
- package/src/claude/intercept/picker-models.ts +14 -6
- package/src/claude/intercept/settings.ts +3 -0
- package/src/claude/long-context.ts +66 -0
- package/src/claude/model-info.ts +19 -16
- package/src/claude/outbound.ts +94 -9
- package/src/claude/subagent-model.ts +40 -11
- package/src/cli/agent.ts +9 -5
- package/src/cli/aside-profile-recovery.ts +33 -0
- package/src/cli/capabilities-base.ts +5 -5
- package/src/cli/capabilities-integrations.ts +2 -2
- package/src/cli/cli-path-diagnostics.ts +132 -0
- package/src/cli/desktop-runtime-guidance.ts +65 -0
- package/src/cli/dispatch.ts +5 -0
- package/src/cli/doctor.ts +16 -3
- package/src/cli/index.ts +14 -9
- package/src/cli/integration-aside-sync.ts +2 -1
- package/src/cli/integrations.ts +16 -3
- package/src/cli/resolve.ts +25 -2
- package/src/cli/root.ts +4 -2
- package/src/cli/status.ts +64 -12
- package/src/cli/update-restart-child.ts +4 -2
- package/src/cli/update-restart-home.ts +28 -10
- package/src/cli/update-restart-service-record.ts +116 -0
- package/src/cli/update-restart-supervision.ts +96 -0
- package/src/cli/update-restart.ts +59 -13
- package/src/cli/v2.ts +27 -20
- package/src/cli/version-skew-notice.ts +77 -0
- package/src/clients/lazycodex.ts +1 -1
- package/src/clients/omo-role-models.ts +32 -7
- package/src/codex/auth-api/account-selection.ts +107 -0
- package/src/codex/auth-api/routes.ts +10 -49
- package/src/codex/auth-context.ts +20 -1
- package/src/codex/autostart-health.ts +12 -3
- package/src/codex/catalog/build-entries.ts +18 -0
- package/src/codex/catalog/bundled.ts +155 -26
- package/src/codex/catalog/combo-member.ts +3 -2
- package/src/codex/catalog/effort.ts +26 -5
- package/src/codex/catalog/gather-capture.ts +5 -0
- package/src/codex/catalog/model-hints.ts +15 -8
- package/src/codex/catalog/model-visibility.ts +3 -1
- package/src/codex/catalog/operator-tiers.ts +38 -0
- package/src/codex/catalog/provider-models.ts +43 -35
- package/src/codex/catalog/retained-sync.ts +7 -5
- package/src/codex/catalog/routed-gather.ts +15 -1
- package/src/codex/catalog-auto-refresh-sources.ts +3 -4
- package/src/codex/catalog-auto-refresh.ts +19 -0
- package/src/codex/catalog.ts +1 -1
- package/src/codex/codex-home-owner.ts +3 -0
- package/src/codex/config-write-lock.ts +333 -0
- package/src/codex/convergence.ts +2 -1
- package/src/codex/features.ts +257 -200
- package/src/codex/inject/config-write-section.ts +34 -0
- package/src/codex/inject/multi-agent-v2.ts +14 -6
- package/src/codex/inject/remove.ts +36 -11
- package/src/codex/inject/restore.ts +85 -20
- package/src/codex/inject-coordination.ts +15 -6
- package/src/codex/inject.ts +62 -20
- package/src/codex/journal.ts +62 -17
- package/src/codex/main-account-cache.ts +43 -0
- package/src/codex/paths.ts +2 -0
- package/src/codex/prepared-home.ts +18 -0
- package/src/codex/prompt-journal.ts +25 -12
- package/src/codex/prompt-layers.ts +49 -26
- package/src/codex/prompt-lock-claim.ts +134 -0
- package/src/codex/prompt-lock-evidence.ts +26 -0
- package/src/codex/prompt-lock-io.ts +16 -0
- package/src/codex/prompt-lock-owner.ts +130 -0
- package/src/codex/prompt-lock.ts +84 -53
- package/src/codex/routing/failure-window.ts +146 -0
- package/src/codex/routing/selection.ts +4 -1
- package/src/codex/routing.ts +13 -17
- package/src/codex/runtime.ts +106 -18
- package/src/codex/shim.ts +45 -19
- package/src/combos/jev-service-exchange.ts +281 -0
- package/src/combos/jev.ts +34 -220
- package/src/config/atomic-write.ts +1 -1
- package/src/config/codex-ws-reuse-setting.ts +10 -0
- package/src/config/live-reconcile.ts +17 -0
- package/src/config/load-degrade.ts +8 -0
- package/src/config/rebase-provenance.ts +11 -0
- package/src/config/schema/config-schema.ts +4 -0
- package/src/generated/compatibility-version.json +303 -171
- package/src/generated/model-metadata.ts +2 -2
- package/src/grok/inject.ts +50 -4
- package/src/grok/sync.ts +2 -1
- package/src/images/loop.ts +29 -11
- package/src/integrations/aside-profiles.ts +10 -3
- package/src/integrations/catalog-refresh.ts +18 -2
- package/src/integrations/writer.ts +9 -3
- package/src/lib/bun-path-runtime.d.mts +32 -0
- package/src/lib/bun-path-runtime.mjs +95 -0
- package/src/lib/bun-runtime-preflight.ts +140 -0
- package/src/lib/desktop-cli-handoff.d.mts +20 -0
- package/src/lib/desktop-cli-handoff.mjs +92 -0
- package/src/lib/desktop-cli-record.d.mts +29 -0
- package/src/lib/desktop-cli-record.mjs +102 -0
- package/src/lib/request-execution-budget.ts +8 -0
- package/src/lib/synced-state-location.ts +46 -6
- package/src/lib/test-home-guard.ts +87 -1
- package/src/lib/translator-budget.ts +17 -4
- package/src/lib/windows-atomic-replace.ts +38 -16
- package/src/lib/windows-elevation.ts +6 -0
- package/src/lib/windows-secret-acl.ts +92 -25
- package/src/lib/winsw.ts +2 -3
- package/src/oauth/anthropic-account-refusal.ts +39 -1
- package/src/oauth/anthropic-routing.ts +1 -1
- package/src/oauth/anthropic-send-ownership.ts +3 -3
- package/src/oauth/chatgpt.ts +113 -41
- package/src/oauth/index.ts +6 -2
- package/src/oauth/store.ts +1 -1
- package/src/providers/azure-model-metadata.ts +115 -0
- package/src/providers/derive.ts +21 -0
- package/src/providers/quota/vendor-probes-key.ts +127 -17
- package/src/providers/quota.ts +1 -1
- package/src/responses/reasoning-envelope.ts +29 -1
- package/src/responses/reasoning-replay-cache.ts +17 -1
- package/src/responses/spill-store.ts +15 -2
- package/src/responses/state/metrics.ts +8 -0
- package/src/responses/state/spill-failure.ts +12 -0
- package/src/responses/state/spill-queue.ts +12 -5
- package/src/responses/state.ts +18 -10
- package/src/responses/task-input.ts +17 -0
- package/src/server/background-lifecycle.ts +27 -0
- package/src/server/chat-completions.ts +1 -0
- package/src/server/chat-native.ts +28 -12
- package/src/server/claude-messages.ts +43 -6
- package/src/server/inbound-body-admission.ts +1 -0
- package/src/server/index/claude-intercept-lifecycle.ts +3 -1
- package/src/server/index/serve-options.ts +10 -2
- package/src/server/index/spend-ledger-lifecycle.ts +2 -1
- package/src/server/index/startup-warnings.ts +19 -0
- package/src/server/index.ts +1 -0
- package/src/server/management/agent-settings-routes.ts +93 -32
- package/src/server/management/codex-agent-role-routes.ts +4 -2
- package/src/server/management/codex-prompt-routes.ts +1 -0
- package/src/server/management/codex-role-auto-assign.ts +13 -2
- package/src/server/management/config-routes.ts +59 -8
- package/src/server/management/context.ts +2 -2
- package/src/server/management/shared.ts +3 -3
- package/src/server/messages-native-oauth.ts +9 -8
- package/src/server/messages-native.ts +49 -13
- package/src/server/messages-response-headers.ts +14 -0
- package/src/server/request-log-conversation.ts +48 -5
- package/src/server/request-log.ts +31 -2
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +34 -13
- package/src/server/responses/codex-ws-exchange.ts +17 -2
- package/src/server/responses/codex-ws-pool.ts +100 -13
- package/src/server/responses/codex-ws-wire.ts +9 -0
- package/src/server/responses/combo-requested-effort.ts +3 -1
- package/src/server/responses/core-codex-account.ts +22 -1
- package/src/server/responses/core-combo-native.ts +20 -3
- package/src/server/responses/core-combo.ts +22 -14
- package/src/server/responses/core-lifetime.ts +3 -36
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/core-replay.ts +73 -0
- package/src/server/responses/core.ts +3 -3
- package/src/server/responses/fetch-helpers.ts +26 -3
- package/src/server/responses/passthrough-delivery.ts +24 -2
- package/src/server/responses/passthrough-dispatch.ts +19 -2
- package/src/server/responses/request-prepare.ts +4 -0
- package/src/server/responses/request-send-budget.ts +112 -49
- package/src/server/responses/response-effects.ts +1 -0
- package/src/server/responses/run-turn-execution.ts +2 -0
- package/src/server/responses/sidecar-execution.ts +25 -16
- package/src/server/responses/sidecar-send-budget.ts +58 -0
- package/src/server/responses/ws-upstream.ts +19 -6
- package/src/server/startup-health-cache.ts +2 -1
- package/src/server/workflow-refusal.ts +3 -0
- package/src/service/cli.ts +20 -4
- package/src/service/desktop-command-guard.ts +32 -0
- package/src/service/desktop-startup.ts +66 -40
- package/src/service/desktop-supervision.d.mts +26 -0
- package/src/service/desktop-supervision.mjs +140 -0
- package/src/service/managing-cli.ts +1 -0
- package/src/service/orchestration.ts +46 -20
- package/src/service/repair.ts +25 -9
- package/src/service/state.ts +4 -3
- package/src/service/systemd.ts +17 -8
- package/src/service/windows-ops.ts +61 -22
- package/src/storage/policy-job.ts +5 -1
- package/src/storage/policy-worker.ts +3 -1
- package/src/storage/policy.ts +9 -0
- package/src/types/config.ts +21 -0
- package/src/types/request.ts +5 -0
- package/src/types/wire.ts +1 -0
- package/src/update/index.ts +57 -9
- package/src/update/restart-ownership.ts +10 -3
- package/src/update/runtime-ownership.d.mts +6 -0
- package/src/update/runtime-ownership.mjs +23 -7
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/App-7yhoys8a.js +0 -54
|
@@ -191,6 +191,7 @@ class Budget implements TranslatorBudget {
|
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
openCall(id: string): void {
|
|
194
|
+
if (this.disposed) return;
|
|
194
195
|
if (!this.calls.has(id)) {
|
|
195
196
|
this.calls.set(id, { logicalBytes: 0, physicalBytes: 0 });
|
|
196
197
|
aggregateActiveCalls += 1;
|
|
@@ -210,6 +211,7 @@ class Budget implements TranslatorBudget {
|
|
|
210
211
|
bytes: number,
|
|
211
212
|
scope: { kind: TranslatorBufferKind; callId?: string },
|
|
212
213
|
): TranslatorTransientReservation {
|
|
214
|
+
if (this.disposed) return { commitRetained() {}, release() {} };
|
|
213
215
|
const admitted = normalizeBytes(bytes);
|
|
214
216
|
if (scope.callId && admitted > this.maxCallArgumentBytes) {
|
|
215
217
|
this.reject("tool_args", this.maxCallArgumentBytes);
|
|
@@ -222,6 +224,7 @@ class Budget implements TranslatorBudget {
|
|
|
222
224
|
return {
|
|
223
225
|
commitRetained: () => {
|
|
224
226
|
if (state !== "reserved") return;
|
|
227
|
+
if (this.disposed) { state = "released"; return; }
|
|
225
228
|
state = "committed";
|
|
226
229
|
if (!scope.callId) return;
|
|
227
230
|
const call = this.calls.get(scope.callId) ?? { logicalBytes: 0, physicalBytes: 0 };
|
|
@@ -234,6 +237,7 @@ class Budget implements TranslatorBudget {
|
|
|
234
237
|
release: () => {
|
|
235
238
|
if (state !== "reserved") return;
|
|
236
239
|
state = "released";
|
|
240
|
+
if (this.disposed) return;
|
|
237
241
|
this.charged.set(scope.kind, Math.max(0, (this.charged.get(scope.kind) ?? 0) - admitted));
|
|
238
242
|
this.hardChargedBytes = Math.max(0, this.hardChargedBytes - admitted);
|
|
239
243
|
this.changed();
|
|
@@ -242,6 +246,7 @@ class Budget implements TranslatorBudget {
|
|
|
242
246
|
}
|
|
243
247
|
|
|
244
248
|
chargeRetained(delta: number, scope: { kind: TranslatorBufferKind; callId?: string }): void {
|
|
249
|
+
if (this.disposed) return;
|
|
245
250
|
const admitted = normalizeBytes(delta);
|
|
246
251
|
if (scope.callId) {
|
|
247
252
|
const call = this.calls.get(scope.callId) ?? { logicalBytes: 0, physicalBytes: 0 };
|
|
@@ -279,6 +284,7 @@ class Budget implements TranslatorBudget {
|
|
|
279
284
|
}
|
|
280
285
|
|
|
281
286
|
private observe(bytes: number): () => void {
|
|
287
|
+
if (this.disposed) return () => {};
|
|
282
288
|
const observed = normalizeBytes(bytes);
|
|
283
289
|
this.observedBytes += observed;
|
|
284
290
|
this.changed();
|
|
@@ -369,7 +375,7 @@ export function resetTranslatorAggregateForTests(): void {
|
|
|
369
375
|
aggregateOverflows = 0;
|
|
370
376
|
}
|
|
371
377
|
|
|
372
|
-
export function finalizeTranslatorBudgetResponse(response: Response, budget: TranslatorBudget): Response {
|
|
378
|
+
export function finalizeTranslatorBudgetResponse(response: Response, budget: TranslatorBudget, signal?: AbortSignal): Response {
|
|
373
379
|
if (!response.body) {
|
|
374
380
|
budget.dispose();
|
|
375
381
|
return response;
|
|
@@ -379,9 +385,12 @@ export function finalizeTranslatorBudgetResponse(response: Response, budget: Tra
|
|
|
379
385
|
const finalize = () => {
|
|
380
386
|
if (finalized) return;
|
|
381
387
|
finalized = true;
|
|
388
|
+
signal?.removeEventListener("abort", finalize);
|
|
382
389
|
budget.dispose();
|
|
383
390
|
};
|
|
384
|
-
|
|
391
|
+
// The transport owns cancellation and terminal precedence. Abort releases only
|
|
392
|
+
// accounting here, including when no consumer ever pulls or cancels this body.
|
|
393
|
+
const body = new ReadableStream<Uint8Array>({
|
|
385
394
|
async pull(controller) {
|
|
386
395
|
try {
|
|
387
396
|
const result = await reader.read();
|
|
@@ -395,7 +404,11 @@ export function finalizeTranslatorBudgetResponse(response: Response, budget: Tra
|
|
|
395
404
|
}
|
|
396
405
|
},
|
|
397
406
|
async cancel(reason) {
|
|
398
|
-
|
|
407
|
+
finalize();
|
|
408
|
+
await reader.cancel(reason);
|
|
399
409
|
},
|
|
400
|
-
})
|
|
410
|
+
});
|
|
411
|
+
if (signal?.aborted) finalize();
|
|
412
|
+
else signal?.addEventListener("abort", finalize, { once: true });
|
|
413
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
401
414
|
}
|
|
@@ -37,6 +37,9 @@ export type ReplacePublisher =
|
|
|
37
37
|
| "storage-cleanup"
|
|
38
38
|
| "tray";
|
|
39
39
|
|
|
40
|
+
/** Called immediately before every rename attempt, including each Windows retry. */
|
|
41
|
+
export interface RenameValidationHooks { validateBeforeRename?: (destination: string) => void }
|
|
42
|
+
|
|
40
43
|
/** The Windows error codes this module treats as a momentary hold. */
|
|
41
44
|
export type ReplaceRetryCode = "EBUSY" | "EPERM" | "EACCES";
|
|
42
45
|
|
|
@@ -97,7 +100,7 @@ const MAX_RETRIES = 2;
|
|
|
97
100
|
* Windows sharing violations only, returning the code so the caller can record
|
|
98
101
|
* which one. Any other error is the caller's to see, immediately.
|
|
99
102
|
*/
|
|
100
|
-
function transientWindowsReplaceCode(
|
|
103
|
+
export function transientWindowsReplaceCode(
|
|
101
104
|
platform: NodeJS.Platform,
|
|
102
105
|
error: unknown,
|
|
103
106
|
): ReplaceRetryCode | null {
|
|
@@ -106,6 +109,32 @@ function transientWindowsReplaceCode(
|
|
|
106
109
|
return code === "EBUSY" || code === "EPERM" || code === "EACCES" ? code : null;
|
|
107
110
|
}
|
|
108
111
|
|
|
112
|
+
/** One filesystem operation plus its retry observers, carried as members (not free callbacks). */
|
|
113
|
+
export interface WindowsFileOperationStep<T> {
|
|
114
|
+
run: () => T;
|
|
115
|
+
onRetry?: (code: ReplaceRetryCode, exhausted: boolean) => void;
|
|
116
|
+
beforeAttempt?: () => void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Bounded sharing-violation retries for one filesystem operation, never a transaction. */
|
|
120
|
+
export function retryWindowsFileOperation<T>(
|
|
121
|
+
step: WindowsFileOperationStep<T>,
|
|
122
|
+
io: Pick<AtomicRenameIO, "platform" | "sleep"> = { platform: process.platform, sleep: Bun.sleepSync },
|
|
123
|
+
): T {
|
|
124
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
125
|
+
step.beforeAttempt?.();
|
|
126
|
+
try { return step.run(); }
|
|
127
|
+
catch (error) {
|
|
128
|
+
const code = transientWindowsReplaceCode(io.platform, error);
|
|
129
|
+
if (!code) throw error;
|
|
130
|
+
const exhausted = attempt >= MAX_RETRIES;
|
|
131
|
+
step.onRetry?.(code, exhausted);
|
|
132
|
+
if (exhausted) throw error;
|
|
133
|
+
io.sleep(25 * (attempt + 1));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
109
138
|
export function renameAtomicFile(
|
|
110
139
|
source: string,
|
|
111
140
|
destination: string,
|
|
@@ -115,30 +144,23 @@ export function renameAtomicFile(
|
|
|
115
144
|
sleep: Bun.sleepSync,
|
|
116
145
|
},
|
|
117
146
|
publisher: ReplacePublisher = "config",
|
|
147
|
+
hooks: RenameValidationHooks = {},
|
|
118
148
|
): void {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const code = transientWindowsReplaceCode(io.platform, error);
|
|
125
|
-
if (!code) throw error;
|
|
126
|
-
if (attempt >= MAX_RETRIES) {
|
|
127
|
-
bump(publisher, code, "exhausted");
|
|
128
|
-
throw error;
|
|
129
|
-
}
|
|
130
|
-
bump(publisher, code, "retried");
|
|
131
|
-
io.sleep(25 * (attempt + 1));
|
|
132
|
-
}
|
|
133
|
-
}
|
|
149
|
+
retryWindowsFileOperation({
|
|
150
|
+
run: () => io.rename(source, destination),
|
|
151
|
+
onRetry: (code, exhausted) => bump(publisher, code, exhausted ? "exhausted" : "retried"),
|
|
152
|
+
beforeAttempt: () => hooks.validateBeforeRename?.(destination),
|
|
153
|
+
}, io);
|
|
134
154
|
}
|
|
135
155
|
|
|
136
156
|
export async function renameAtomicFileAsync(
|
|
137
157
|
source: string,
|
|
138
158
|
destination: string,
|
|
139
159
|
publisher: ReplacePublisher = "config",
|
|
160
|
+
hooks: RenameValidationHooks = {},
|
|
140
161
|
): Promise<void> {
|
|
141
162
|
for (let attempt = 0; ; attempt += 1) {
|
|
163
|
+
hooks.validateBeforeRename?.(destination);
|
|
142
164
|
try {
|
|
143
165
|
renameSync(source, destination);
|
|
144
166
|
return;
|
|
@@ -203,6 +203,12 @@ export function resolveTrustedWindowsPowerShellExe(): string {
|
|
|
203
203
|
return assertTrustedSystemExecutable(candidate, "PowerShell");
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
/** Absolute System32\\reg.exe path; PATH, SystemRoot and WINDIR cannot select it. */
|
|
207
|
+
export function resolveTrustedWindowsRegExe(): string {
|
|
208
|
+
const candidate = join(resolveTrustedWindowsSystemDirectory(), "reg.exe");
|
|
209
|
+
return assertTrustedSystemExecutable(candidate, "reg.exe");
|
|
210
|
+
}
|
|
211
|
+
|
|
206
212
|
/** Absolute path to System32\\schtasks.exe from a trusted system directory. */
|
|
207
213
|
export function resolveTrustedWindowsSchtasksExe(): string {
|
|
208
214
|
if (elevationExeOverridesForTests?.schtasks) {
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
* own caches so one shape never satisfies the other's lookup.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
import { existsSync, statSync } from "node:fs";
|
|
40
|
-
import { isAbsolute, relative, resolve } from "node:path";
|
|
39
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
40
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
41
41
|
import { env, platform } from "node:process";
|
|
42
42
|
import {
|
|
43
43
|
SUBPROCESS_KILL_GRACE_MS,
|
|
@@ -82,7 +82,10 @@ const timedOutPaths = new Map<string, { consumed: boolean; consumedAt: number }>
|
|
|
82
82
|
export const TIMEOUT_MEMO_REARM_MS = 5 * 60_000;
|
|
83
83
|
/** Compatibility slack before the outer belt releases a caller whose killed child has not reaped. */
|
|
84
84
|
const ASYNC_ICACLS_BELT_MARGIN_MS = 250;
|
|
85
|
-
|
|
85
|
+
interface RemovalIdentity { readonly lexical: string; readonly canonical: string | undefined }
|
|
86
|
+
interface AsyncAclWork { readonly identity: RemovalIdentity; readonly reap: Promise<void> }
|
|
87
|
+
const pendingAsyncIcaclsReaps = new Map<string, Set<AsyncAclWork>>();
|
|
88
|
+
const activeAsyncAclWork = new Map<string, Set<AsyncAclWork>>();
|
|
86
89
|
|
|
87
90
|
const scheduleAsyncIcaclsBelt: SubprocessDeadlineScheduler = (callback, milliseconds) => {
|
|
88
91
|
const timer = setTimeout(callback, milliseconds);
|
|
@@ -414,10 +417,13 @@ function awaitAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<Icac
|
|
|
414
417
|
cancelBelt?.();
|
|
415
418
|
resolve(result);
|
|
416
419
|
};
|
|
417
|
-
const
|
|
420
|
+
const run = () => asyncIcaclsRunner(args, timeoutMs).then(
|
|
418
421
|
result => { finish(result); },
|
|
419
422
|
() => { finish(spawnFailedResult()); },
|
|
420
423
|
);
|
|
424
|
+
// Capture and register ownership before the child can start or mutate its alias.
|
|
425
|
+
const tracked = args[0] ? startTrackedAsyncAclWork(args[0], run) : undefined;
|
|
426
|
+
if (!tracked) run();
|
|
421
427
|
// The belt has to outlast the runner it is guarding, or it is not a belt -- it is the
|
|
422
428
|
// deadline. The runner may now legitimately outlive it while a killed child is reaped. The
|
|
423
429
|
// caller is still released, but the target is registered so removal can wait for the distinct
|
|
@@ -425,7 +431,7 @@ function awaitAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<Icac
|
|
|
425
431
|
cancelBelt = asyncIcaclsBeltScheduler(
|
|
426
432
|
() => {
|
|
427
433
|
const targetPath = args[0];
|
|
428
|
-
if (targetPath) registerPendingAsyncIcaclsReap(targetPath,
|
|
434
|
+
if (targetPath && tracked) registerPendingAsyncIcaclsReap(targetPath, tracked.entry);
|
|
429
435
|
finish({ success: false, exitCode: null, timedOut: true, stdout: "" });
|
|
430
436
|
},
|
|
431
437
|
Math.max(1, timeoutMs) + SUBPROCESS_KILL_GRACE_MS + ASYNC_ICACLS_BELT_MARGIN_MS,
|
|
@@ -433,22 +439,69 @@ function awaitAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<Icac
|
|
|
433
439
|
});
|
|
434
440
|
}
|
|
435
441
|
|
|
436
|
-
function registerPendingAsyncIcaclsReap(
|
|
437
|
-
|
|
442
|
+
function registerPendingAsyncIcaclsReap(
|
|
443
|
+
targetPath: string,
|
|
444
|
+
entry: AsyncAclWork,
|
|
445
|
+
registry = pendingAsyncIcaclsReaps,
|
|
446
|
+
): void {
|
|
447
|
+
let pending = registry.get(targetPath);
|
|
438
448
|
if (!pending) {
|
|
439
449
|
pending = new Set();
|
|
440
|
-
|
|
450
|
+
registry.set(targetPath, pending);
|
|
441
451
|
}
|
|
442
|
-
pending.add(
|
|
443
|
-
void reap.
|
|
444
|
-
pending!.delete(
|
|
445
|
-
if (pending!.size === 0)
|
|
452
|
+
pending.add(entry);
|
|
453
|
+
void entry.reap.then(() => {
|
|
454
|
+
pending!.delete(entry);
|
|
455
|
+
if (pending!.size === 0) registry.delete(targetPath);
|
|
446
456
|
});
|
|
447
457
|
}
|
|
448
458
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
459
|
+
/** Register the captured identity before invoking a work/child factory. */
|
|
460
|
+
function startTrackedAsyncAclWork<T>(targetPath: string, start: () => Promise<T>): { work: Promise<T>; entry: AsyncAclWork } {
|
|
461
|
+
const identity = captureRemovalIdentity(targetPath);
|
|
462
|
+
let release!: () => void;
|
|
463
|
+
const entry: AsyncAclWork = { identity, reap: new Promise<void>(done => { release = done; }) };
|
|
464
|
+
registerPendingAsyncIcaclsReap(targetPath, entry, activeAsyncAclWork);
|
|
465
|
+
try {
|
|
466
|
+
const work = start();
|
|
467
|
+
void work.then(release, release);
|
|
468
|
+
return { work, entry };
|
|
469
|
+
} catch (error) { release(); throw error; }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function canonicalRemovalPath(path: string): string | undefined {
|
|
473
|
+
const missing: string[] = [];
|
|
474
|
+
let current = resolve(path);
|
|
475
|
+
while (true) {
|
|
476
|
+
try { return resolve(realpathSync.native(current), ...missing); }
|
|
477
|
+
catch (error) {
|
|
478
|
+
if (!["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) return undefined;
|
|
479
|
+
const parent = dirname(current);
|
|
480
|
+
if (parent === current) return undefined;
|
|
481
|
+
missing.unshift(basename(current));
|
|
482
|
+
current = parent;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function captureRemovalIdentity(path: string): RemovalIdentity {
|
|
488
|
+
return { lexical: resolve(path), canonical: canonicalRemovalPath(path) };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function pathIsAtOrBelow(target: string, root: string): boolean {
|
|
492
|
+
const suffix = relative(root, target);
|
|
493
|
+
return suffix !== ".." && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function retainedWorkIsAtOrBelow(entry: AsyncAclWork, root: RemovalIdentity): boolean {
|
|
497
|
+
const target = entry.identity;
|
|
498
|
+
// Failure at capture stays conservative, even if the alias later becomes readable.
|
|
499
|
+
if (target.canonical === undefined || root.canonical === undefined) return true;
|
|
500
|
+
const current = canonicalRemovalPath(target.lexical);
|
|
501
|
+
if (current === undefined) return true;
|
|
502
|
+
const targets = [target.lexical, target.canonical, current];
|
|
503
|
+
return [root.lexical, root.canonical].some(rootPath =>
|
|
504
|
+
targets.some(targetPath => pathIsAtOrBelow(targetPath, rootPath)));
|
|
452
505
|
}
|
|
453
506
|
|
|
454
507
|
/** True while an async icacls runner still owns this exact path after its caller's belt fired. */
|
|
@@ -458,22 +511,25 @@ export function windowsSecretAclReapPendingForPath(targetPath: string): boolean
|
|
|
458
511
|
|
|
459
512
|
/** Non-blocking removal guard for callers that must refuse rather than wait for a stuck child. */
|
|
460
513
|
export function windowsSecretAclReapPendingAtOrBelow(rootPath: string): boolean {
|
|
461
|
-
|
|
462
|
-
|
|
514
|
+
const root = captureRemovalIdentity(rootPath);
|
|
515
|
+
return [...activeAsyncAclWork.values(), ...pendingAsyncIcaclsReaps.values()]
|
|
516
|
+
.some(entries => [...entries].some(entry => retainedWorkIsAtOrBelow(entry, root)));
|
|
463
517
|
}
|
|
464
518
|
|
|
465
519
|
/**
|
|
466
|
-
* Removal barrier for a file or tree
|
|
520
|
+
* Removal barrier for a file or tree held by a normal or timed-out async icacls child.
|
|
467
521
|
*
|
|
468
522
|
* This wait is deliberately separate from ordinary startup and shutdown: a genuinely stuck child
|
|
469
523
|
* must not defeat the caller-facing belt. Code that chooses to remove the target has the stricter
|
|
470
524
|
* contract and must not proceed until every registered runner at or below it has actually reaped.
|
|
471
525
|
*/
|
|
472
526
|
export async function flushWindowsSecretAclReapsBeforeRemoval(rootPath: string): Promise<void> {
|
|
527
|
+
const root = captureRemovalIdentity(rootPath);
|
|
473
528
|
while (true) {
|
|
474
|
-
const pending = [...pendingAsyncIcaclsReaps]
|
|
475
|
-
.
|
|
476
|
-
.
|
|
529
|
+
const pending = [...activeAsyncAclWork.values(), ...pendingAsyncIcaclsReaps.values()]
|
|
530
|
+
.flatMap(entries => [...entries])
|
|
531
|
+
.filter(entry => retainedWorkIsAtOrBelow(entry, root))
|
|
532
|
+
.map(entry => entry.reap);
|
|
477
533
|
if (pending.length === 0) return;
|
|
478
534
|
await Promise.all(pending);
|
|
479
535
|
}
|
|
@@ -1058,17 +1114,28 @@ function hardenEntry(
|
|
|
1058
1114
|
return { ok: false, diagnostics };
|
|
1059
1115
|
}
|
|
1060
1116
|
|
|
1117
|
+
/** Retain removal ownership across principal lookup, every ACL command and diagnostics. */
|
|
1118
|
+
function hardenEntryAsync(
|
|
1119
|
+
targetPath: string,
|
|
1120
|
+
directory: boolean,
|
|
1121
|
+
opts: HardenOptions,
|
|
1122
|
+
cache: Map<string, HardenedIdentity>,
|
|
1123
|
+
extraReadAces: readonly string[],
|
|
1124
|
+
): Promise<HardenResult> {
|
|
1125
|
+
if (!existsSync(targetPath)) { cache.delete(targetPath); return Promise.resolve({ ok: true }); }
|
|
1126
|
+
if (effectivePlatform() !== "win32" || memoSatisfied(cache, targetPath)) return Promise.resolve({ ok: true });
|
|
1127
|
+
return startTrackedAsyncAclWork(targetPath,
|
|
1128
|
+
() => runHardenEntryAsync(targetPath, directory, opts, cache, extraReadAces)).work;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1061
1131
|
/** Async counterpart of hardenEntry — yields while waiting on icacls (#612). */
|
|
1062
|
-
async function
|
|
1132
|
+
async function runHardenEntryAsync(
|
|
1063
1133
|
targetPath: string,
|
|
1064
1134
|
directory: boolean,
|
|
1065
1135
|
opts: HardenOptions,
|
|
1066
1136
|
cache: Map<string, HardenedIdentity>,
|
|
1067
1137
|
extraReadAces: readonly string[],
|
|
1068
1138
|
): Promise<HardenResult> {
|
|
1069
|
-
if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; }
|
|
1070
|
-
if (effectivePlatform() !== "win32") return { ok: true };
|
|
1071
|
-
if (memoSatisfied(cache, targetPath)) return { ok: true };
|
|
1072
1139
|
const deadline = nowFn() + resolveHardenDeadlineMs(opts.deadlineMs);
|
|
1073
1140
|
if (extraReadAces.length === 0 && await existingAclAlreadyCompliantAsync(targetPath, directory, deadline)) return { ok: true };
|
|
1074
1141
|
const memoKey = timeoutMemoKey(targetPath, opts);
|
package/src/lib/winsw.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { homedir } from "node:os";
|
|
|
20
20
|
import { join, resolve, win32 } from "node:path";
|
|
21
21
|
import { expandUserPath, getConfigDir, loadConfig } from "../config";
|
|
22
22
|
import { recordOwnedConfigPath } from "./config-ownership";
|
|
23
|
-
import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./bun-runtime";
|
|
23
|
+
import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime, type DurableBunRuntime } from "./bun-runtime";
|
|
24
24
|
import type { BunRuntimeSource } from "./bun-runtime";
|
|
25
25
|
import { serviceApiTokenFilePath } from "./service-secrets";
|
|
26
26
|
import { filterTransientServicePath } from "./transient-service-path";
|
|
@@ -408,7 +408,6 @@ export function winswStatusSummary(): string {
|
|
|
408
408
|
}
|
|
409
409
|
|
|
410
410
|
/** Default entry mirrors the Task Scheduler baking: durable Bun + cli.ts. */
|
|
411
|
-
export function defaultWinswEntry(cliDir: string): WinswEntry {
|
|
412
|
-
const runtime = durableBunRuntime();
|
|
411
|
+
export function defaultWinswEntry(cliDir: string, runtime: DurableBunRuntime = durableBunRuntime()): WinswEntry {
|
|
413
412
|
return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(cliDir, "cli", "index.ts") };
|
|
414
413
|
}
|
|
@@ -3,7 +3,7 @@ import { anthropicModelFamily } from "./anthropic-model-quota";
|
|
|
3
3
|
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
4
4
|
import { classifyAnthropic429, anthropicRetryAfterMs, anthropicRatePolicyFor, ANTHROPIC_SHORT_RETRY_MS, ANTHROPIC_MAX_INLINE_THROTTLE_MS } from "./anthropic-rate-limit-policy";
|
|
5
5
|
import { isNonReplayableResponse, sleepWithAbort } from "../lib/upstream-retry";
|
|
6
|
-
import { credentialGeneration, getAccountCredentialWithStatus } from "./store";
|
|
6
|
+
import { credentialGeneration, getAccountCredentialWithStatus, markAccountNeedsReauthIfGeneration } from "./store";
|
|
7
7
|
import type { OAuthAccessSnapshot } from "./index";
|
|
8
8
|
import type { OcxConfig } from "../types";
|
|
9
9
|
import type { AnthropicRouteDecision } from "./anthropic-model-routes";
|
|
@@ -31,6 +31,22 @@ export function bindAnthropicRefusalCredentialForSend(response: Response, owner:
|
|
|
31
31
|
responseCredentials.set(response, Object.freeze({ ...owner, providerAccountUuid, checkProviderUuid: arguments.length >= 3 }));
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
async function isRevokedOAuthToken(response: Response, signal?: AbortSignal): Promise<boolean> {
|
|
35
|
+
if (response.status !== 401) return false;
|
|
36
|
+
try {
|
|
37
|
+
const body = await readBoundedResponseBody(response.clone(), { signal, fatalUtf8: true });
|
|
38
|
+
if (!body.displaySafe || body.truncated) return false;
|
|
39
|
+
const payload: unknown = JSON.parse(body.text);
|
|
40
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)
|
|
41
|
+
|| !("type" in payload) || payload.type !== "error" || !("error" in payload)) return false;
|
|
42
|
+
const error = payload.error;
|
|
43
|
+
return !!error && typeof error === "object" && !Array.isArray(error)
|
|
44
|
+
&& "type" in error && error.type === "authentication_error"
|
|
45
|
+
&& "message" in error && error.message === "OAuth access token has been revoked."
|
|
46
|
+
&& (!("code" in error) || error.code == null);
|
|
47
|
+
} catch { return false; }
|
|
48
|
+
}
|
|
49
|
+
|
|
34
50
|
async function isAccountRefusal(response: Response, signal?: AbortSignal): Promise<boolean> {
|
|
35
51
|
try {
|
|
36
52
|
const body = await readBoundedResponseBody(response.clone(), { signal });
|
|
@@ -91,6 +107,28 @@ export async function rotateAnthropicAccountOnResponseForInstance(
|
|
|
91
107
|
&& credentialGeneration(row.credential) === sent.generation
|
|
92
108
|
&& (!sent.checkProviderUuid || row.credential.accountId === sent.providerAccountUuid) ? row : undefined;
|
|
93
109
|
};
|
|
110
|
+
if (response.status === 401) {
|
|
111
|
+
if (options.allowAccountRefusal === false) return null;
|
|
112
|
+
let verdict = verdicts.get(response);
|
|
113
|
+
if (!verdict) { verdict = isRevokedOAuthToken(response, options.signal); verdicts.set(response, verdict); }
|
|
114
|
+
if (!await verdict || options.signal?.aborted || !ownedCurrent()) return null;
|
|
115
|
+
options.currentDecision?.();
|
|
116
|
+
let marked: boolean;
|
|
117
|
+
try {
|
|
118
|
+
marked = await markAccountNeedsReauthIfGeneration(instance, sent.accountId, sent.generation, undefined, undefined, store => {
|
|
119
|
+
const row = store[instance]?.accounts.find(account => account.id === sent.accountId);
|
|
120
|
+
return !options.signal?.aborted && configuredAnthropicInstance(options.config, instance) === instance
|
|
121
|
+
&& anthropicPhysicalSendOwnershipIsCurrent(sent, store)
|
|
122
|
+
&& (!sent.checkProviderUuid || row?.credential.accountId === sent.providerAccountUuid);
|
|
123
|
+
});
|
|
124
|
+
} catch { return null; }
|
|
125
|
+
if (!marked || configuredAnthropicInstance(options.config, instance) !== instance
|
|
126
|
+
|| !anthropicPhysicalSendOwnershipIsCurrent(sent)) return null;
|
|
127
|
+
routing.clearAnthropicSessionAffinityForAccount(sent.accountId);
|
|
128
|
+
if (!options.canRetry || options.signal?.aborted) return null;
|
|
129
|
+
const decision = options.currentDecision ? options.currentDecision() : options.decision ?? null;
|
|
130
|
+
return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(), decision, options.model, options.excludedAccountIds);
|
|
131
|
+
}
|
|
94
132
|
if (response.status === 429) {
|
|
95
133
|
const current = ownedCurrent();
|
|
96
134
|
if (!current) return null;
|
|
@@ -704,7 +704,7 @@ function createAnthropicRouting(instance: AnthropicInstanceId): AnthropicRouting
|
|
|
704
704
|
excludedAccountIds?: ReadonlySet<string>,
|
|
705
705
|
): string | null {
|
|
706
706
|
if (!admitted(config)) return null;
|
|
707
|
-
const strategy = anthropicPoolStrategy(config);
|
|
707
|
+
const strategy = isAnthropicAccountPoolEnabled(config) ? anthropicPoolStrategy(config) : "quota";
|
|
708
708
|
const eligible = routeCandidates(getEligibleAnthropicAccounts(now, model), decision).filter(id => id !== excludeId && !excludedAccountIds?.has(id));
|
|
709
709
|
if (strategy === "round-robin") {
|
|
710
710
|
return peekRoundRobinAccount(poolKey, eligible, stickyLimitForPool(config));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Ownership captured before a physical send, independent of subsequent cooldown observations. */
|
|
2
2
|
import type { OAuthAccessSnapshot } from "./index";
|
|
3
|
-
import { credentialGeneration, getAccountSet } from "./store";
|
|
3
|
+
import { credentialGeneration, getAccountSet, type AuthStore } from "./store";
|
|
4
4
|
import { isAnthropicInstanceId, type AnthropicInstanceId } from "../providers/anthropic-instance-id";
|
|
5
5
|
import { anthropicCooldownRecoveryFor } from "../providers/quota/anthropic-cooldown-recovery";
|
|
6
6
|
import { captureProviderAccountQuotaEpoch } from "../providers/quota/account-cache";
|
|
@@ -32,8 +32,8 @@ export function captureAnthropicPhysicalSendOwnership(snapshot: OAuthAccessSnaps
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/** Pure ownership read: never adopt or reserve the replacement account's current incarnation. */
|
|
35
|
-
export function anthropicPhysicalSendOwnershipIsCurrent(owner: AnthropicPhysicalSendOwnership): boolean {
|
|
36
|
-
const row = getAccountSet(owner.provider)?.accounts.find(account => account.id === owner.accountId);
|
|
35
|
+
export function anthropicPhysicalSendOwnershipIsCurrent(owner: AnthropicPhysicalSendOwnership, store?: AuthStore): boolean {
|
|
36
|
+
const row = (store ? store[owner.provider] : getAccountSet(owner.provider))?.accounts.find(account => account.id === owner.accountId);
|
|
37
37
|
return !!row && row.loginId === owner.loginId && row.addedAt === owner.addedAt
|
|
38
38
|
&& row.credential.access === owner.accessToken && credentialGeneration(row.credential) === owner.generation
|
|
39
39
|
&& anthropicCooldownRecoveryFor(owner.provider).anthropicAccountIncarnation(owner.accountId) === owner.accountIncarnation
|
package/src/oauth/chatgpt.ts
CHANGED
|
@@ -1,6 +1,106 @@
|
|
|
1
1
|
import { OAuthCallbackFlow } from "./callback-server";
|
|
2
2
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
3
3
|
import { generatePKCE } from "./pkce";
|
|
4
|
+
import { classifyChatgptRefreshFailure } from "../codex/chatgpt-refresh-failure";
|
|
5
|
+
import { readBoundedResponseBytes } from "../lib/bounded-body";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Per-fetch deadline for ChatGPT auth endpoint calls. The deviceauth grant got the
|
|
9
|
+
* same bound in #3898 after one stuck TCP connection held the login slot for the
|
|
10
|
+
* whole grant; a hung refresh would pin the refresh intent lock indefinitely.
|
|
11
|
+
*/
|
|
12
|
+
export const CHATGPT_FETCH_TIMEOUT_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
/** Non-HTTP failures retain no caller reason, upstream error, or cause. */
|
|
15
|
+
export class ChatGptTokenRequestError extends Error {
|
|
16
|
+
readonly terminal = false;
|
|
17
|
+
|
|
18
|
+
constructor(kind: "abort" | "timeout" | "transport") {
|
|
19
|
+
super(kind === "abort" ? "ChatGPT token request cancelled"
|
|
20
|
+
: kind === "timeout" ? "ChatGPT token request timed out" : "ChatGPT token request failed");
|
|
21
|
+
this.name = kind === "abort" ? "AbortError" : kind === "timeout" ? "TimeoutError" : "ChatGptTokenRequestError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CHATGPT_ERROR_BODY_MAX_BYTES = 16 * 1024;
|
|
26
|
+
const CHATGPT_SUCCESS_BODY_MAX_BYTES = 64 * 1024;
|
|
27
|
+
|
|
28
|
+
/** One deadline covers headers and body; every settled call detaches its caller. */
|
|
29
|
+
async function chatGptTokenRequest(
|
|
30
|
+
body: URLSearchParams,
|
|
31
|
+
label: "ChatGPT refresh" | "ChatGPT token exchange",
|
|
32
|
+
options: { signal?: AbortSignal; timeoutMs?: number },
|
|
33
|
+
): Promise<OAuthCredentials> {
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const onAbort = () => controller.abort(new ChatGptTokenRequestError("abort"));
|
|
36
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
37
|
+
if (options.signal?.aborted) onAbort();
|
|
38
|
+
const timer = setTimeout(() => controller.abort(new ChatGptTokenRequestError("timeout")),
|
|
39
|
+
options.timeoutMs ?? CHATGPT_FETCH_TIMEOUT_MS);
|
|
40
|
+
let endpointError: ChatGptTokenError | undefined;
|
|
41
|
+
try {
|
|
42
|
+
controller.signal.throwIfAborted();
|
|
43
|
+
const response = await fetch(TOKEN_URL, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
46
|
+
body: body.toString(),
|
|
47
|
+
signal: controller.signal,
|
|
48
|
+
});
|
|
49
|
+
const result = await readBoundedResponseBytes(response, {
|
|
50
|
+
signal: controller.signal,
|
|
51
|
+
maxBytes: response.ok ? CHATGPT_SUCCESS_BODY_MAX_BYTES : CHATGPT_ERROR_BODY_MAX_BYTES,
|
|
52
|
+
});
|
|
53
|
+
controller.signal.throwIfAborted();
|
|
54
|
+
const text = new TextDecoder().decode(result.bytes);
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
endpointError = chatGptTokenError(response.status, result.oversized ? "" : text, label);
|
|
57
|
+
throw endpointError;
|
|
58
|
+
}
|
|
59
|
+
if (result.oversized) throw new ChatGptTokenRequestError("transport");
|
|
60
|
+
return credsFromToken(JSON.parse(text) as Record<string, unknown>);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (controller.signal.aborted) throw controller.signal.reason;
|
|
63
|
+
if (endpointError && error === endpointError) throw endpointError;
|
|
64
|
+
throw new ChatGptTokenRequestError("transport");
|
|
65
|
+
} finally {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Token-endpoint failure carrying the HTTP status, the allowlisted OAuth error code,
|
|
73
|
+
* and a terminal verdict requiring an explicit grant-refusal code. The shared
|
|
74
|
+
* Codex classifier supplies the code vocabulary; free text is never grant evidence.
|
|
75
|
+
*/
|
|
76
|
+
export class ChatGptTokenError extends Error {
|
|
77
|
+
constructor(
|
|
78
|
+
public readonly httpStatus: number,
|
|
79
|
+
public readonly oauthError: string | undefined,
|
|
80
|
+
public readonly terminal: boolean,
|
|
81
|
+
message: string,
|
|
82
|
+
) {
|
|
83
|
+
super(message);
|
|
84
|
+
this.name = "ChatGptTokenError";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function chatGptTokenError(status: number, body: string, label: string): ChatGptTokenError {
|
|
89
|
+
const failure = classifyChatgptRefreshFailure(status, body);
|
|
90
|
+
// Reclassify the allowlisted code alone to reuse the shared terminal vocabulary
|
|
91
|
+
// without its description-only compatibility rule for other Codex callers.
|
|
92
|
+
const terminal = failure.reason !== "unknown" && failure.code !== undefined
|
|
93
|
+
&& classifyChatgptRefreshFailure(status, JSON.stringify({ error: failure.code })).reason !== "unknown";
|
|
94
|
+
// The message carries the status and the allowlisted OAuth code only — never the
|
|
95
|
+
// free-text `error_description`, which can echo OAuth material into log surfaces
|
|
96
|
+
// (the same closed vocabulary the pool's noteChatgptRefreshFailure logs with).
|
|
97
|
+
return new ChatGptTokenError(
|
|
98
|
+
status,
|
|
99
|
+
failure.code,
|
|
100
|
+
terminal,
|
|
101
|
+
`${label} failed: ${status} code=${failure.code ?? "none"}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
4
104
|
|
|
5
105
|
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
6
106
|
const AUTH_URL = "https://auth.openai.com/oauth/authorize";
|
|
@@ -202,34 +302,16 @@ export class ChatGPTOAuthFlow extends OAuthCallbackFlow {
|
|
|
202
302
|
|
|
203
303
|
async exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials> {
|
|
204
304
|
if (!this.#verifier) throw new Error("ChatGPT PKCE verifier not initialized");
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
redirect_uri: redirectUri,
|
|
213
|
-
code_verifier: this.#verifier,
|
|
214
|
-
}).toString(),
|
|
215
|
-
});
|
|
216
|
-
if (!resp.ok) {
|
|
217
|
-
const errDesc = await safeErrorDescription(resp);
|
|
218
|
-
throw new Error(`ChatGPT token exchange failed: ${resp.status} ${errDesc}`);
|
|
219
|
-
}
|
|
220
|
-
return credsFromToken((await resp.json()) as Record<string, unknown>);
|
|
305
|
+
return chatGptTokenRequest(new URLSearchParams({
|
|
306
|
+
grant_type: "authorization_code",
|
|
307
|
+
client_id: CLIENT_ID,
|
|
308
|
+
code,
|
|
309
|
+
redirect_uri: redirectUri,
|
|
310
|
+
code_verifier: this.#verifier,
|
|
311
|
+
}), "ChatGPT token exchange", { signal: this.ctrl.signal });
|
|
221
312
|
}
|
|
222
313
|
}
|
|
223
314
|
|
|
224
|
-
function safeErrorDescription(resp: Response): Promise<string> {
|
|
225
|
-
return resp.text().catch(() => "").then(text => {
|
|
226
|
-
try {
|
|
227
|
-
const parsed = JSON.parse(text) as { error?: string; error_description?: string };
|
|
228
|
-
return [parsed.error, parsed.error_description].filter(Boolean).join(": ") || `HTTP ${resp.status}`;
|
|
229
|
-
} catch { return `HTTP ${resp.status}`; }
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
|
|
233
315
|
/**
|
|
234
316
|
* How the user proves identity. `browser` runs the localhost:1455 callback flow;
|
|
235
317
|
* `device` runs the deviceauth grant, which needs no local browser or listener
|
|
@@ -255,21 +337,11 @@ export async function loginChatGPT(
|
|
|
255
337
|
// Codex-rs uses JSON for refresh — intentional divergence; both accepted by auth.openai.com.
|
|
256
338
|
export async function refreshChatGPTToken(
|
|
257
339
|
refreshToken: string,
|
|
258
|
-
options: { signal?: AbortSignal } = {},
|
|
340
|
+
options: { signal?: AbortSignal; timeoutMs?: number } = {},
|
|
259
341
|
): Promise<OAuthCredentials> {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
client_id: CLIENT_ID,
|
|
266
|
-
refresh_token: refreshToken,
|
|
267
|
-
}).toString(),
|
|
268
|
-
signal: options.signal,
|
|
269
|
-
});
|
|
270
|
-
if (!resp.ok) {
|
|
271
|
-
const errDesc = await safeErrorDescription(resp);
|
|
272
|
-
throw new Error(`ChatGPT refresh failed: ${resp.status} ${errDesc}`);
|
|
273
|
-
}
|
|
274
|
-
return credsFromToken((await resp.json()) as Record<string, unknown>);
|
|
342
|
+
return chatGptTokenRequest(new URLSearchParams({
|
|
343
|
+
grant_type: "refresh_token",
|
|
344
|
+
client_id: CLIENT_ID,
|
|
345
|
+
refresh_token: refreshToken,
|
|
346
|
+
}), "ChatGPT refresh", options);
|
|
275
347
|
}
|