@bitkyc08/opencodex 2.84.0 → 2.86.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/README.md +0 -4
- package/bin/ocx.mjs +61 -3
- package/gui/dist/assets/App-BjlttVLy.js +58 -0
- package/gui/dist/assets/{Tray-C9TGtwad.js → Tray-DyR9yBee.js} +1 -1
- package/gui/dist/assets/index-BMssP4k3.js +94 -0
- package/gui/dist/assets/quota-summary-D8M1bc_R.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -2
- package/src/adapters/anthropic/beta-allowlist.ts +87 -8
- package/src/adapters/anthropic/client-identity.ts +1 -1
- package/src/adapters/anthropic/passthrough.ts +33 -4
- package/src/adapters/azure.ts +19 -1
- package/src/adapters/client-fingerprint.ts +24 -0
- package/src/adapters/coding-agent/turn.ts +22 -9
- package/src/adapters/cursor/arg-codec.ts +17 -5
- package/src/adapters/cursor/catalog.ts +8 -0
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/cursor/freeform-args.ts +75 -0
- package/src/adapters/cursor/protobuf-events.ts +36 -23
- package/src/adapters/google-antigravity-wire.ts +22 -1
- package/src/adapters/google.ts +2 -2
- package/src/adapters/openai-chat/response-events.ts +59 -1
- package/src/adapters/openai-chat.ts +8 -5
- package/src/adapters/qoder/adapter.ts +40 -1
- package/src/advisor/consult.ts +220 -0
- package/src/advisor/context.ts +226 -0
- package/src/advisor/disclosure.ts +20 -0
- package/src/advisor/runtime.ts +305 -0
- package/src/advisor/settings.ts +135 -0
- package/src/advisor/state.ts +369 -0
- package/src/advisor/synthetic-tool.ts +33 -0
- package/src/bridge/errors.ts +5 -0
- package/src/chat/inbound.ts +30 -6
- package/src/chat/outbound.ts +5 -0
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +22 -8
- package/src/claude/desktop-picker-profile.ts +11 -4
- package/src/claude/desktop-profile.ts +10 -1
- package/src/claude/desktop-remote-store-io.ts +5 -3
- package/src/claude/inbound.ts +121 -50
- package/src/claude/intercept/local-ca.ts +8 -1
- package/src/claude/model-default-settings.ts +154 -0
- package/src/claude/model-info.ts +7 -7
- package/src/claude/outbound.ts +13 -6
- package/src/cli/advisor.ts +151 -0
- package/src/cli/auxiliary-listener-preflight.ts +37 -0
- package/src/cli/capabilities-agents-routing.ts +18 -0
- package/src/cli/capabilities-base.ts +1 -1
- package/src/cli/chatgpt-command.ts +35 -14
- package/src/cli/claude.ts +46 -6
- package/src/cli/dispatch.ts +5 -1
- package/src/cli/doctor.ts +9 -0
- package/src/cli/ensure-desktop-cold-start.ts +329 -0
- package/src/cli/export-command.ts +1 -1
- package/src/cli/help.ts +2 -1
- package/src/cli/index.ts +13 -4
- package/src/cli/integration-plan-dto.ts +2 -0
- package/src/cli/registry.ts +15 -3
- package/src/cli/restart-scope.ts +3 -1
- package/src/clients/config-export/contracts.ts +1 -0
- package/src/clients/config-export/model-metadata.ts +3 -2
- package/src/clients/config-export/qoder.ts +93 -0
- package/src/clients/config-export.ts +17 -4
- package/src/codex/auth-api/pool-mode-gate.ts +5 -2
- package/src/codex/auth-context.ts +79 -5
- package/src/codex/autostart-health.ts +4 -0
- package/src/codex/catalog/build-entries.ts +11 -2
- package/src/codex/catalog/configured-tiers.ts +50 -0
- package/src/codex/catalog/effort.ts +4 -1
- package/src/codex/catalog/model-hints.ts +21 -3
- package/src/codex/catalog/native-ultrafast.ts +106 -0
- package/src/codex/catalog/parsing.ts +5 -0
- package/src/codex/catalog/provider-models.ts +4 -0
- package/src/codex/catalog/retained-sync.ts +2 -0
- package/src/codex/catalog/routed-gather.ts +22 -4
- package/src/codex/catalog/vendor-context.ts +82 -0
- package/src/codex/config-write-lock.ts +40 -2
- package/src/codex/convergence.ts +2 -0
- package/src/codex/desktop-app/types.ts +2 -0
- package/src/codex/desktop-app/windows.ts +29 -16
- package/src/codex/desktop-app-restart.ts +4 -2
- package/src/codex/desktop-compatibility/certificate-service.ts +99 -0
- package/src/codex/desktop-compatibility/certificate-store.ts +193 -0
- package/src/codex/desktop-compatibility/connection-store.ts +87 -0
- package/src/codex/desktop-compatibility/installed-build.ts +55 -0
- package/src/codex/desktop-compatibility/json-body.ts +29 -0
- package/src/codex/desktop-compatibility/native-identity.ts +62 -0
- package/src/codex/desktop-compatibility/relay-listener.ts +93 -0
- package/src/codex/desktop-compatibility/routing-binding.ts +10 -0
- package/src/codex/desktop-compatibility/routing-preflight.ts +69 -0
- package/src/codex/desktop-compatibility/runtime-ownership.ts +28 -0
- package/src/codex/desktop-compatibility/runtime.ts +173 -0
- package/src/codex/desktop-compatibility/service.ts +30 -0
- package/src/codex/desktop-compatibility/startup-settings.ts +50 -0
- package/src/codex/desktop-compatibility/usage-activation.ts +86 -0
- package/src/codex/desktop-compatibility/usage-controlled-fetch.ts +59 -0
- package/src/codex/desktop-compatibility/usage-controller.ts +107 -0
- package/src/codex/desktop-compatibility/usage-policy.ts +59 -0
- package/src/codex/desktop-compatibility/usage-refresh.ts +33 -0
- package/src/codex/desktop-compatibility/usage-sse-controller.ts +86 -0
- package/src/codex/desktop-compatibility/windows-activation-source.ts +44 -0
- package/src/codex/desktop-compatibility/windows-certificate-trust.ts +81 -0
- package/src/codex/desktop-compatibility/windows-key-protection.ts +59 -0
- package/src/codex/desktop-compatibility/windows-package-command.ts +86 -0
- package/src/codex/desktop-compatibility/windows-package-launch.ts +28 -0
- package/src/codex/history-provider.ts +1 -1
- package/src/codex/inject/config-write-section.ts +4 -1
- package/src/codex/inject/remove.ts +18 -6
- package/src/codex/journal.ts +16 -1
- package/src/codex/main-account.ts +99 -15
- package/src/codex/native-main-refresh-mode.ts +217 -0
- package/src/codex/routing/health-store.ts +1 -1
- package/src/codex/routing/pool-recovery.ts +79 -0
- package/src/codex/shim-diagnostics.ts +6 -0
- package/src/codex/shim-overlay.ts +7 -2
- package/src/config/diagnostics.ts +2 -1
- package/src/config/live-reconcile.ts +13 -0
- package/src/config/load-degrade.ts +6 -1
- package/src/config/paths.ts +10 -3
- package/src/config/schema/config-schema.ts +17 -0
- package/src/config/schema/desktop-compatibility.ts +9 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +451 -163
- package/src/generated/model-metadata.ts +1 -1
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/config-io.ts +20 -2
- package/src/integrations/journal.ts +15 -1
- package/src/integrations/mutation-plan.ts +1 -0
- package/src/integrations/registry.ts +7 -0
- package/src/integrations/store.ts +3 -0
- package/src/integrations/writer.ts +10 -1
- package/src/lib/advisor-activation.ts +16 -0
- package/src/lib/background-work.ts +65 -0
- package/src/lib/bounded-file-read.ts +58 -0
- package/src/lib/bun-path-runtime.d.mts +10 -1
- package/src/lib/bun-path-runtime.mjs +62 -3
- package/src/lib/desktop-proxy-route.ts +30 -0
- package/src/lib/desktop-upstream-tunnel.ts +107 -0
- package/src/lib/errors.ts +7 -0
- package/src/lib/local-internal-call-capability.ts +55 -0
- package/src/lib/package-tree-integrity.ts +2 -2
- package/src/lib/socks5-fetch.ts +48 -71
- package/src/lib/socks5-handshake.ts +112 -0
- package/src/lib/translator-budget.ts +7 -1
- package/src/oauth/generic-account-failover.ts +29 -5
- package/src/oauth/index.ts +4 -2
- package/src/oauth/kiro-device-login.ts +2 -1
- package/src/oauth/kiro.ts +7 -1
- package/src/protocols/codecs/chat-ir.ts +467 -0
- package/src/protocols/codecs/chat.ts +10 -1
- package/src/protocols/codecs/messages-ir.ts +500 -0
- package/src/protocols/features.ts +10 -2
- package/src/protocols/path.ts +11 -1
- package/src/protocols/plan-snapshot.ts +39 -16
- package/src/protocols/plan.ts +9 -1
- package/src/protocols/settings.ts +8 -0
- package/src/providers/antigravity-account-roster.ts +102 -0
- package/src/providers/antigravity-models.ts +85 -7
- package/src/providers/model-rename-fields.ts +2 -0
- package/src/providers/model-rename-migration.ts +5 -3
- package/src/providers/registry/entries-core.ts +7 -6
- package/src/providers/registry/entries-extended.ts +19 -0
- package/src/responses/parser.ts +30 -19
- package/src/responses/state.ts +32 -29
- package/src/routing/history/build-file.ts +92 -0
- package/src/routing/history/indexer.ts +248 -216
- package/src/routing/history/ingest.ts +238 -0
- package/src/routing/history/rebuild-job.ts +111 -0
- package/src/routing/history/rebuild-protocol.ts +15 -0
- package/src/routing/history/rebuild-worker.ts +34 -0
- package/src/server/admission-model-scope.ts +4 -1
- package/src/server/antigravity-direct-eligibility.ts +208 -0
- package/src/server/auth-cors.ts +4 -0
- package/src/server/chat-completions.ts +75 -9
- package/src/server/claude-messages.ts +110 -20
- package/src/server/index/desktop-compatibility-startup.ts +56 -0
- package/src/server/index/serve-options.ts +2 -2
- package/src/server/index/startup-warnings.ts +9 -0
- package/src/server/index/websocket-handler.ts +9 -0
- package/src/server/index.ts +13 -10
- package/src/server/inference/client-encoder-delivery.ts +6 -5
- package/src/server/login-shell-path.ts +146 -0
- package/src/server/management/advisor-routes.ts +191 -0
- package/src/server/management/agent-settings-routes.ts +22 -4
- package/src/server/management/companion-routes.ts +4 -0
- package/src/server/management/config-routes.ts +2 -2
- package/src/server/management/context.ts +4 -0
- package/src/server/management/desktop-compatibility-routes.ts +46 -0
- package/src/server/management/desktop-compatibility-runtime-routes.ts +46 -0
- package/src/server/management/desktop-compatibility-settings-routes.ts +27 -0
- package/src/server/management/model-routes.ts +2 -2
- package/src/server/management/protocol-settings-patch.ts +1 -0
- package/src/server/management/route-registry.ts +11 -0
- package/src/server/management/sibling-guard.ts +1 -0
- package/src/server/management-api.ts +6 -0
- package/src/server/messages-antigravity-direct.ts +195 -0
- package/src/server/messages-native.ts +5 -1
- package/src/server/request-log.ts +7 -0
- package/src/server/responses/adapter-continuation.ts +35 -18
- package/src/server/responses/adapter-delivery.ts +24 -2
- package/src/server/responses/adapter-dispatch.ts +29 -2
- package/src/server/responses/advisor-plan-slot.ts +27 -0
- package/src/server/responses/advisor-slot.ts +336 -0
- package/src/server/responses/code-mode-collab-surface.ts +116 -8
- package/src/server/responses/core-combo-failure.ts +10 -1
- package/src/server/responses/core-combo.ts +21 -0
- package/src/server/responses/core-lifetime.ts +2 -0
- package/src/server/responses/core-normalize.ts +11 -0
- package/src/server/responses/core-options.ts +46 -0
- package/src/server/responses/core-replay.ts +7 -4
- package/src/server/responses/core.ts +1 -1
- package/src/server/responses/passthrough-delivery.ts +16 -10
- package/src/server/responses/passthrough-dispatch.ts +14 -3
- package/src/server/responses/request-prepare.ts +68 -5
- package/src/server/responses/request-transport.ts +16 -0
- package/src/server/responses/run-turn-execution.ts +70 -8
- package/src/server/responses/sidecar-execution.ts +78 -3
- package/src/server/responses/terminal-guard.ts +2 -1
- package/src/server/responses-undeclared-tool-guard.ts +30 -9
- package/src/server/startup-action-control.ts +16 -2
- package/src/server/startup-health-cache.ts +1 -0
- package/src/service/desktop-startup.ts +14 -0
- package/src/service/health-sentinel-main.ts +23 -4
- package/src/service/health-sentinel-probe.ts +24 -7
- package/src/service/logging.ts +72 -0
- package/src/tray/windows-tray.ps1 +43 -24
- package/src/types/config.ts +63 -6
- package/src/types/provider.ts +7 -0
- package/src/types/request.ts +15 -0
- package/src/types/tools.ts +2 -0
- package/src/update/async-check.ts +1 -1
- package/src/update/badge.ts +2 -2
- package/src/update/index.ts +88 -15
- package/src/update/job-supervised-restart.ts +107 -0
- package/src/update/job.ts +32 -32
- package/src/update/notify.ts +1 -1
- package/src/update/refresh-scheduler.ts +2 -2
- package/src/update/supervised-recovery.d.mts +27 -0
- package/src/update/supervised-recovery.mjs +157 -0
- package/src/usage/cache-diagnostic.ts +2 -0
- package/src/usage/expected-prices.ts +12 -0
- package/src/web-search/gemini-executor.ts +2 -2
- package/assets/sponsors/packycode.png +0 -0
- package/gui/dist/assets/App-CZtqiGcw.js +0 -56
- package/gui/dist/assets/index-CTm4l-HQ.js +0 -94
- package/gui/dist/assets/quota-summary-BbEeRfPY.js +0 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getConfigDir } from "../../config/paths";
|
|
4
|
+
import { issueServerLeaf } from "../../claude/intercept/local-ca";
|
|
5
|
+
import { startConnectProxy, type ConnectProxyHandle } from "../../claude/intercept/connect-proxy";
|
|
6
|
+
import { didRunOptionalShutdownHooks, registerOptionalShutdownHook } from "../../lib/optional-shutdown-hooks";
|
|
7
|
+
import { isTestHomeGuardArmed } from "../../lib/test-home-guard";
|
|
8
|
+
import { desktopOutboundFetch, desktopProxyFor } from "../../lib/desktop-proxy-route";
|
|
9
|
+
import { inspectDesktopCompatibilityAuthority, loadDesktopCompatibilityAuthority, type StoredDesktopAuthority } from "./certificate-store";
|
|
10
|
+
import { inspectWindowsCertificateTrust } from "./windows-certificate-trust";
|
|
11
|
+
import { createNativeIdentityReader } from "./native-identity";
|
|
12
|
+
import { UsageRelayController, type UsageIdentity } from "./usage-controller";
|
|
13
|
+
import { createUsageControlledFetch } from "./usage-controlled-fetch";
|
|
14
|
+
import { startDesktopRelay } from "./relay-listener";
|
|
15
|
+
import { launchWindowsCodexCompatibility } from "./windows-package-launch";
|
|
16
|
+
import { acquireDesktopCompatibilityRuntime, bindDesktopCompatibilityLaunch } from "./runtime-ownership";
|
|
17
|
+
import { createDesktopConnectionStore, type DesktopConnectionStore } from "./connection-store";
|
|
18
|
+
import { createNativeRoutingVerifier } from "./routing-preflight";
|
|
19
|
+
import { createInstalledBuildProbe } from "./installed-build";
|
|
20
|
+
|
|
21
|
+
export { DESKTOP_COMPATIBILITY_ASSESSED_VERSION, isAssessedDesktopInstalled } from "./installed-build";
|
|
22
|
+
export interface DesktopRuntimeIo {
|
|
23
|
+
platform?: string;
|
|
24
|
+
loadAuthority?: () => Promise<StoredDesktopAuthority>;
|
|
25
|
+
trust?: (authority: StoredDesktopAuthority) => Promise<"trusted" | "not-trusted" | "unknown">;
|
|
26
|
+
identity?: ReturnType<typeof createNativeIdentityReader>;
|
|
27
|
+
buildSupported?: () => boolean | Promise<boolean>;
|
|
28
|
+
upstreamFetch?: typeof fetch;
|
|
29
|
+
connectionStore?: DesktopConnectionStore;
|
|
30
|
+
routingSupported?: () => boolean;
|
|
31
|
+
/** Synthetic-test execution is admitted only with explicit fixture identity and CA seams. */
|
|
32
|
+
testOnly?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Optional runtime. Import/construction/status do not start listeners or touch credentials. */
|
|
36
|
+
export function createDesktopCompatibilityRuntime(io: DesktopRuntimeIo = {}) {
|
|
37
|
+
const platform = io.platform ?? process.platform;
|
|
38
|
+
let buildProbe: ReturnType<typeof createInstalledBuildProbe> | undefined;
|
|
39
|
+
const buildSupported = io.buildSupported ?? (() => buildProbe?.check() ?? Promise.resolve(false));
|
|
40
|
+
let routingSupported = io.routingSupported ?? (() => false);
|
|
41
|
+
let contextFailure: "build_unverified" | "native_routing_unverified" | null = null;
|
|
42
|
+
const contextValid = async () => {
|
|
43
|
+
try {
|
|
44
|
+
if (!routingSupported()) { contextFailure = "native_routing_unverified"; return false; }
|
|
45
|
+
const supported = await buildSupported();
|
|
46
|
+
contextFailure = !routingSupported() ? "native_routing_unverified" : !supported ? "build_unverified" : null;
|
|
47
|
+
}
|
|
48
|
+
catch { contextFailure = "build_unverified"; }
|
|
49
|
+
return contextFailure === null;
|
|
50
|
+
};
|
|
51
|
+
let phase: "off" | "starting" | "running" | "stopping" | "cleanup-required" = "off";
|
|
52
|
+
let owned: { controller: UsageRelayController; close(): Promise<void>; pacUrl: string; fingerprint: string; deadline: number } | null = null;
|
|
53
|
+
let closing: Promise<void> | null = null;
|
|
54
|
+
let pendingCleanup: (() => Promise<void>) | null = null;
|
|
55
|
+
let releaseOwner: (() => void) | null = null;
|
|
56
|
+
const status = () => ({ phase, supported: platform === "win32", running: owned !== null, contextFailure,
|
|
57
|
+
...(owned ? { fingerprint: owned.fingerprint, safetyDeadline: owned.deadline, usage: owned.controller.snapshot() } : {}) });
|
|
58
|
+
async function stop(): Promise<ReturnType<typeof status>> {
|
|
59
|
+
if (phase === "starting") throw new Error("desktop_compatibility_busy");
|
|
60
|
+
if (closing) { await closing; return status(); }
|
|
61
|
+
if (!pendingCleanup) return status();
|
|
62
|
+
phase = "stopping";
|
|
63
|
+
closing = pendingCleanup().then(() => { owned = null; pendingCleanup = null; phase = "off"; releaseOwner?.(); releaseOwner = null; })
|
|
64
|
+
.catch(error => { phase = "cleanup-required"; throw error; }).finally(() => { closing = null; });
|
|
65
|
+
await closing; return status();
|
|
66
|
+
}
|
|
67
|
+
async function start() {
|
|
68
|
+
if (phase !== "off") throw new Error("desktop_compatibility_busy");
|
|
69
|
+
if (platform !== "win32") throw new Error("desktop_compatibility_unsupported");
|
|
70
|
+
if (isTestHomeGuardArmed() && !(io.testOnly && io.identity && io.loadAuthority && io.trust && io.buildSupported && io.connectionStore && io.routingSupported)) throw new Error("desktop_compatibility_test_environment");
|
|
71
|
+
if (didRunOptionalShutdownHooks()) throw new Error("desktop_compatibility_stopping");
|
|
72
|
+
try { desktopProxyFor(new URL("https://chatgpt.com")); } catch { throw new Error("desktop_compatibility_egress_proxy_invalid"); }
|
|
73
|
+
releaseOwner = acquireDesktopCompatibilityRuntime(); phase = "starting";
|
|
74
|
+
if (!io.buildSupported) buildProbe = createInstalledBuildProbe();
|
|
75
|
+
let relay: Awaited<ReturnType<typeof startDesktopRelay>> | undefined, proxy: ConnectProxyHandle | undefined;
|
|
76
|
+
let pac: ReturnType<typeof Bun.serve> | undefined, timer: ReturnType<typeof setInterval> | undefined;
|
|
77
|
+
let detach: (() => void) | undefined, unbindLaunch: (() => void) | undefined, controller: UsageRelayController | undefined;
|
|
78
|
+
const cleanup = async () => {
|
|
79
|
+
unbindLaunch?.();
|
|
80
|
+
if (timer) clearInterval(timer); detach?.();
|
|
81
|
+
// Abort transport before awaiting stream refresh so a stuck reader cannot retain sockets.
|
|
82
|
+
const results = await Promise.allSettled([pac?.stop(true), proxy?.close(), relay?.close(), controller?.observeOnly(), buildProbe?.close()]);
|
|
83
|
+
if (results.some(result => result.status === "rejected")) throw new Error("desktop_compatibility_cleanup_incomplete");
|
|
84
|
+
};
|
|
85
|
+
pendingCleanup = cleanup;
|
|
86
|
+
try {
|
|
87
|
+
if (!io.routingSupported) routingSupported = createNativeRoutingVerifier((await import("../paths")).getCodexHome());
|
|
88
|
+
if (!await contextValid()) throw new Error(`desktop_compatibility_${contextFailure}`);
|
|
89
|
+
const directory = join(getConfigDir(), "codex-desktop-compatibility");
|
|
90
|
+
if (!io.loadAuthority) {
|
|
91
|
+
const saved = inspectDesktopCompatibilityAuthority(directory);
|
|
92
|
+
if (saved.status === "missing") throw new Error("desktop_compatibility_certificate_not_prepared");
|
|
93
|
+
if (saved.status === "invalid") throw new Error("desktop_compatibility_certificate_invalid");
|
|
94
|
+
if (saved.status === "expired") throw new Error("desktop_compatibility_certificate_expired");
|
|
95
|
+
}
|
|
96
|
+
const authority = await (io.loadAuthority?.() ?? loadDesktopCompatibilityAuthority({ directory }));
|
|
97
|
+
const trust = await (io.trust?.(authority) ?? inspectWindowsCertificateTrust(authority.authority.certPem, authority.fingerprint));
|
|
98
|
+
if (trust !== "trusted") throw new Error("desktop_compatibility_trust_required");
|
|
99
|
+
const connections = io.connectionStore ?? createDesktopConnectionStore(directory);
|
|
100
|
+
const previousConnection = connections.read();
|
|
101
|
+
const deadline = authority.expiresAt - 300_000;
|
|
102
|
+
if (deadline <= Date.now()) throw new Error("desktop_compatibility_certificate_expiring");
|
|
103
|
+
const identity = io.identity ?? createNativeIdentityReader(join((await import("../paths")).getCodexHome(), "auth.json"), desktopOutboundFetch as typeof fetch);
|
|
104
|
+
const account: UsageIdentity | null = await identity.verifyFreshIdentity();
|
|
105
|
+
if (!account) throw new Error("desktop_compatibility_native_identity_unverified");
|
|
106
|
+
if (didRunOptionalShutdownHooks()) throw new Error("desktop_compatibility_stopping");
|
|
107
|
+
controller = new UsageRelayController(account, identity.readCurrentIdentity, identity.verifyFreshIdentity, Date.now, deadline, 180000, contextValid);
|
|
108
|
+
relay = await startDesktopRelay({ leaf: issueServerLeaf(authority.authority, authority.commonName, ["chatgpt.com"]),
|
|
109
|
+
fetchImpl: createUsageControlledFetch(controller, io.upstreamFetch ?? desktopOutboundFetch as typeof fetch) });
|
|
110
|
+
try { proxy = await startConnectProxy(previousConnection?.connectPort ?? 0, { interceptPort: relay.port, interceptHosts: ["chatgpt.com"], allowedTargets: ["chatgpt.com:443"] }); }
|
|
111
|
+
catch { throw new Error("desktop_compatibility_connection_unavailable"); }
|
|
112
|
+
const runId = previousConnection?.id ?? randomUUID(), proxyPort = proxy.port;
|
|
113
|
+
try { pac = Bun.serve({ hostname: "127.0.0.1", port: previousConnection?.pacPort ?? 0, fetch(req) {
|
|
114
|
+
const url = new URL(req.url);
|
|
115
|
+
if (req.method !== "GET" || req.headers.has("origin") || url.origin !== `http://127.0.0.1:${pac!.port}` || url.pathname !== `/${runId}/proxy.pac`) return new Response(null, { status: 404 });
|
|
116
|
+
return new Response(`function FindProxyForURL(url, host) { return Date.now() < ${deadline} && host.toLowerCase() === "chatgpt.com" && url.indexOf("https:") === 0 ? "PROXY 127.0.0.1:${proxyPort}; DIRECT" : "DIRECT"; }`,
|
|
117
|
+
{ headers: { "content-type": "application/x-ns-proxy-autoconfig", "cache-control": "no-store" } });
|
|
118
|
+
} }); } catch { throw new Error("desktop_compatibility_connection_unavailable"); }
|
|
119
|
+
const connection = await connections.publish({ version: 1, id: runId, connectPort: proxyPort, pacPort: pac.port! });
|
|
120
|
+
if (connection.id !== runId || connection.connectPort !== proxyPort || connection.pacPort !== pac.port) throw new Error("desktop_compatibility_connection_changed");
|
|
121
|
+
if (didRunOptionalShutdownHooks()) throw new Error("desktop_compatibility_stopping");
|
|
122
|
+
const active = controller; let ticking = false, deadlineHandled = false, lastContextCheck = Date.now();
|
|
123
|
+
timer = setInterval(() => {
|
|
124
|
+
if (ticking) return; ticking = true;
|
|
125
|
+
const expired = Date.now() >= deadline;
|
|
126
|
+
const work = (async () => {
|
|
127
|
+
const contextCadence = active.snapshot().mode === "apply" ? 10000 : 60000;
|
|
128
|
+
if (!expired && Date.now() - lastContextCheck >= contextCadence) {
|
|
129
|
+
lastContextCheck = Date.now();
|
|
130
|
+
if (!await contextValid() && active.snapshot().mode === "apply") { await active.observeOnly(); return; }
|
|
131
|
+
}
|
|
132
|
+
await (expired && !deadlineHandled ? (deadlineHandled = true, active.observeOnly()) : active.expireIfNeeded());
|
|
133
|
+
})();
|
|
134
|
+
void work.catch(() => {}).finally(() => { ticking = false; });
|
|
135
|
+
}, 1000); timer.unref();
|
|
136
|
+
owned = { controller, close: cleanup, fingerprint: authority.fingerprint, deadline, pacUrl: `http://127.0.0.1:${pac.port}/${runId}/proxy.pac` };
|
|
137
|
+
phase = "running";
|
|
138
|
+
const launchedBy = owned;
|
|
139
|
+
unbindLaunch = bindDesktopCompatibilityLaunch(owned.pacUrl,
|
|
140
|
+
() => owned === launchedBy && phase === "running" && Date.now() < deadline);
|
|
141
|
+
detach = registerOptionalShutdownHook("codex-desktop-compatibility", () => { void stop().catch(() => {}); });
|
|
142
|
+
return status();
|
|
143
|
+
} catch (error) {
|
|
144
|
+
try { await cleanup(); pendingCleanup = null; phase = "off"; releaseOwner?.(); releaseOwner = null; }
|
|
145
|
+
catch { phase = "cleanup-required"; throw new Error("desktop_compatibility_cleanup_incomplete"); }
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function apply(accountWideConsent: boolean) {
|
|
150
|
+
const current = owned;
|
|
151
|
+
if (!current || phase !== "running") throw new Error("desktop_compatibility_not_running");
|
|
152
|
+
const generation = current.controller.snapshot().generation, valid = await contextValid();
|
|
153
|
+
if (owned !== current || phase !== "running") throw new Error("desktop_compatibility_not_running");
|
|
154
|
+
if (generation !== current.controller.snapshot().generation) return { accepted: false, reason: "superseded", ...current.controller.snapshot() };
|
|
155
|
+
if (!valid) { await current.controller.observeOnly(); throw new Error(`desktop_compatibility_${contextFailure}`); }
|
|
156
|
+
const result = await current.controller.activate({ scope: "account-ui-compatibility", accountWideConsent });
|
|
157
|
+
if (owned !== current || phase !== "running") { await current.controller.observeOnly(); return { ...result, accepted: false, reason: "runtime-stopped" }; }
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
return { status, start, stop, apply,
|
|
161
|
+
async observe() { if (owned) await owned.controller.observeOnly(); return status(); },
|
|
162
|
+
async launch() {
|
|
163
|
+
if (!owned || phase !== "running") throw new Error("desktop_compatibility_not_running");
|
|
164
|
+
const current = owned;
|
|
165
|
+
if (!await contextValid()) throw new Error(`desktop_compatibility_${contextFailure}`);
|
|
166
|
+
if (owned !== current || phase !== "running") throw new Error("desktop_compatibility_not_running");
|
|
167
|
+
return launchWindowsCodexCompatibility(current.pacUrl);
|
|
168
|
+
},
|
|
169
|
+
/** Internal native launch context; management status never returns the control endpoint. */
|
|
170
|
+
getPacUrl: () => owned?.pacUrl ?? null,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
export type DesktopCompatibilityRuntime = ReturnType<typeof createDesktopCompatibilityRuntime>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createDesktopCompatibilityRuntime, type DesktopCompatibilityRuntime } from "./runtime";
|
|
2
|
+
|
|
3
|
+
/** One runtime for management and automatic startup; shutdown never constructs a new instance. */
|
|
4
|
+
export function createDesktopCompatibilityService(factory: () => DesktopCompatibilityRuntime = createDesktopCompatibilityRuntime) {
|
|
5
|
+
let runtime: DesktopCompatibilityRuntime | undefined;
|
|
6
|
+
let starting: Promise<ReturnType<DesktopCompatibilityRuntime["status"]>> | null = null;
|
|
7
|
+
let shuttingDown = false;
|
|
8
|
+
return {
|
|
9
|
+
get(): DesktopCompatibilityRuntime {
|
|
10
|
+
if (!runtime) {
|
|
11
|
+
const raw = factory();
|
|
12
|
+
runtime = { ...raw, start() {
|
|
13
|
+
if (shuttingDown) return Promise.reject(new Error("desktop_compatibility_stopping"));
|
|
14
|
+
if (starting) return Promise.reject(new Error("desktop_compatibility_busy"));
|
|
15
|
+
starting = raw.start().finally(() => { starting = null; });
|
|
16
|
+
return starting;
|
|
17
|
+
} };
|
|
18
|
+
}
|
|
19
|
+
return runtime;
|
|
20
|
+
},
|
|
21
|
+
async shutdown(): Promise<void> {
|
|
22
|
+
shuttingDown = true;
|
|
23
|
+
try { await starting; } catch { /* failed start owns its cleanup */ }
|
|
24
|
+
if (runtime) await runtime.stop();
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const service = createDesktopCompatibilityService();
|
|
29
|
+
export const getDesktopCompatibilityRuntime = () => service.get();
|
|
30
|
+
export const shutdownDesktopCompatibility = () => service.shutdown();
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readConfigFileSnapshot } from "../../config/diagnostics";
|
|
3
|
+
import { mutatePersistedConfig } from "../../config/persisted-mutation";
|
|
4
|
+
import { desktopCompatibilityConfigError } from "../../config/schema/desktop-compatibility";
|
|
5
|
+
import { adoptPersistedDesktopCompatibility } from "../../config/live-reconcile";
|
|
6
|
+
import type { OcxConfig } from "../../types";
|
|
7
|
+
|
|
8
|
+
export interface DesktopStartupSettings { startOnProxyStart: boolean; revision: string }
|
|
9
|
+
interface SettingsIo { read?: typeof readConfigFileSnapshot; mutate?: typeof mutatePersistedConfig; liveConfig?: OcxConfig }
|
|
10
|
+
const failure = (code: string) => new Error(`desktop_compatibility_settings_${code}`);
|
|
11
|
+
/** A next-process preference: never changes live runtime, trust, app or login state. */
|
|
12
|
+
export function createDesktopStartupSettings(io: SettingsIo = {}) {
|
|
13
|
+
const read = io.read ?? readConfigFileSnapshot, mutate = io.mutate ?? mutatePersistedConfig;
|
|
14
|
+
function inspect() {
|
|
15
|
+
const snapshot = read();
|
|
16
|
+
if (snapshot.diagnostics.source !== "file" || snapshot.raw === undefined) throw failure("unavailable");
|
|
17
|
+
let raw: Record<string, unknown>;
|
|
18
|
+
try { raw = JSON.parse(snapshot.raw.replace(/^\uFEFF/, "")); } catch { throw failure("unavailable"); }
|
|
19
|
+
if (desktopCompatibilityConfigError(raw)) throw failure("invalid");
|
|
20
|
+
const setting = raw.desktopCompatibility as { startOnProxyStart: boolean } | undefined;
|
|
21
|
+
return { setting, status: { startOnProxyStart: setting?.startOnProxyStart === true,
|
|
22
|
+
revision: createHash("sha256").update(JSON.stringify(setting ?? null)).digest("hex") } };
|
|
23
|
+
}
|
|
24
|
+
const status = (): DesktopStartupSettings => inspect().status;
|
|
25
|
+
function set(startOnProxyStart: boolean, expectedRevision: string): DesktopStartupSettings {
|
|
26
|
+
if (typeof startOnProxyStart !== "boolean" || !/^[a-f0-9]{64}$/.test(expectedRevision)) throw failure("invalid_request");
|
|
27
|
+
let outcome;
|
|
28
|
+
try { outcome = mutate(config => {
|
|
29
|
+
// Runs under the existing config lock, including each rebase retry. Unrelated fields
|
|
30
|
+
// may change, but a changed preference must be shown again before this write.
|
|
31
|
+
const current = status();
|
|
32
|
+
if (current.revision !== expectedRevision) throw failure("changed");
|
|
33
|
+
if (current.startOnProxyStart === startOnProxyStart) return { changed: false, value: startOnProxyStart };
|
|
34
|
+
config.desktopCompatibility = { startOnProxyStart };
|
|
35
|
+
return { changed: true, value: startOnProxyStart };
|
|
36
|
+
}); } catch (error) {
|
|
37
|
+
// Publication can succeed before bookkeeping fails. Reconcile a verified disk
|
|
38
|
+
// field without claiming success or replaying the uncertain write.
|
|
39
|
+
if (io.liveConfig) { try { adoptPersistedDesktopCompatibility(io.liveConfig, inspect().setting); } catch { /* unresolved state remains an error */ } }
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
if (outcome.status === "unavailable") throw failure("unavailable");
|
|
43
|
+
const actual = inspect();
|
|
44
|
+
if (io.liveConfig) adoptPersistedDesktopCompatibility(io.liveConfig, actual.setting);
|
|
45
|
+
if (actual.status.startOnProxyStart !== startOnProxyStart) throw failure("changed");
|
|
46
|
+
return actual.status;
|
|
47
|
+
}
|
|
48
|
+
return { status, set };
|
|
49
|
+
}
|
|
50
|
+
export type DesktopStartupSettingsService = ReturnType<typeof createDesktopStartupSettings>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** Account-bound, time-limited compatibility activation; no persistent settings or timers. */
|
|
2
|
+
export type Observation = 'exhausted' | 'available' | 'protected';
|
|
3
|
+
export type ApplyScope = 'selected-external-model' | 'account-ui-compatibility';
|
|
4
|
+
type RefreshResult = {requested:number;closed:number;failed:number};
|
|
5
|
+
export class UsageActivation {
|
|
6
|
+
private mode: 'observe' | 'apply' = 'observe';
|
|
7
|
+
private phase = 'observing';
|
|
8
|
+
private generation = 0;
|
|
9
|
+
private latest: { kind:Observation; at:number } | null = null;
|
|
10
|
+
private startedAt: number | null = null;
|
|
11
|
+
private outputs = 0;
|
|
12
|
+
constructor(private binding:string, private refresh:(binding:string)=>Promise<RefreshResult>,
|
|
13
|
+
private verifyIdentity:()=>Promise<string|null>,
|
|
14
|
+
private clock:()=>number=Date.now, private timeoutMs=180000) {
|
|
15
|
+
if(!binding || !Number.isSafeInteger(timeoutMs) || timeoutMs<1 || timeoutMs>180000) throw new Error('Invalid activation limits');
|
|
16
|
+
}
|
|
17
|
+
snapshot() { return {mode:this.mode,phase:this.phase,generation:this.generation,outputs:this.outputs,
|
|
18
|
+
appCacheConfirmed:false,requestedUiScope:'account-wide',providerIsolationAvailable:false}; }
|
|
19
|
+
invalidateIdentity() {
|
|
20
|
+
this.mode='observe';this.phase='identity-invalidated';this.startedAt=null;this.latest=null;this.outputs=0;++this.generation;
|
|
21
|
+
}
|
|
22
|
+
observe(binding:string,kind:Observation) {
|
|
23
|
+
if(binding!==this.binding)return false;
|
|
24
|
+
this.latest={kind,at:this.clock()};
|
|
25
|
+
// A recovered or protected account must not silently re-enter a previous trial
|
|
26
|
+
// if a later snapshot becomes exhausted again. A new trial requires consent.
|
|
27
|
+
if(this.mode==='apply'&&kind!=='exhausted'){
|
|
28
|
+
this.mode='observe';this.phase=kind==='available'?'usage-recovered':'protected-observation';
|
|
29
|
+
this.startedAt=null;this.outputs=0;++this.generation;
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
private async reset(phase:string) {
|
|
34
|
+
this.mode='observe';this.phase=phase;this.startedAt=null;this.latest=null;this.outputs=0;++this.generation;
|
|
35
|
+
return this.refresh(this.binding);
|
|
36
|
+
}
|
|
37
|
+
async activate(options:{scope:ApplyScope;accountWideConsent:boolean}) {
|
|
38
|
+
if(options.scope!=='account-ui-compatibility')return{accepted:false,reason:'selected-model-scope-unavailable',...this.snapshot()};
|
|
39
|
+
if(!options.accountWideConsent)return{accepted:false,reason:'account-wide-scope-not-accepted',...this.snapshot()};
|
|
40
|
+
const beforeVerification=this.generation,binding=this.binding;
|
|
41
|
+
let verified:string|null;
|
|
42
|
+
try{verified=await this.verifyIdentity();}catch{verified=null;}
|
|
43
|
+
if(verified!==binding||this.binding!==binding||this.generation!==beforeVerification)return{accepted:false,reason:'identity-not-verified',...this.snapshot()};
|
|
44
|
+
if(this.mode==='apply')return{accepted:false,reason:'activation-already-pending',...this.snapshot()};
|
|
45
|
+
const age=this.latest?this.clock()-this.latest.at:Infinity;
|
|
46
|
+
if(!this.latest||this.latest.kind!=='exhausted'||age<0||age>300000)return{accepted:false,reason:'no-fresh-eligible-exhaustion',...this.snapshot()};
|
|
47
|
+
// An exhausted observation authorizes one trial, not every retry within its TTL.
|
|
48
|
+
this.latest=null;
|
|
49
|
+
this.mode='apply';this.phase='awaiting-fresh-usage';this.startedAt=this.clock();this.outputs=0;
|
|
50
|
+
const generation=++this.generation;
|
|
51
|
+
let refreshed:RefreshResult;
|
|
52
|
+
try { refreshed=await this.refresh(this.binding); }
|
|
53
|
+
catch {
|
|
54
|
+
if(this.generation===generation){this.mode='observe';this.phase='refresh-failed';this.startedAt=null;this.latest=null;this.outputs=0;++this.generation;}
|
|
55
|
+
return{accepted:false,reason:'refresh-failed',...this.snapshot()};
|
|
56
|
+
}
|
|
57
|
+
// Account/mode changes while a close callback awaited invalidate this activation.
|
|
58
|
+
if(this.generation!==generation)return{accepted:false,reason:'superseded',...this.snapshot()};
|
|
59
|
+
// The registry settles every close and reports failures; it does not reject the batch.
|
|
60
|
+
// Partial closure cannot leave rewriting enabled while an old app cache may survive.
|
|
61
|
+
if(!Number.isSafeInteger(refreshed.requested)||!Number.isSafeInteger(refreshed.closed)
|
|
62
|
+
||!Number.isSafeInteger(refreshed.failed)||refreshed.requested<0||refreshed.closed<0
|
|
63
|
+
||refreshed.failed!==0||refreshed.closed!==refreshed.requested){
|
|
64
|
+
this.mode='observe';this.phase='refresh-failed';this.startedAt=null;this.latest=null;this.outputs=0;++this.generation;
|
|
65
|
+
return{accepted:false,reason:'refresh-failed',refresh:refreshed,...this.snapshot()};
|
|
66
|
+
}
|
|
67
|
+
return{accepted:true,reason:'awaiting-new-response',refresh:refreshed,...this.snapshot()};
|
|
68
|
+
}
|
|
69
|
+
recordOutput(binding:string,generation:number) {
|
|
70
|
+
if(binding!==this.binding||generation!==this.generation||this.mode!=='apply')return false;
|
|
71
|
+
if(this.startedAt===null||this.clock()-this.startedAt<0||this.clock()-this.startedAt>=this.timeoutMs)return false;
|
|
72
|
+
++this.outputs;this.phase='response-produced-app-confirmation-needed';return true;
|
|
73
|
+
}
|
|
74
|
+
async expireIfNeeded() {
|
|
75
|
+
if(this.startedAt===null||this.clock()-this.startedAt<this.timeoutMs&&this.clock()-this.startedAt>=0)return false;
|
|
76
|
+
await this.reset('expired-awaiting-original-response');return true;
|
|
77
|
+
}
|
|
78
|
+
async observeOnly() { return this.reset('observing-awaiting-original-response'); }
|
|
79
|
+
async changeBinding(binding:string) {
|
|
80
|
+
if(!binding)throw new Error('Verified account binding is required');
|
|
81
|
+
const previous=this.binding;
|
|
82
|
+
this.mode='observe';this.phase='account-changed';this.startedAt=null;this.latest=null;this.outputs=0;++this.generation;
|
|
83
|
+
this.binding=binding;
|
|
84
|
+
return this.refresh(previous);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { UsageRelayController } from './usage-controller';
|
|
2
|
+
import { controlledUsageSse } from './usage-sse-controller';
|
|
3
|
+
import { boundedJsonBody } from './json-body';
|
|
4
|
+
|
|
5
|
+
/** Inject as the listener's fetchImpl. Existing auth forwarding/TLS/WS code stays unchanged. */
|
|
6
|
+
export function createUsageControlledFetch(controller: UsageRelayController, upstreamFetch: typeof fetch = fetch): typeof fetch {
|
|
7
|
+
return (async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
8
|
+
const upstream = await upstreamFetch(input, init);
|
|
9
|
+
const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname;
|
|
10
|
+
const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
|
|
11
|
+
const exchange = { pathname, method, status: upstream.status };
|
|
12
|
+
if (method !== 'GET' || upstream.status !== 200 || !upstream.body
|
|
13
|
+
|| !['/backend-api/wham/usage', '/backend-api/wham/usage/stream'].includes(pathname)) return upstream;
|
|
14
|
+
const contentType = upstream.headers.get('content-type') ?? '';
|
|
15
|
+
const headers = new Headers(upstream.headers);
|
|
16
|
+
const output = (body: BodyInit, transformed = false) => {
|
|
17
|
+
headers.delete('content-length'); headers.delete('content-encoding');
|
|
18
|
+
if (transformed) {
|
|
19
|
+
for (const name of ['etag', 'last-modified', 'digest', 'content-md5']) headers.delete(name);
|
|
20
|
+
headers.set('cache-control', 'no-store');
|
|
21
|
+
}
|
|
22
|
+
return new Response(body, { status: upstream.status, statusText: upstream.statusText, headers });
|
|
23
|
+
};
|
|
24
|
+
if (contentType.includes('application/json') || contentType.endsWith('+json')) {
|
|
25
|
+
const body = await boundedJsonBody(upstream.body);
|
|
26
|
+
if ('stream' in body) return output(body.stream);
|
|
27
|
+
let text: string;
|
|
28
|
+
try { text = new TextDecoder('utf-8', { fatal: true }).decode(body.bytes); }
|
|
29
|
+
catch { return output(new Uint8Array(body.bytes)); }
|
|
30
|
+
const changed = await controller.rewriteJson(text, exchange);
|
|
31
|
+
return output(changed ?? new Uint8Array(body.bytes), changed !== null);
|
|
32
|
+
}
|
|
33
|
+
if (!contentType.includes('text/event-stream') || pathname !== '/backend-api/wham/usage/stream') return upstream;
|
|
34
|
+
let registration: ReturnType<UsageRelayController['registerStream']> = null;
|
|
35
|
+
const reader = upstream.body.pipeThrough(controlledUsageSse(text => controller.rewriteJson(text, exchange, registration))).getReader();
|
|
36
|
+
let ended = false, sink: ReadableStreamDefaultController<Uint8Array>;
|
|
37
|
+
const close = async () => {
|
|
38
|
+
if (ended) return;
|
|
39
|
+
ended = true; registration?.release();
|
|
40
|
+
const cancelled = reader.cancel();
|
|
41
|
+
try { sink.close(); } catch { /* caller already cancelled */ }
|
|
42
|
+
await cancelled;
|
|
43
|
+
};
|
|
44
|
+
const body = new ReadableStream<Uint8Array>({
|
|
45
|
+
start(value) { sink = value; },
|
|
46
|
+
async pull(value) {
|
|
47
|
+
try {
|
|
48
|
+
const next = await reader.read();
|
|
49
|
+
if (ended) return;
|
|
50
|
+
if (next.done) { ended = true; registration?.release(); value.close(); }
|
|
51
|
+
else value.enqueue(next.value);
|
|
52
|
+
} catch (error) { if (!ended) { ended = true; registration?.release(); value.error(error); } }
|
|
53
|
+
},
|
|
54
|
+
cancel: close,
|
|
55
|
+
});
|
|
56
|
+
registration = controller.registerStream(exchange, close);
|
|
57
|
+
return output(body, true);
|
|
58
|
+
}) as typeof fetch;
|
|
59
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { UsageActivation, type ApplyScope } from './usage-activation';
|
|
2
|
+
import { UsageRefreshRegistry } from './usage-refresh';
|
|
3
|
+
import { evaluateUsageRewrite, type UsageRewriteContext } from './usage-policy';
|
|
4
|
+
|
|
5
|
+
export type UsageIdentity = NonNullable<UsageRewriteContext['account']> & { credentialGeneration?: string };
|
|
6
|
+
type Registration = NonNullable<ReturnType<UsageRefreshRegistry['register']>>;
|
|
7
|
+
type Exchange = { method: string; pathname: string; status: number };
|
|
8
|
+
const same = (a: UsageIdentity | null, b: UsageIdentity) => a !== null
|
|
9
|
+
&& a.id === b.id && a.userId === b.userId && a.plan === b.plan && a.structure === b.structure
|
|
10
|
+
&& a.credentialGeneration === b.credentialGeneration;
|
|
11
|
+
const object = (value: unknown): value is Record<string, unknown> => value !== null
|
|
12
|
+
&& typeof value === 'object' && !Array.isArray(value);
|
|
13
|
+
|
|
14
|
+
/** Response-level controller. Construction never starts a listener, timer or trust operation. */
|
|
15
|
+
export class UsageRelayController {
|
|
16
|
+
private readonly account: UsageIdentity;
|
|
17
|
+
private readonly key: string;
|
|
18
|
+
private readonly refresh = new UsageRefreshRegistry();
|
|
19
|
+
private readonly activation: UsageActivation;
|
|
20
|
+
private jsonSnapshots = 0;
|
|
21
|
+
private streamSnapshots = 0;
|
|
22
|
+
private lastSnapshotAt: number | null = null;
|
|
23
|
+
constructor(account: UsageIdentity, private readonly readCurrentIdentity: () => Promise<UsageIdentity | null>,
|
|
24
|
+
verifyFreshIdentity: () => Promise<UsageIdentity | null>, private readonly clock: () => number,
|
|
25
|
+
private readonly expiresAt: number, timeoutMs = 180000, private readonly contextValid: () => boolean | Promise<boolean> = () => true) {
|
|
26
|
+
if (!account.id || !account.userId || !['plus', 'pro'].includes(account.plan)
|
|
27
|
+
|| account.structure !== 'personal' || !Number.isFinite(expiresAt) || expiresAt <= clock()) {
|
|
28
|
+
throw new Error('A verified supported identity and finite safety deadline are required');
|
|
29
|
+
}
|
|
30
|
+
this.account = { ...account };
|
|
31
|
+
this.key = JSON.stringify([account.id, account.userId, account.plan, account.credentialGeneration ?? null]);
|
|
32
|
+
this.activation = new UsageActivation(this.key, key => this.refresh.refresh(key), async () => {
|
|
33
|
+
return same(await verifyFreshIdentity(), this.account) ? this.key : null;
|
|
34
|
+
}, clock, timeoutMs);
|
|
35
|
+
}
|
|
36
|
+
snapshot() { return { ...this.activation.snapshot(), trackedStreams: this.refresh.size, expired: this.clock() >= this.expiresAt,
|
|
37
|
+
observation: { jsonSnapshots: this.jsonSnapshots, streamSnapshots: this.streamSnapshots,
|
|
38
|
+
validatedActiveStreams: this.refresh.boundSize, lastSnapshotAt: this.lastSnapshotAt,
|
|
39
|
+
sourceProcessVerified: false as const, composerRecoveryVerified: false as const } }; }
|
|
40
|
+
private async checkContext() { try { return await this.contextValid(); } catch { return false; } }
|
|
41
|
+
async activate(options: { scope: ApplyScope; accountWideConsent: boolean }) {
|
|
42
|
+
const generation = this.activation.snapshot().generation, valid = await this.checkContext();
|
|
43
|
+
if (generation !== this.activation.snapshot().generation) return { accepted: false, reason: 'superseded', ...this.snapshot() };
|
|
44
|
+
if (!valid) { this.activation.invalidateIdentity(); return { accepted: false, reason: 'native-context-changed', ...this.snapshot() }; }
|
|
45
|
+
if (this.clock() >= this.expiresAt) { this.activation.invalidateIdentity(); return { accepted: false, reason: 'safety-deadline', ...this.snapshot() }; }
|
|
46
|
+
return this.activation.activate(options);
|
|
47
|
+
}
|
|
48
|
+
observeOnly() { return this.activation.observeOnly(); }
|
|
49
|
+
expireIfNeeded() { return this.activation.expireIfNeeded(); }
|
|
50
|
+
registerStream(exchange: Exchange, close: () => Promise<void>): Registration | null {
|
|
51
|
+
if (exchange.status !== 200) return null;
|
|
52
|
+
return this.refresh.register(exchange.method, exchange.pathname, close);
|
|
53
|
+
}
|
|
54
|
+
/** Only a complete original usage record may establish the stream's account binding. */
|
|
55
|
+
async rewriteJson(text: string, exchange: Exchange, stream?: Registration | null): Promise<string | null> {
|
|
56
|
+
if (exchange.method !== 'GET' || exchange.status !== 200
|
|
57
|
+
|| !['/backend-api/wham/usage', '/backend-api/wham/usage/stream'].includes(exchange.pathname)) return null;
|
|
58
|
+
if (this.clock() >= this.expiresAt) { this.activation.invalidateIdentity(); stream?.exclude(); return null; }
|
|
59
|
+
const observedGeneration = this.activation.snapshot().generation;
|
|
60
|
+
let current: UsageIdentity | null;
|
|
61
|
+
try { current = await this.readCurrentIdentity(); } catch { current = null; }
|
|
62
|
+
if (!same(current, this.account)) { this.activation.invalidateIdentity(); stream?.exclude(); return null; }
|
|
63
|
+
// Do not apply an activation that raced this response's identity read.
|
|
64
|
+
if (observedGeneration !== this.activation.snapshot().generation) return null;
|
|
65
|
+
if (this.clock() >= this.expiresAt) { this.activation.invalidateIdentity(); stream?.exclude(); return null; }
|
|
66
|
+
if (Buffer.byteLength(text, 'utf8') > 262144) { stream?.exclude(); return null; }
|
|
67
|
+
let parsed: unknown;
|
|
68
|
+
try { parsed = JSON.parse(text); } catch { stream?.exclude(); return null; }
|
|
69
|
+
const envelope = object(parsed) && 'usage' in parsed ? parsed : null;
|
|
70
|
+
if ((exchange.pathname.endsWith('/stream') && !envelope)
|
|
71
|
+
|| (envelope && (envelope.version !== 1 || typeof envelope.stream_id !== 'string' || !envelope.stream_id
|
|
72
|
+
|| !Number.isSafeInteger(envelope.sequence) || Number(envelope.sequence) <= 0))) { stream?.exclude(); return null; }
|
|
73
|
+
const original = envelope ? envelope.usage : parsed;
|
|
74
|
+
if (!object(original) || original.account_id !== this.account.id || original.user_id !== this.account.userId
|
|
75
|
+
|| original.plan_type !== this.account.plan) { stream?.exclude(); return null; }
|
|
76
|
+
const rate = original.rate_limit;
|
|
77
|
+
if (!object(rate) || typeof rate.allowed !== 'boolean' || typeof rate.limit_reached !== 'boolean') {
|
|
78
|
+
stream?.exclude(); return null;
|
|
79
|
+
}
|
|
80
|
+
if (stream && !stream.bind(this.key)) return null;
|
|
81
|
+
// Identity-checked responses received by this relay do not establish the
|
|
82
|
+
// sending process or prove that an authoritative UI cache consumed them.
|
|
83
|
+
if (exchange.pathname.endsWith('/stream')) this.streamSnapshots = Math.min(Number.MAX_SAFE_INTEGER, this.streamSnapshots + 1);
|
|
84
|
+
else this.jsonSnapshots = Math.min(Number.MAX_SAFE_INTEGER, this.jsonSnapshots + 1);
|
|
85
|
+
this.lastSnapshotAt = this.clock();
|
|
86
|
+
const context: UsageRewriteContext = { enabled: true, mode: 'observe', status: exchange.status,
|
|
87
|
+
pathname: exchange.pathname, account: this.account };
|
|
88
|
+
const observed = evaluateUsageRewrite(original, context);
|
|
89
|
+
this.activation.observe(this.key, observed.eligible ? 'exhausted'
|
|
90
|
+
: rate.allowed === true && rate.limit_reached === false ? 'available' : 'protected');
|
|
91
|
+
const state = this.activation.snapshot();
|
|
92
|
+
if (state.mode !== 'apply') return null;
|
|
93
|
+
const result = evaluateUsageRewrite(original, { ...context, mode: 'apply' });
|
|
94
|
+
if (!result.changed) return null;
|
|
95
|
+
// Observe and unchanged responses never spawn an installed-package probe.
|
|
96
|
+
const valid = await this.checkContext();
|
|
97
|
+
if (state.generation !== this.activation.snapshot().generation) return null;
|
|
98
|
+
if (!valid) { this.activation.invalidateIdentity(); stream?.exclude(); return null; }
|
|
99
|
+
// A native account or trial can change while the asynchronous build check runs.
|
|
100
|
+
try { current = await this.readCurrentIdentity(); } catch { current = null; }
|
|
101
|
+
if (state.generation !== this.activation.snapshot().generation) return null;
|
|
102
|
+
if (!same(current, this.account)) { this.activation.invalidateIdentity(); stream?.exclude(); return null; }
|
|
103
|
+
if (this.clock() >= this.expiresAt) { this.activation.invalidateIdentity(); stream?.exclude(); return null; }
|
|
104
|
+
if (!this.activation.recordOutput(this.key, state.generation)) return null;
|
|
105
|
+
return JSON.stringify(envelope ? { ...envelope, usage: result.value } : result.value);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Pure response policy. Real quota windows, credits and spending restrictions stay unchanged. */
|
|
2
|
+
export interface UsageRewriteContext {
|
|
3
|
+
enabled: boolean;
|
|
4
|
+
mode: 'observe' | 'apply';
|
|
5
|
+
status: number;
|
|
6
|
+
pathname: string;
|
|
7
|
+
account: {
|
|
8
|
+
id: string;
|
|
9
|
+
userId: string;
|
|
10
|
+
plan: 'plus' | 'pro';
|
|
11
|
+
structure: 'personal';
|
|
12
|
+
} | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type RecordValue = Record<string, unknown>;
|
|
16
|
+
function record(value: unknown): value is RecordValue {
|
|
17
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Transform only a complete, matched personal-account usage snapshot.
|
|
22
|
+
* Account context must come from a trusted live integration; this module does not
|
|
23
|
+
* discover it or prove provider isolation. Never persist the adjusted snapshot as
|
|
24
|
+
* actual provider/account capacity. Unknown or protected states pass unchanged.
|
|
25
|
+
*/
|
|
26
|
+
export function evaluateUsageRewrite(value: unknown, context: UsageRewriteContext) {
|
|
27
|
+
const unchanged = (reason: string) => ({ value, changed: false, eligible: false, reason });
|
|
28
|
+
if (!context.enabled) return unchanged('off');
|
|
29
|
+
if (context.status !== 200) return unchanged('non-success-response');
|
|
30
|
+
if (!['/backend-api/wham/usage', '/backend-api/wham/usage/stream'].includes(context.pathname)) return unchanged('unowned-path');
|
|
31
|
+
const account = context.account;
|
|
32
|
+
if (!account || !account.id || !account.userId || account.structure !== 'personal' || !['plus', 'pro'].includes(account.plan)) return unchanged('unsupported-account');
|
|
33
|
+
if (!record(value) || value.account_id !== account.id || value.user_id !== account.userId || value.plan_type !== account.plan) return unchanged('identity-mismatch');
|
|
34
|
+
const rate = value.rate_limit;
|
|
35
|
+
if (!record(rate) || rate.allowed !== false || rate.limit_reached !== true) return unchanged('not-explicitly-exhausted');
|
|
36
|
+
const spend = value.spend_control;
|
|
37
|
+
// Absence is unknown, not evidence that no protected spending restriction exists.
|
|
38
|
+
if (!record(spend) || spend.reached !== false) return unchanged('protected-or-unknown-spend');
|
|
39
|
+
const reason = value.rate_limit_reached_type;
|
|
40
|
+
if (reason != null && (!record(reason) || reason.type !== 'rate_limit_reached')) return unchanged('protected-or-unknown-reason');
|
|
41
|
+
const credits = value.credits;
|
|
42
|
+
if (!record(credits) || typeof credits.has_credits !== 'boolean' || typeof credits.unlimited !== 'boolean') return unchanged('unknown-credit-schema');
|
|
43
|
+
if (credits.overage_limit_reached != null && credits.overage_limit_reached !== false) return unchanged('overage-restriction');
|
|
44
|
+
// These entitlements already keep the native gate open. Rewriting the ordinary
|
|
45
|
+
// rate limit would unnecessarily change the app's Reserve activation predicate.
|
|
46
|
+
if (credits.has_credits || credits.unlimited) return unchanged('credits-available');
|
|
47
|
+
const upsell = value.rate_limit_upsell, additional = value.additional_rate_limits;
|
|
48
|
+
if (upsell != null && !record(upsell)) return unchanged('unknown-reserve-schema');
|
|
49
|
+
if (additional != null && (!Array.isArray(additional) || additional.some(limit => !record(limit)
|
|
50
|
+
|| limit.limit_name === 'gpt-reserve' && (!record(limit.rate_limit) || typeof limit.rate_limit.allowed !== 'boolean')))) return unchanged('unknown-reserve-schema');
|
|
51
|
+
if (record(upsell) && upsell.banner_type === 'luna_reserve' && Array.isArray(additional)
|
|
52
|
+
&& additional.some(limit => record(limit) && limit.limit_name === 'gpt-reserve'
|
|
53
|
+
&& record(limit.rate_limit) && limit.rate_limit.allowed === true)) return unchanged('reserve-active');
|
|
54
|
+
if (context.mode !== 'apply') return { value, changed: false, eligible: true, reason: 'observe-only' };
|
|
55
|
+
return {
|
|
56
|
+
value: { ...value, rate_limit: { ...rate, allowed: true, limit_reached: false } },
|
|
57
|
+
changed: true, eligible: true, reason: 'personal-usage-only',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Exact usage-stream refresh registry. No timers, sockets or core-path imports. */
|
|
2
|
+
export class UsageRefreshRegistry {
|
|
3
|
+
private entries = new Set<{ binding: string | null; tainted: boolean; close: () => Promise<void> }>();
|
|
4
|
+
register(method: string, pathname: string, close: () => Promise<void>) {
|
|
5
|
+
if (method !== 'GET' || pathname !== '/backend-api/wham/usage/stream') return null;
|
|
6
|
+
const entry = { binding: null as string | null, tainted: false, close };
|
|
7
|
+
this.entries.add(entry);
|
|
8
|
+
return {
|
|
9
|
+
// Caller must invoke only after validating a complete upstream snapshot's identity.
|
|
10
|
+
// A connection that changes account identity never becomes a refresh target again.
|
|
11
|
+
bind: (verifiedBinding: string) => {
|
|
12
|
+
if (!verifiedBinding || entry.tainted || !this.entries.has(entry)) return false;
|
|
13
|
+
if (entry.binding !== null && entry.binding !== verifiedBinding) {
|
|
14
|
+
entry.tainted = true; entry.binding = null; return false;
|
|
15
|
+
}
|
|
16
|
+
entry.binding = verifiedBinding; return true;
|
|
17
|
+
},
|
|
18
|
+
exclude: () => { entry.tainted = true; entry.binding = null; },
|
|
19
|
+
release: () => { this.entries.delete(entry); },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
async refresh(binding: string) {
|
|
23
|
+
if (!binding) throw new Error('Verified account binding is required');
|
|
24
|
+
const selected = [...this.entries].filter(e => !e.tainted && e.binding === binding);
|
|
25
|
+
// Detach before awaiting; concurrent refreshes cannot close the same stream twice.
|
|
26
|
+
for (const entry of selected) this.entries.delete(entry);
|
|
27
|
+
const results = await Promise.allSettled(selected.map(entry => Promise.resolve().then(entry.close)));
|
|
28
|
+
return { requested: selected.length, closed: results.filter(r => r.status === 'fulfilled').length,
|
|
29
|
+
failed: results.filter(r => r.status === 'rejected').length };
|
|
30
|
+
}
|
|
31
|
+
get size() { return this.entries.size; }
|
|
32
|
+
get boundSize() { return [...this.entries].filter(entry => !entry.tainted && entry.binding !== null).length; }
|
|
33
|
+
}
|