@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,193 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, randomUUID, X509Certificate } from "node:crypto";
|
|
2
|
+
import { closeSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import { createCertificateAuthority, isServerAuthOnlyCertificate, type LocalInterceptCa } from "../../claude/intercept/local-ca";
|
|
5
|
+
import { withClientLifecycle } from "../../client/lifecycle-lock";
|
|
6
|
+
import { hardenSecretDirAsync, hardenSecretPathAsync } from "../../lib/windows-secret-acl";
|
|
7
|
+
import { windowsAuthorityKeyProtection, type AuthorityKeyProtection } from "./windows-key-protection";
|
|
8
|
+
|
|
9
|
+
const POLICY = "codex-desktop-chatgpt-only/v1";
|
|
10
|
+
const FILE = "authority.json";
|
|
11
|
+
const MAX_FILE = 131_072;
|
|
12
|
+
const VALIDITY_DAYS = 30;
|
|
13
|
+
const DAY = 86_400_000;
|
|
14
|
+
const digest = (value: string) => createHash("sha256").update(value).digest("hex");
|
|
15
|
+
|
|
16
|
+
export class DesktopAuthorityError extends Error {
|
|
17
|
+
constructor(readonly code: "unsafe_path" | "unreadable" | "expired" | "renewal_required" | "protection_failed" | "fingerprint_changed") {
|
|
18
|
+
super(`desktop_compatibility_authority_${code}`); this.name = "DesktopAuthorityError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DesktopAuthorityStoreOptions {
|
|
23
|
+
/** Explicit feature-owned directory; never an OS trust store or existing Claude CA. */
|
|
24
|
+
directory: string;
|
|
25
|
+
now?: () => number;
|
|
26
|
+
/** Test seam; production always uses Windows CurrentUser DPAPI. */
|
|
27
|
+
protection?: AuthorityKeyProtection;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StoredDesktopAuthority {
|
|
31
|
+
authority: LocalInterceptCa;
|
|
32
|
+
commonName: string;
|
|
33
|
+
fingerprint: string;
|
|
34
|
+
expiresAt: number;
|
|
35
|
+
renewalDue: boolean;
|
|
36
|
+
reused: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type DesktopAuthorityInspection =
|
|
40
|
+
| { status: "missing" | "invalid" }
|
|
41
|
+
| { status: "present" | "expired" | "renewal-required"; certPem: string; fingerprint: string; expiresAt: number; renewalDue: boolean };
|
|
42
|
+
|
|
43
|
+
/** Read-only public metadata for status: no key decryption, ACL write, lock or generation. */
|
|
44
|
+
export function inspectDesktopCompatibilityAuthority(directory: string, now = Date.now()): DesktopAuthorityInspection {
|
|
45
|
+
if (!isAbsolute(directory) || !Number.isFinite(now)) return { status: "invalid" };
|
|
46
|
+
const path = join(directory, FILE);
|
|
47
|
+
try {
|
|
48
|
+
if (!pathPresent(directory)) return { status: "missing" };
|
|
49
|
+
assertPath(directory, true);
|
|
50
|
+
if (!pathPresent(path)) return { status: "missing" };
|
|
51
|
+
assertPath(path, false);
|
|
52
|
+
const saved = JSON.parse(readFileSync(path, "utf8"));
|
|
53
|
+
if (saved.version !== 1 || saved.protection !== "windows-current-user-dpapi" || typeof saved.certPem !== "string"
|
|
54
|
+
|| typeof saved.sealed !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(saved.sealed) || saved.sealed.length > MAX_FILE) return { status: "invalid" };
|
|
55
|
+
const certificate = new X509Certificate(saved.certPem), expiresAt = Date.parse(certificate.validTo);
|
|
56
|
+
if (!certificate.ca || !Number.isFinite(expiresAt)) return { status: "invalid" };
|
|
57
|
+
return { status: expiresAt <= now ? "expired" : isServerAuthOnlyCertificate(certificate) ? "present" : "renewal-required", certPem: saved.certPem,
|
|
58
|
+
fingerprint: certificate.fingerprint256.replaceAll(":", ""), expiresAt, renewalDue: expiresAt - now <= 7 * DAY };
|
|
59
|
+
} catch { return { status: "invalid" }; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function assertPath(path: string, directory: boolean): void {
|
|
63
|
+
const stat = lstatSync(path);
|
|
64
|
+
if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1 || stat.size > MAX_FILE)) {
|
|
65
|
+
throw new DesktopAuthorityError("unsafe_path");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function pathPresent(path: string): boolean {
|
|
70
|
+
try { lstatSync(path); return true; }
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return false;
|
|
73
|
+
throw new DesktopAuthorityError("unsafe_path");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function validatedAuthority(certPem: string, keyPem: string, commonName: string, now: number, reused: boolean, allowExpired = false): StoredDesktopAuthority {
|
|
78
|
+
try {
|
|
79
|
+
const certificate = new X509Certificate(certPem);
|
|
80
|
+
const privateKey = createPrivateKey(keyPem), publicKey = createPublicKey(keyPem);
|
|
81
|
+
if (!certificate.ca || !certificate.checkPrivateKey(privateKey) || !certificate.verify(publicKey)
|
|
82
|
+
|| certificate.subject !== certificate.issuer
|
|
83
|
+
|| !commonName.startsWith("OpenCodex Codex Desktop ") || !certificate.subject.split("\n").includes(`CN=${commonName}`)) {
|
|
84
|
+
throw new Error("invalid");
|
|
85
|
+
}
|
|
86
|
+
const expiresAt = Date.parse(certificate.validTo), startsAt = Date.parse(certificate.validFrom);
|
|
87
|
+
if (!Number.isFinite(expiresAt) || !Number.isFinite(startsAt) || startsAt > now
|
|
88
|
+
|| expiresAt - startsAt > (VALIDITY_DAYS + 1) * DAY) throw new Error("invalid");
|
|
89
|
+
if (expiresAt <= now && !allowExpired) throw new DesktopAuthorityError("expired");
|
|
90
|
+
if (!allowExpired && !isServerAuthOnlyCertificate(certificate)) throw new DesktopAuthorityError("renewal_required");
|
|
91
|
+
return { authority: { certPem, keyPem, privateKey, publicKey }, commonName,
|
|
92
|
+
fingerprint: certificate.fingerprint256.replaceAll(":", ""), expiresAt,
|
|
93
|
+
renewalDue: expiresAt - now <= 7 * DAY, reused };
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error instanceof DesktopAuthorityError) throw error;
|
|
96
|
+
throw new DesktopAuthorityError("unreadable");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function readAuthority(path: string, protection: AuthorityKeyProtection, now: number, allowExpired = false): Promise<StoredDesktopAuthority> {
|
|
101
|
+
assertPath(path, false);
|
|
102
|
+
await hardenSecretPathAsync(path, { required: true });
|
|
103
|
+
assertPath(path, false);
|
|
104
|
+
let cleartext: Uint8Array | undefined;
|
|
105
|
+
try {
|
|
106
|
+
const saved = JSON.parse(readFileSync(path, "utf8"));
|
|
107
|
+
if (saved.version !== 1 || saved.protection !== "windows-current-user-dpapi"
|
|
108
|
+
|| typeof saved.certPem !== "string" || typeof saved.sealed !== "string"
|
|
109
|
+
|| !/^[A-Za-z0-9+/]+={0,2}$/.test(saved.sealed) || saved.sealed.length > MAX_FILE) throw new Error("invalid");
|
|
110
|
+
cleartext = await protection.unprotect(Buffer.from(saved.sealed, "base64"));
|
|
111
|
+
const secret = JSON.parse(Buffer.from(cleartext).toString("utf8"));
|
|
112
|
+
if (secret.policy !== POLICY || secret.certSha256 !== digest(saved.certPem)
|
|
113
|
+
|| typeof secret.keyPem !== "string" || typeof secret.commonName !== "string") throw new Error("invalid");
|
|
114
|
+
return validatedAuthority(saved.certPem, secret.keyPem, secret.commonName, now, true, allowExpired);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error instanceof DesktopAuthorityError) throw error;
|
|
117
|
+
// A corrupt or foreign-user key never silently regenerates a new root or trust prompt.
|
|
118
|
+
throw new DesktopAuthorityError("unreadable");
|
|
119
|
+
} finally { cleartext?.fill(0); }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Loads only existing state. Expired or legacy-purpose keys may be loaded solely for trust removal/renewal. */
|
|
123
|
+
export async function loadDesktopCompatibilityAuthority(options: DesktopAuthorityStoreOptions, allowExpiredForRemoval = false): Promise<StoredDesktopAuthority> {
|
|
124
|
+
if (!isAbsolute(options.directory)) throw new DesktopAuthorityError("unsafe_path");
|
|
125
|
+
const now = options.now?.() ?? Date.now();
|
|
126
|
+
if (!Number.isFinite(now)) throw new DesktopAuthorityError("unreadable");
|
|
127
|
+
assertPath(options.directory, true);
|
|
128
|
+
const path = join(options.directory, FILE);
|
|
129
|
+
if (!pathPresent(path)) throw new DesktopAuthorityError("unreadable");
|
|
130
|
+
return readAuthority(path, options.protection ?? windowsAuthorityKeyProtection, now, allowExpiredForRemoval);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Reuses one user-protected root across restarts; never installs, rotates or removes trust. */
|
|
134
|
+
export async function ensureDesktopCompatibilityAuthority(options: DesktopAuthorityStoreOptions): Promise<StoredDesktopAuthority> {
|
|
135
|
+
return publishAuthority(options);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Deliberate replacement after the caller has removed OS trust and stopped consumers. */
|
|
139
|
+
export async function renewDesktopCompatibilityAuthority(options: DesktopAuthorityStoreOptions, expectedFingerprint: string): Promise<StoredDesktopAuthority> {
|
|
140
|
+
if (!/^[A-F0-9]{64}$/.test(expectedFingerprint)) throw new DesktopAuthorityError("fingerprint_changed");
|
|
141
|
+
return publishAuthority(options, expectedFingerprint);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function publishAuthority(options: DesktopAuthorityStoreOptions, expectedFingerprint?: string): Promise<StoredDesktopAuthority> {
|
|
145
|
+
if (!isAbsolute(options.directory)) throw new DesktopAuthorityError("unsafe_path");
|
|
146
|
+
if (!options.protection && process.platform !== "win32") throw new Error("desktop_compatibility_windows_required");
|
|
147
|
+
const now = options.now?.() ?? Date.now();
|
|
148
|
+
if (!Number.isFinite(now)) throw new DesktopAuthorityError("unreadable");
|
|
149
|
+
const protection = options.protection ?? windowsAuthorityKeyProtection;
|
|
150
|
+
mkdirSync(options.directory, { recursive: true, mode: 0o700 });
|
|
151
|
+
assertPath(options.directory, true);
|
|
152
|
+
await hardenSecretDirAsync(options.directory, { required: true });
|
|
153
|
+
return withClientLifecycle(async () => {
|
|
154
|
+
assertPath(options.directory, true);
|
|
155
|
+
const path = join(options.directory, FILE);
|
|
156
|
+
let original: string | undefined;
|
|
157
|
+
if (expectedFingerprint !== undefined) {
|
|
158
|
+
if (!pathPresent(path)) throw new DesktopAuthorityError("fingerprint_changed");
|
|
159
|
+
const previous = await readAuthority(path, protection, now, true);
|
|
160
|
+
if (previous.fingerprint !== expectedFingerprint) throw new DesktopAuthorityError("fingerprint_changed");
|
|
161
|
+
original = readFileSync(path, "utf8");
|
|
162
|
+
} else if (pathPresent(path)) return readAuthority(path, protection, now);
|
|
163
|
+
const commonName = `OpenCodex Codex Desktop ${randomUUID()}`;
|
|
164
|
+
const authority = createCertificateAuthority({ commonName, validityDays: VALIDITY_DAYS,
|
|
165
|
+
permittedDnsNames: ["chatgpt.com"], excludeAllIpAddresses: true, serverAuthOnly: true });
|
|
166
|
+
const cleartext = Buffer.from(JSON.stringify({ policy: POLICY, certSha256: digest(authority.certPem), commonName, keyPem: authority.keyPem }));
|
|
167
|
+
let sealed: Uint8Array;
|
|
168
|
+
try { sealed = await protection.protect(cleartext); }
|
|
169
|
+
catch { throw new DesktopAuthorityError("protection_failed"); }
|
|
170
|
+
finally { cleartext.fill(0); }
|
|
171
|
+
if (sealed.byteLength === 0 || sealed.byteLength > 65_536) throw new DesktopAuthorityError("protection_failed");
|
|
172
|
+
const contents = JSON.stringify({ version: 1, protection: "windows-current-user-dpapi", certPem: authority.certPem,
|
|
173
|
+
sealed: Buffer.from(sealed).toString("base64") });
|
|
174
|
+
const temporary = join(options.directory, `authority-${randomUUID()}.tmp`);
|
|
175
|
+
let created = false;
|
|
176
|
+
try {
|
|
177
|
+
const fd = openSync(temporary, "wx", 0o600); created = true;
|
|
178
|
+
try { writeFileSync(fd, contents); fsyncSync(fd); } finally { closeSync(fd); }
|
|
179
|
+
await hardenSecretPathAsync(temporary, { required: true });
|
|
180
|
+
assertPath(temporary, false); assertPath(options.directory, true);
|
|
181
|
+
// Verify the new encrypted envelope before replacing the only recovery source.
|
|
182
|
+
await readAuthority(temporary, protection, now);
|
|
183
|
+
if (original !== undefined) {
|
|
184
|
+
assertPath(path, false);
|
|
185
|
+
if (readFileSync(path, "utf8") !== original) throw new DesktopAuthorityError("fingerprint_changed");
|
|
186
|
+
} else if (pathPresent(path)) throw new DesktopAuthorityError("unsafe_path");
|
|
187
|
+
renameSync(temporary, path); created = false;
|
|
188
|
+
// Read back the actual persisted pair before reporting a successful setup.
|
|
189
|
+
const persisted = await readAuthority(path, protection, now);
|
|
190
|
+
return { ...persisted, reused: false };
|
|
191
|
+
} finally { if (created) unlinkSync(temporary); }
|
|
192
|
+
}, { lockPath: join(options.directory, "authority-publication.sqlite") });
|
|
193
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import { withClientLifecycle } from "../../client/lifecycle-lock";
|
|
5
|
+
import { hardenSecretDirAsync, hardenSecretPathAsync } from "../../lib/windows-secret-acl";
|
|
6
|
+
|
|
7
|
+
export interface DesktopConnectionIdentity {
|
|
8
|
+
version: 1;
|
|
9
|
+
id: string;
|
|
10
|
+
connectPort: number;
|
|
11
|
+
pacPort: number;
|
|
12
|
+
}
|
|
13
|
+
export interface DesktopConnectionStore {
|
|
14
|
+
read(): DesktopConnectionIdentity | null;
|
|
15
|
+
publish(value: DesktopConnectionIdentity): Promise<DesktopConnectionIdentity>;
|
|
16
|
+
}
|
|
17
|
+
const FILE = "connection.json";
|
|
18
|
+
const fail = (reason: "invalid" | "changed" | "cleanup_required", cause?: unknown) => new Error(`desktop_compatibility_connection_${reason}`, { cause });
|
|
19
|
+
const port = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 1024 && Number(value) <= 65535;
|
|
20
|
+
function validate(value: unknown): DesktopConnectionIdentity {
|
|
21
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw fail("invalid");
|
|
22
|
+
const row = value as Record<string, unknown>;
|
|
23
|
+
if (Object.keys(row).length !== 4 || row.version !== 1 || typeof row.id !== "string"
|
|
24
|
+
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(row.id)
|
|
25
|
+
|| !port(row.connectPort) || !port(row.pacPort) || row.connectPort === row.pacPort) throw fail("invalid");
|
|
26
|
+
return { version: 1, id: row.id, connectPort: row.connectPort, pacPort: row.pacPort };
|
|
27
|
+
}
|
|
28
|
+
function assertPath(path: string, directory: boolean): void {
|
|
29
|
+
const stat = lstatSync(path);
|
|
30
|
+
if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1 || stat.size > 4096)) throw fail("invalid");
|
|
31
|
+
}
|
|
32
|
+
function present(path: string): boolean {
|
|
33
|
+
try { lstatSync(path); return true; }
|
|
34
|
+
catch (error) { if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return false; throw fail("invalid"); }
|
|
35
|
+
}
|
|
36
|
+
function same(left: DesktopConnectionIdentity, right: DesktopConnectionIdentity): boolean {
|
|
37
|
+
return left.version === right.version && left.id === right.id && left.connectPort === right.connectPort && left.pacPort === right.pacPort;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Public endpoint identity only. No token, account, private key or expiry is persisted here. */
|
|
41
|
+
export function createDesktopConnectionStore(directory: string): DesktopConnectionStore {
|
|
42
|
+
if (!isAbsolute(directory)) throw fail("invalid");
|
|
43
|
+
const path = join(directory, FILE);
|
|
44
|
+
const read = (): DesktopConnectionIdentity | null => {
|
|
45
|
+
try {
|
|
46
|
+
if (!present(directory)) return null;
|
|
47
|
+
assertPath(directory, true);
|
|
48
|
+
if (!present(path)) return null;
|
|
49
|
+
assertPath(path, false);
|
|
50
|
+
return validate(JSON.parse(readFileSync(path, "utf8")));
|
|
51
|
+
} catch { throw fail("invalid"); }
|
|
52
|
+
};
|
|
53
|
+
const verifyExisting = (value: DesktopConnectionIdentity): DesktopConnectionIdentity | null => {
|
|
54
|
+
const existing = read();
|
|
55
|
+
if (existing && !same(existing, value)) throw fail("changed");
|
|
56
|
+
return existing;
|
|
57
|
+
};
|
|
58
|
+
return { read, async publish(input) {
|
|
59
|
+
const value = validate(input);
|
|
60
|
+
const existing = verifyExisting(value); if (existing) return existing;
|
|
61
|
+
// A trusted authority must already own this directory. Never create a replacement home.
|
|
62
|
+
assertPath(directory, true); await hardenSecretDirAsync(directory, { required: true });
|
|
63
|
+
return withClientLifecycle(async () => {
|
|
64
|
+
const raced = verifyExisting(value); if (raced) return raced;
|
|
65
|
+
const temporary = join(directory, `connection-${randomUUID()}.tmp`);
|
|
66
|
+
let created = false;
|
|
67
|
+
try {
|
|
68
|
+
const fd = openSync(temporary, "wx", 0o600); created = true;
|
|
69
|
+
try { writeFileSync(fd, JSON.stringify(value)); fsyncSync(fd); } finally { closeSync(fd); }
|
|
70
|
+
await hardenSecretPathAsync(temporary, { required: true });
|
|
71
|
+
assertPath(directory, true); assertPath(temporary, false);
|
|
72
|
+
// Create-only publication cannot overwrite another process's endpoint identity.
|
|
73
|
+
try { linkSync(temporary, path); }
|
|
74
|
+
catch (error) {
|
|
75
|
+
const winner = verifyExisting(value); if (!winner) throw error;
|
|
76
|
+
}
|
|
77
|
+
unlinkSync(temporary); created = false;
|
|
78
|
+
const published = verifyExisting(value); if (!published) throw fail("changed");
|
|
79
|
+
return published;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
try { if (created && present(temporary)) unlinkSync(temporary); }
|
|
82
|
+
catch (cleanupError) { throw fail("cleanup_required", new AggregateError([error, cleanupError], "Endpoint publication and cleanup failed")); }
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}, { lockPath: join(directory, "connection-publication.sqlite") });
|
|
86
|
+
} };
|
|
87
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { win32 } from "node:path";
|
|
3
|
+
import { isTestHomeGuardArmed } from "../../lib/test-home-guard";
|
|
4
|
+
import { resolveTrustedWindowsPowerShellExe } from "../../lib/windows-elevation";
|
|
5
|
+
import { parseWindowsDesktopPackage, WINDOWS_PACKAGE_DISCOVERY_SCRIPT } from "../desktop-app/windows";
|
|
6
|
+
import type { DesktopAppInstall } from "../desktop-app/types";
|
|
7
|
+
|
|
8
|
+
export const DESKTOP_COMPATIBILITY_ASSESSED_VERSION = "26.924.2738.0";
|
|
9
|
+
export const DESKTOP_COMPATIBILITY_ASSESSED_FAMILY = "OpenAI.Codex_2p2nqsd0c76g0";
|
|
10
|
+
|
|
11
|
+
function matchesAssessedPackage(install: DesktopAppInstall): boolean {
|
|
12
|
+
const name = win32.basename(install.root);
|
|
13
|
+
const parts = /^OpenAI\.Codex_(\d+\.\d+\.\d+\.\d+)_(x64|arm64|x86|neutral)_([A-Za-z0-9.-]*)_([A-Za-z0-9]+)$/.exec(name);
|
|
14
|
+
return install.id === DESKTOP_COMPATIBILITY_ASSESSED_FAMILY && install.relaunch === `${install.id}!App`
|
|
15
|
+
&& !!parts && parts[1] === DESKTOP_COMPATIBILITY_ASSESSED_VERSION && `OpenAI.Codex_${parts[4]}` === install.id;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Resolve only after the exact asynchronous child has closed, including abort/timeout. */
|
|
19
|
+
function queryInstalledPackage(signal: AbortSignal): Promise<DesktopAppInstall | null> {
|
|
20
|
+
if (process.platform !== "win32" || isTestHomeGuardArmed() || signal.aborted) return Promise.resolve(null);
|
|
21
|
+
return new Promise(resolve => {
|
|
22
|
+
let completed = false, closed = false, result: DesktopAppInstall | null = null;
|
|
23
|
+
const finish = () => { if (completed && closed) { signal.removeEventListener("abort", abort); resolve(result); } };
|
|
24
|
+
const child = execFile(resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_PACKAGE_DISCOVERY_SCRIPT], {
|
|
25
|
+
timeout: 10000, maxBuffer: 65536, encoding: "utf8", windowsHide: true,
|
|
26
|
+
}, (error, stdout) => { result = error ? null : parseWindowsDesktopPackage(stdout); completed = true; finish(); });
|
|
27
|
+
const abort = () => { try { child.kill(); } catch { /* Keep ownership until execFile reports close. */ } };
|
|
28
|
+
child.once("close", () => { closed = true; finish(); });
|
|
29
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
30
|
+
if (signal.aborted) abort();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Coalesce concurrent fresh probes; never cache a positive verdict across requests. */
|
|
35
|
+
export function createInstalledBuildProbe(query: (signal: AbortSignal) => Promise<DesktopAppInstall | null> = queryInstalledPackage) {
|
|
36
|
+
let pending: Promise<boolean> | null = null, controller: AbortController | null = null, closed = false;
|
|
37
|
+
return {
|
|
38
|
+
check(): Promise<boolean> {
|
|
39
|
+
if (closed) return Promise.resolve(false);
|
|
40
|
+
if (pending) return pending;
|
|
41
|
+
const current = new AbortController(); controller = current;
|
|
42
|
+
pending = Promise.resolve().then(() => current.signal.aborted ? null : query(current.signal))
|
|
43
|
+
.then(install => !closed && !current.signal.aborted && !!install
|
|
44
|
+
&& matchesAssessedPackage(install))
|
|
45
|
+
.catch(() => false).finally(() => { pending = null; controller = null; });
|
|
46
|
+
return pending;
|
|
47
|
+
},
|
|
48
|
+
async close(): Promise<void> { closed = true; controller?.abort(); await pending; },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function isAssessedDesktopInstalled(): Promise<boolean> {
|
|
53
|
+
const probe = createInstalledBuildProbe();
|
|
54
|
+
try { return await probe.check(); } finally { await probe.close(); }
|
|
55
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Buffer small JSON only; large bodies pass through without collecting the remainder. */
|
|
2
|
+
export async function boundedJsonBody(body: ReadableStream<Uint8Array> | null, maxBytes = 262144): Promise<{ bytes: Buffer } | { stream: ReadableStream<Uint8Array> }> {
|
|
3
|
+
if (!body) return { bytes: Buffer.alloc(0) };
|
|
4
|
+
const reader = body.getReader();
|
|
5
|
+
const chunks: Uint8Array[] = [];
|
|
6
|
+
let count = 0;
|
|
7
|
+
try {
|
|
8
|
+
while (true) {
|
|
9
|
+
const next = await reader.read();
|
|
10
|
+
if (next.done) { reader.releaseLock(); return { bytes: Buffer.concat(chunks, count) }; }
|
|
11
|
+
chunks.push(next.value); count += next.value.byteLength;
|
|
12
|
+
if (count <= maxBytes && chunks.length < 1024) continue;
|
|
13
|
+
let index = 0;
|
|
14
|
+
return { stream: new ReadableStream<Uint8Array>({
|
|
15
|
+
async pull(controller) {
|
|
16
|
+
if (index < chunks.length) {
|
|
17
|
+
const value = chunks[index]!; chunks[index++] = new Uint8Array(0); controller.enqueue(value); return;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const part = await reader.read();
|
|
21
|
+
if (part.done) { reader.releaseLock(); controller.close(); }
|
|
22
|
+
else controller.enqueue(part.value);
|
|
23
|
+
} catch (error) { reader.releaseLock(); controller.error(error); }
|
|
24
|
+
},
|
|
25
|
+
async cancel(reason) { try { await reader.cancel(reason); } finally { reader.releaseLock(); } },
|
|
26
|
+
}) };
|
|
27
|
+
}
|
|
28
|
+
} catch (error) { reader.releaseLock(); throw error; }
|
|
29
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readFileSync, statSync, type BigIntStats } from 'node:fs';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import type { UsageIdentity } from './usage-controller';
|
|
4
|
+
|
|
5
|
+
type Credential = { identity: UsageIdentity; accessToken: string };
|
|
6
|
+
const equal = (a: UsageIdentity, b: UsageIdentity) => a.id === b.id && a.userId === b.userId && a.plan === b.plan
|
|
7
|
+
&& a.credentialGeneration === b.credentialGeneration;
|
|
8
|
+
|
|
9
|
+
/** Local native credentials remain in memory and are never returned by the public reader. */
|
|
10
|
+
export function createNativeIdentityReader(authPath: string, upstreamFetch: typeof fetch = fetch) {
|
|
11
|
+
const readerId = randomUUID(); let generation = 0, previousSnapshot: string | undefined;
|
|
12
|
+
const stamp = (value: BigIntStats) =>
|
|
13
|
+
`${value.dev}:${value.ino}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`;
|
|
14
|
+
const readCredential = (): Credential => {
|
|
15
|
+
const before = statSync(authPath, { bigint: true });
|
|
16
|
+
if (before.size > 1048576n) throw new Error('Unexpected native auth size');
|
|
17
|
+
const text = readFileSync(authPath, 'utf8'), after = statSync(authPath, { bigint: true });
|
|
18
|
+
if (stamp(before) !== stamp(after)) throw new Error('Native credentials changed during read');
|
|
19
|
+
const snapshot = `${stamp(after)}:${createHash('sha256').update(text).digest('hex')}`;
|
|
20
|
+
if (snapshot !== previousSnapshot) { previousSnapshot = snapshot; generation++; }
|
|
21
|
+
const auth = JSON.parse(text);
|
|
22
|
+
if (auth.auth_mode !== 'chatgpt' || typeof auth.tokens?.id_token !== 'string'
|
|
23
|
+
|| typeof auth.tokens?.access_token !== 'string' || !auth.tokens.access_token
|
|
24
|
+
|| typeof auth.tokens?.account_id !== 'string') throw new Error('Native login required');
|
|
25
|
+
// These local claims are only a binding hint. Activation additionally verifies upstream.
|
|
26
|
+
const claims = JSON.parse(Buffer.from(auth.tokens.id_token.split('.')[1], 'base64url').toString('utf8'))['https://api.openai.com/auth'];
|
|
27
|
+
if (!claims || claims.chatgpt_account_id !== auth.tokens.account_id
|
|
28
|
+
|| typeof claims.chatgpt_user_id !== 'string' || !claims.chatgpt_user_id
|
|
29
|
+
|| !['plus', 'pro'].includes(claims.chatgpt_plan_type)) throw new Error('Unsupported identity');
|
|
30
|
+
return { identity: { id: auth.tokens.account_id, userId: claims.chatgpt_user_id,
|
|
31
|
+
plan: claims.chatgpt_plan_type, structure: 'personal', credentialGeneration: `${readerId}:${generation}` }, accessToken: auth.tokens.access_token };
|
|
32
|
+
};
|
|
33
|
+
const readCurrentIdentity = async (): Promise<UsageIdentity | null> => {
|
|
34
|
+
try { return readCredential().identity; } catch { return null; }
|
|
35
|
+
};
|
|
36
|
+
const verifyFreshIdentity = async (): Promise<UsageIdentity | null> => {
|
|
37
|
+
try {
|
|
38
|
+
const before = readCredential();
|
|
39
|
+
const response = await upstreamFetch('https://chatgpt.com/backend-api/wham/usage', {
|
|
40
|
+
headers: { authorization: 'Bearer ' + before.accessToken, 'ChatGPT-Account-ID': before.identity.id },
|
|
41
|
+
redirect: 'manual', signal: AbortSignal.timeout(10000),
|
|
42
|
+
});
|
|
43
|
+
if (response.status !== 200 || !response.body) { await response.body?.cancel(); return null; }
|
|
44
|
+
const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let size = 0;
|
|
45
|
+
try {
|
|
46
|
+
for (;;) {
|
|
47
|
+
const part = await reader.read(); if (part.done) break;
|
|
48
|
+
size += part.value.length;
|
|
49
|
+
if (size > 65536) { await reader.cancel(); return null; }
|
|
50
|
+
chunks.push(part.value);
|
|
51
|
+
}
|
|
52
|
+
} finally { reader.releaseLock(); }
|
|
53
|
+
const usage = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
54
|
+
const after = readCredential();
|
|
55
|
+
if (!equal(before.identity, after.identity) || before.accessToken !== after.accessToken
|
|
56
|
+
|| usage.account_id !== before.identity.id || usage.user_id !== before.identity.userId
|
|
57
|
+
|| usage.plan_type !== before.identity.plan) return null;
|
|
58
|
+
return before.identity;
|
|
59
|
+
} catch { return null; }
|
|
60
|
+
};
|
|
61
|
+
return { readCurrentIdentity, verifyFreshIdentity };
|
|
62
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createServer } from "node:https";
|
|
2
|
+
import { Readable, type Duplex } from "node:stream";
|
|
3
|
+
import type { PemKeyPair } from "../../claude/intercept/local-ca";
|
|
4
|
+
import { forwardHeadersForUpstream } from "../../claude/intercept/listener";
|
|
5
|
+
import { dialDesktopUpstream, type DesktopTunnelOptions } from "../../lib/desktop-upstream-tunnel";
|
|
6
|
+
|
|
7
|
+
const ORIGIN = "https://chatgpt.com";
|
|
8
|
+
const STRIP = new Set(["connection", "keep-alive", "transfer-encoding", "content-encoding", "content-length", "alt-svc", "proxy-authenticate"]);
|
|
9
|
+
export interface DesktopRelayOptions {
|
|
10
|
+
leaf: PemKeyPair;
|
|
11
|
+
fetchImpl: typeof fetch;
|
|
12
|
+
/** Test-only TLS peer; the production destination is fixed, never taken from a request. */
|
|
13
|
+
websocketPeer?: { host: string; port: number; ca: string };
|
|
14
|
+
websocketTransport?: Pick<DesktopTunnelOptions, "proxy" | "proxyCa" | "timeoutMs">;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Transparent HTTP and raw upgraded-socket relay. Only fetchImpl owns usage policy. */
|
|
18
|
+
export async function startDesktopRelay(options: DesktopRelayOptions) {
|
|
19
|
+
const sockets = new Set<Duplex>(), requests = new Set<AbortController>();
|
|
20
|
+
const server = createServer({ cert: options.leaf.certPem, key: options.leaf.keyPem, ALPNProtocols: ["http/1.1"] });
|
|
21
|
+
server.on("connection", socket => { sockets.add(socket); socket.once("close", () => sockets.delete(socket)); });
|
|
22
|
+
const valid = (host: string | undefined, path: string | undefined) =>
|
|
23
|
+
(host?.toLowerCase() === "chatgpt.com" || host?.toLowerCase() === "chatgpt.com:443") && !!path && path.startsWith("/") && !path.startsWith("//");
|
|
24
|
+
server.on("request", (req, res) => {
|
|
25
|
+
if (!valid(req.headers.host, req.url)) { res.writeHead(421); res.end(); return; }
|
|
26
|
+
const abort = new AbortController(); requests.add(abort);
|
|
27
|
+
const finish = () => { requests.delete(abort); abort.abort(); };
|
|
28
|
+
res.once("close", finish); req.once("error", finish);
|
|
29
|
+
void (async () => {
|
|
30
|
+
const headers = new Headers();
|
|
31
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) headers.append(req.rawHeaders[i]!, req.rawHeaders[i + 1]!);
|
|
32
|
+
const method = req.method ?? "GET";
|
|
33
|
+
const response = await options.fetchImpl(ORIGIN + req.url, {
|
|
34
|
+
method, headers: forwardHeadersForUpstream(headers), redirect: "manual", signal: abort.signal,
|
|
35
|
+
// Node's and Bun's declarations disagree on BYOB overloads; both implement Web streams.
|
|
36
|
+
body: method === "GET" || method === "HEAD" ? undefined : Readable.toWeb(req) as unknown as ReadableStream<Uint8Array>,
|
|
37
|
+
// @ts-expect-error Node-compatible streaming fetch requires half duplex.
|
|
38
|
+
duplex: "half",
|
|
39
|
+
});
|
|
40
|
+
if (res.destroyed) { await response.body?.cancel(); return; }
|
|
41
|
+
const output: Record<string, string | string[]> = {};
|
|
42
|
+
response.headers.forEach((value, key) => { if (!STRIP.has(key) && key !== "set-cookie") output[key] = value; });
|
|
43
|
+
const cookies = response.headers.getSetCookie(); if (cookies.length) output["set-cookie"] = cookies;
|
|
44
|
+
res.writeHead(response.status, output);
|
|
45
|
+
if (!response.body || method === "HEAD") { await response.body?.cancel(); res.end(); return; }
|
|
46
|
+
const body = Readable.fromWeb(response.body as unknown as import("node:stream/web").ReadableStream<Uint8Array>);
|
|
47
|
+
body.once("error", () => res.destroy()); res.once("close", () => body.destroy()); body.pipe(res);
|
|
48
|
+
})().catch(() => {
|
|
49
|
+
if (res.headersSent) res.destroy(); else { res.writeHead(502, { "Content-Length": "0" }); res.end(); }
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
server.on("upgrade", (req, client, head) => {
|
|
53
|
+
if (!valid(req.headers.host, req.url)) { client.end("HTTP/1.1 421 Misdirected Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); return; }
|
|
54
|
+
const peer = options.websocketPeer;
|
|
55
|
+
const abort = new AbortController(); requests.add(abort);
|
|
56
|
+
client.once("close", () => { requests.delete(abort); abort.abort(); });
|
|
57
|
+
client.once("error", () => abort.abort());
|
|
58
|
+
void dialDesktopUpstream({ ...options.websocketTransport, ...(peer ? { target: { host: peer.host, port: peer.port }, ca: peer.ca } : {}), signal: abort.signal }).then(upstream => {
|
|
59
|
+
requests.delete(abort);
|
|
60
|
+
if (client.destroyed || abort.signal.aborted) { upstream.destroy(); return; }
|
|
61
|
+
sockets.add(upstream);
|
|
62
|
+
upstream.once("error", () => client.destroy());
|
|
63
|
+
upstream.once("close", () => { sockets.delete(upstream); client.destroy(); });
|
|
64
|
+
client.once("close", () => upstream.destroy());
|
|
65
|
+
const lines = [`${req.method ?? "GET"} ${req.url} HTTP/1.1`];
|
|
66
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
67
|
+
if (!/^proxy-(?:authorization|connection)$/i.test(req.rawHeaders[i]!)) lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);
|
|
68
|
+
}
|
|
69
|
+
upstream.write(lines.join("\r\n") + "\r\n\r\n");
|
|
70
|
+
if (head.length) upstream.write(head);
|
|
71
|
+
client.pipe(upstream).pipe(client);
|
|
72
|
+
}).catch(() => {
|
|
73
|
+
requests.delete(abort);
|
|
74
|
+
if (!client.destroyed) client.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
try {
|
|
78
|
+
await new Promise<void>((resolve, reject) => {
|
|
79
|
+
server.once("error", reject);
|
|
80
|
+
server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); });
|
|
81
|
+
});
|
|
82
|
+
} catch (error) { server.close(); throw error; }
|
|
83
|
+
const address = server.address();
|
|
84
|
+
if (!address || typeof address === "string") throw new Error("desktop_compatibility_listener_unavailable");
|
|
85
|
+
let closing: Promise<void> | null = null;
|
|
86
|
+
return { port: address.port, close() {
|
|
87
|
+
if (closing) return closing;
|
|
88
|
+
for (const abort of requests) abort.abort();
|
|
89
|
+
for (const socket of sockets) socket.destroy();
|
|
90
|
+
closing = new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
|
|
91
|
+
server.closeAllConnections(); return closing;
|
|
92
|
+
} };
|
|
93
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { OcxConfig } from "../../types";
|
|
2
|
+
|
|
3
|
+
export interface NativeCompatibilityOwner { hostname: string; port: number; loopbackPort?: number; config: OcxConfig }
|
|
4
|
+
let owner: NativeCompatibilityOwner | null = null;
|
|
5
|
+
/** Bound sockets, not persisted desired ports, identify this process's native routing target. */
|
|
6
|
+
export function bindNativeCompatibilityOwner(value: NativeCompatibilityOwner): () => void {
|
|
7
|
+
owner = value;
|
|
8
|
+
return () => { if (owner === value) owner = null; };
|
|
9
|
+
}
|
|
10
|
+
export function nativeCompatibilityOwner(): NativeCompatibilityOwner | null { return owner; }
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { OcxConfig } from "../../types";
|
|
5
|
+
import { nativeCompatibilityOwner, type NativeCompatibilityOwner } from "./routing-binding";
|
|
6
|
+
|
|
7
|
+
const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
8
|
+
// These are routing inputs, not proof of a conversation's final provider. Their
|
|
9
|
+
// changes revoke the observed context even if the loopback URL stays the same.
|
|
10
|
+
const ROUTING_KEYS = ["providers", "defaultProvider", "defaultModelAliases", "customModels", "combos", "routingProfiles",
|
|
11
|
+
"subagentModelFallback", "subagentModelFallbackByModel", "injectionModel", "compactionRouting", "compactionRecovery",
|
|
12
|
+
"blockedModelRedirects", "shadowCallIntercept", "protocols", "memoryModels"] as const satisfies readonly (keyof OcxConfig)[];
|
|
13
|
+
function routingDigest(text: string, owner: NativeCompatibilityOwner): string {
|
|
14
|
+
const native = Bun.TOML.parse(text) as Record<string, unknown>;
|
|
15
|
+
// Desktop refreshes MCP endpoints after launch. Tool transport metadata and TOML
|
|
16
|
+
// formatting do not select the model transport. Keep all other (including unknown)
|
|
17
|
+
// native fields bound, rather than an allowlist that could miss a new routing key.
|
|
18
|
+
const { mcp_servers: _mcpServers, ...nativeContext } = native;
|
|
19
|
+
const projection = Object.fromEntries(ROUTING_KEYS.map(key => [key, owner.config[key]]));
|
|
20
|
+
const serialized = JSON.stringify([nativeContext, owner.hostname, owner.port, owner.loopbackPort, projection], (_key, value) =>
|
|
21
|
+
record(value) ? Object.fromEntries(Object.keys(value).sort().map(key => [key, value[key]])) : value);
|
|
22
|
+
if (Buffer.byteLength(serialized) > 1048576) throw new Error("Routing snapshot too large");
|
|
23
|
+
return createHash("sha256").update(serialized).digest("hex");
|
|
24
|
+
}
|
|
25
|
+
/** Validates the effective root/profile routing only; it does not prove a thread's selected provider. */
|
|
26
|
+
export function matchesNativeCompatibilityRouting(text: string, owner: NativeCompatibilityOwner | null): boolean {
|
|
27
|
+
if (!owner || owner.config.codexDesktopAuthless === true || owner.config.runtimeRole === "client") return false;
|
|
28
|
+
try {
|
|
29
|
+
const root: unknown = Bun.TOML.parse(text);
|
|
30
|
+
if (!record(root)) return false;
|
|
31
|
+
let effective = root;
|
|
32
|
+
if (root.profile !== undefined) {
|
|
33
|
+
if (typeof root.profile !== "string" || !record(root.profiles)) return false;
|
|
34
|
+
const profile = root.profiles[root.profile];
|
|
35
|
+
if (!record(profile)) return false;
|
|
36
|
+
effective = { ...root, ...profile };
|
|
37
|
+
}
|
|
38
|
+
if ((effective.model_provider ?? "openai") !== "openai" || effective.forced_login_method === "api") return false;
|
|
39
|
+
if (typeof effective.openai_base_url !== "string") return false;
|
|
40
|
+
const url = new URL(effective.openai_base_url);
|
|
41
|
+
if (url.protocol !== "http:" || !["127.0.0.1", "[::1]"].includes(url.hostname) || url.username || url.password
|
|
42
|
+
|| url.search || url.hash || !["/v1", "/v1/"].includes(url.pathname) || !url.port) return false;
|
|
43
|
+
const port = Number(url.port), matchesPort = (value: number | undefined) => Number.isInteger(value) && Number(value) > 0 && value === port;
|
|
44
|
+
// The companion always binds IPv4. localhost can resolve to another address family.
|
|
45
|
+
if (matchesPort(owner.loopbackPort) && url.hostname === "127.0.0.1") return true;
|
|
46
|
+
if (!matchesPort(owner.port)) return false;
|
|
47
|
+
return url.hostname === "127.0.0.1" ? ["127.0.0.1", "0.0.0.0"].includes(owner.hostname)
|
|
48
|
+
: ["::1", "[::1]", "::", "[::]"].includes(owner.hostname);
|
|
49
|
+
} catch { return false; }
|
|
50
|
+
}
|
|
51
|
+
export function createNativeRoutingVerifier(codexHome: string, readOwner = nativeCompatibilityOwner) {
|
|
52
|
+
const path = join(codexHome, "config.toml");
|
|
53
|
+
let baseline: string | undefined, invalidated = false;
|
|
54
|
+
return () => {
|
|
55
|
+
if (invalidated) return false;
|
|
56
|
+
try {
|
|
57
|
+
// Process-level overrides could put the app on another transport despite its TOML.
|
|
58
|
+
if (["CODEX_API_BASE_URL", "CODEX_APP_SERVER_WS_URL", "CODEX_ELECTRON_USER_DATA_PATH", "ELECTRON_RUN_AS_NODE"].some(key => process.env[key]?.trim())) { invalidated = true; return false; }
|
|
59
|
+
const stat = lstatSync(path);
|
|
60
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1048576) { invalidated = true; return false; }
|
|
61
|
+
const text = readFileSync(path, "utf8"), owner = readOwner();
|
|
62
|
+
if (!owner || !matchesNativeCompatibilityRouting(text, owner)) { invalidated = true; return false; }
|
|
63
|
+
const current = routingDigest(text, owner);
|
|
64
|
+
if (baseline !== undefined && current !== baseline) { invalidated = true; return false; }
|
|
65
|
+
baseline ??= current;
|
|
66
|
+
return true;
|
|
67
|
+
} catch { invalidated = true; return false; }
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
interface LaunchContext { pacUrl: string; generation: string; current(): boolean }
|
|
4
|
+
/** Process-local ownership; the management sibling guard owns the cross-instance boundary. */
|
|
5
|
+
let owner: { token: symbol; kind: "runtime" | "certificate"; launch?: LaunchContext } | null = null;
|
|
6
|
+
export function desktopCompatibilityRuntimeActive(): boolean { return owner?.kind === "runtime"; }
|
|
7
|
+
function acquire(kind: "runtime" | "certificate"): () => void {
|
|
8
|
+
if (owner !== null) throw new Error("desktop_compatibility_busy");
|
|
9
|
+
const token = Symbol(); owner = { token, kind };
|
|
10
|
+
return () => { if (owner?.token === token) owner = null; };
|
|
11
|
+
}
|
|
12
|
+
export const acquireDesktopCompatibilityRuntime = () => acquire("runtime");
|
|
13
|
+
export const acquireDesktopCertificateMutation = () => acquire("certificate");
|
|
14
|
+
|
|
15
|
+
/** Published only by a successfully started runtime, never reconstructed from disk or app arguments. */
|
|
16
|
+
export function bindDesktopCompatibilityLaunch(pacUrl: string, current: () => boolean): () => void {
|
|
17
|
+
if (owner?.kind !== "runtime" || owner.launch) throw new Error("desktop_compatibility_launch_owner_unverified");
|
|
18
|
+
const lease = owner, launch = { pacUrl, generation: randomUUID(), current };
|
|
19
|
+
lease.launch = launch;
|
|
20
|
+
return () => { if (lease.launch === launch) delete lease.launch; };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** No key loading, socket discovery, or optional-runtime construction on the ordinary restart path. */
|
|
24
|
+
export function readDesktopCompatibilityLaunch(): { pacUrl: string; generation: string } | null {
|
|
25
|
+
const launch = owner?.kind === "runtime" ? owner.launch : undefined;
|
|
26
|
+
try { return launch?.current() ? { pacUrl: launch.pacUrl, generation: launch.generation } : null; }
|
|
27
|
+
catch { return null; }
|
|
28
|
+
}
|