codex-copilot-dx 0.9.23 → 0.9.25
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 +20 -20
- package/bin/cli.mjs +51 -19
- package/docs/ARCHITECTURE.md +50 -0
- package/docs/README.md +4 -0
- package/docs/REFERENCE.md +124 -71
- package/package.json +3 -2
- package/src/adapter.mjs +5 -2
- package/src/cli-image.mjs +13 -10
- package/src/cli-options.mjs +13 -4
- package/src/cli-status.mjs +5 -0
- package/src/client-integration.mjs +41 -0
- package/src/codex-config-doctor.mjs +3 -3
- package/src/codex-paths.mjs +10 -0
- package/src/codex-thread-compression.mjs +11 -6
- package/src/config.mjs +10 -6
- package/src/copilot-responses-policy.mjs +4 -2
- package/src/dashboard/index.html +7 -6
- package/src/dashboard/language.js +13 -21
- package/src/dashboard/ui.js +10 -20
- package/src/dashboard-api.mjs +0 -12
- package/src/launcher.mjs +7 -2
- package/src/lock.mjs +29 -0
- package/src/native-prediction-overlay.mjs +92 -0
- package/src/native-prediction-profile.mjs +178 -0
- package/src/native-prediction-state.mjs +29 -0
- package/src/native-prediction-transport.mjs +255 -0
- package/src/native-prediction.mjs +170 -0
- package/src/network-route.mjs +102 -0
- package/src/observability.mjs +2 -0
- package/src/operation-error.mjs +37 -0
- package/src/package-update.mjs +2 -3
- package/src/prediction-retirement.mjs +86 -0
- package/src/proxy-tunnel.mjs +102 -0
- package/src/responses-request.mjs +5 -2
- package/src/suggestion-auto.mjs +3 -103
- package/src/suggestion-mcp.mjs +64 -132
- package/src/user-settings.mjs +17 -4
- package/src/version.mjs +23 -46
- package/src/cli-suggestion.mjs +0 -120
- package/src/suggestion-config.mjs +0 -98
- package/src/suggestion-context.mjs +0 -108
- package/src/suggestion-provider.mjs +0 -41
- package/src/suggestion-ui.html +0 -26
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { createHash, X509Certificate, createPrivateKey, createPublicKey } from "node:crypto";
|
|
6
|
+
import { atomicWriteFileSync } from "./atomic-file.mjs";
|
|
7
|
+
import { userSettingsPath } from "./user-settings.mjs";
|
|
8
|
+
import { resolveNetworkRoute } from "./network-route.mjs";
|
|
9
|
+
import { codexPaths } from "./codex-paths.mjs";
|
|
10
|
+
|
|
11
|
+
export function predictionPaths({ env = process.env, home = os.homedir() } = {}) {
|
|
12
|
+
const directory = path.join(path.dirname(userSettingsPath({ env, home })), "native-prediction");
|
|
13
|
+
const { profile, certificateStore } = codexPaths({ env, home });
|
|
14
|
+
return { directory, settings: path.join(directory, "settings.json"), profile,
|
|
15
|
+
store: certificateStore };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function readPredictionSettings(options = {}) {
|
|
19
|
+
const { settings } = predictionPaths(options);
|
|
20
|
+
try {
|
|
21
|
+
const value = JSON.parse(fs.readFileSync(settings, "utf8"));
|
|
22
|
+
if (value?.version !== 1 || typeof value.enabled !== "boolean") throw new Error();
|
|
23
|
+
return value;
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error.code === "ENOENT") return { version: 1, enabled: false };
|
|
26
|
+
throw new Error("Native prediction settings are invalid; no settings were changed");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function writePredictionSettings(value, options = {}) {
|
|
31
|
+
atomicWriteFileSync(predictionPaths(options).settings, JSON.stringify(value, null, 2) + "\n", { preserveMode: false });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function assertPredictionProxyCompatible({ env = process.env, systemProxy = "" } = {}) {
|
|
35
|
+
return resolveNetworkRoute({ env, systemProxy });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function assertCodexClosed({ exec = execFileSync } = {}) {
|
|
39
|
+
for (const name of ["ChatGPT", "Codex"]) {
|
|
40
|
+
try {
|
|
41
|
+
exec("/usr/bin/pgrep", ["-x", name], { stdio: "pipe", timeout: 2000 });
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.status === 1) continue;
|
|
44
|
+
throw new Error("Could not check whether Codex is closed; prediction setup was skipped");
|
|
45
|
+
}
|
|
46
|
+
throw new Error("Quit Codex App before changing native prediction setup; no running App will be stopped");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function installedPredictionApp({ exec = execFileSync } = {}) {
|
|
51
|
+
for (const app of ["/Applications/ChatGPT.app", "/Applications/Codex.app"]) {
|
|
52
|
+
const archive = path.join(app, "Contents", "Resources", "app.asar");
|
|
53
|
+
if (!fs.existsSync(archive)) continue;
|
|
54
|
+
// Check only during explicit setup; startup uses the cheap identity check.
|
|
55
|
+
const fd = fs.openSync(archive, "r");
|
|
56
|
+
try {
|
|
57
|
+
const prefix = Buffer.alloc(16); fs.readSync(fd, prefix, 0, 16, 0);
|
|
58
|
+
const headerSize = prefix.readUInt32LE(12);
|
|
59
|
+
if (headerSize > 32 * 1024 * 1024) continue;
|
|
60
|
+
const header = Buffer.alloc(headerSize); fs.readSync(fd, header, 0, header.length, 16);
|
|
61
|
+
const files = JSON.parse(header).files?.[".vite"]?.files?.build?.files;
|
|
62
|
+
const entry = Object.entries(files || {}).find(([name]) => /^main-[\w-]+\.js$/.test(name))?.[1];
|
|
63
|
+
if (!entry || entry.size > 32 * 1024 * 1024 || entry.unpacked) continue;
|
|
64
|
+
const bytes = Buffer.alloc(entry.size);
|
|
65
|
+
fs.readSync(fd, bytes, 0, bytes.length, 8 + prefix.readUInt32LE(4) + Number(entry.offset));
|
|
66
|
+
const source = bytes.toString();
|
|
67
|
+
if (!["/wham/predictions/config", "experimentalPredictionMode"].every((value) => source.includes(value))) continue;
|
|
68
|
+
const renderer = Object.entries(JSON.parse(header).files?.webview?.files?.assets?.files || {})
|
|
69
|
+
.find(([name]) => /^general-settings-[\w-]+\.js$/.test(name))?.[1];
|
|
70
|
+
if (!renderer || renderer.unpacked || renderer.size > 4 * 1024 * 1024) continue;
|
|
71
|
+
const rendererBytes = Buffer.alloc(renderer.size);
|
|
72
|
+
fs.readSync(fd, rendererBytes, 0, rendererBytes.length, 8 + prefix.readUInt32LE(4) + Number(renderer.offset));
|
|
73
|
+
if (!rendererBytes.includes(Buffer.from("4290031608"))) continue;
|
|
74
|
+
exec("/usr/bin/codesign", ["--verify", "--deep", "--strict", app], { stdio: "pipe", timeout: 15_000 });
|
|
75
|
+
const stat = fs.statSync(archive);
|
|
76
|
+
return { app, archive, size: stat.size, mtimeMs: stat.mtimeMs };
|
|
77
|
+
} finally { fs.closeSync(fd); }
|
|
78
|
+
}
|
|
79
|
+
throw new Error("This Codex App does not expose the verified native prediction protocol; setup was skipped");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function predictionAppMatches(app) {
|
|
83
|
+
try { const stat = fs.statSync(app.archive); return stat.size === app.size && stat.mtimeMs === app.mtimeMs; }
|
|
84
|
+
catch { return false; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function privateDirectory(directory) {
|
|
88
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
89
|
+
const stat = fs.lstatSync(directory);
|
|
90
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077)) {
|
|
91
|
+
throw new Error("Prediction key directory must be private and must not be a symlink");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function readPredictionCertificate(directory, { allowExpired = false } = {}) {
|
|
96
|
+
const cert = fs.readFileSync(path.join(directory, "bridge.pem"));
|
|
97
|
+
const ca = new X509Certificate(fs.readFileSync(path.join(directory, "ca.pem")));
|
|
98
|
+
const keyFile = path.join(directory, "bridge.key");
|
|
99
|
+
const info = fs.lstatSync(keyFile);
|
|
100
|
+
if (!info.isFile() || info.isSymbolicLink() || (info.mode & 0o077)) throw new Error("Prediction key is not private");
|
|
101
|
+
const key = fs.readFileSync(keyFile);
|
|
102
|
+
const x509 = new X509Certificate(cert);
|
|
103
|
+
const publicKey = createPublicKey(createPrivateKey(key)).export({ type: "spki", format: "der" });
|
|
104
|
+
if (!x509.publicKey.export({ type: "spki", format: "der" }).equals(publicKey)
|
|
105
|
+
|| (!allowExpired && Date.parse(x509.validTo) <= Date.now()) || !x509.checkHost("chatgpt.com") || !x509.checkHost("ab.chatgpt.com")
|
|
106
|
+
|| !x509.verify(ca.publicKey) || !ca.ca || x509.ca) {
|
|
107
|
+
throw new Error("Prediction certificate needs renewal; quit Codex and run ccdx prediction on");
|
|
108
|
+
}
|
|
109
|
+
return { cert, key, ca: ca.toString(), hash: createHash("sha256").update(ca.raw).digest("hex").toUpperCase(), der: ca.raw };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createPredictionCertificate(directory, { exec = execFileSync } = {}) {
|
|
113
|
+
privateDirectory(directory);
|
|
114
|
+
const temporary = fs.mkdtempSync(path.join(directory, "certificate-"));
|
|
115
|
+
try {
|
|
116
|
+
const config = `[req]\nprompt=no\ndistinguished_name=dn\nx509_extensions=ext\n[dn]\nCN=CCDX native prediction local bridge\n[ext]\nbasicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,digitalSignature\nextendedKeyUsage=serverAuth\nsubjectAltName=DNS:chatgpt.com,DNS:ab.chatgpt.com\nnameConstraints=critical,permitted;DNS:chatgpt.com\n`;
|
|
117
|
+
const configFile = path.join(temporary, "openssl.cnf");
|
|
118
|
+
atomicWriteFileSync(configFile, config);
|
|
119
|
+
const certFile = path.join(temporary, "cert.pem"), keyFile = path.join(temporary, "key.pem");
|
|
120
|
+
const caFile = path.join(temporary, "ca.pem"), caKey = path.join(temporary, "ca.key");
|
|
121
|
+
const run = (args) => exec("/usr/bin/openssl", args, { stdio: "pipe", timeout: 15_000 });
|
|
122
|
+
run(["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "90", "-config", configFile, "-out", caFile, "-keyout", caKey]);
|
|
123
|
+
const csr = path.join(temporary, "leaf.csr"), extensions = path.join(temporary, "leaf.cnf");
|
|
124
|
+
atomicWriteFileSync(extensions, "basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=DNS:chatgpt.com,DNS:ab.chatgpt.com\n");
|
|
125
|
+
run(["req", "-newkey", "rsa:2048", "-nodes", "-keyout", keyFile, "-out", csr, "-subj", "/CN=CCDX local prediction bridge"]);
|
|
126
|
+
run(["x509", "-req", "-in", csr, "-CA", caFile, "-CAkey", caKey, "-CAcreateserial", "-days", "90", "-extfile", extensions, "-out", certFile]);
|
|
127
|
+
atomicWriteFileSync(path.join(directory, "bridge.key"), fs.readFileSync(keyFile), { preserveMode: false });
|
|
128
|
+
atomicWriteFileSync(path.join(directory, "bridge.pem"), fs.readFileSync(certFile), { preserveMode: false });
|
|
129
|
+
atomicWriteFileSync(path.join(directory, "ca.pem"), fs.readFileSync(caFile), { preserveMode: false });
|
|
130
|
+
return readPredictionCertificate(directory);
|
|
131
|
+
} finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Chromium certificate metadata: trusted anchor, exact DNS constraints. This
|
|
135
|
+
// private record is scoped to one App profile; no system keychain is changed.
|
|
136
|
+
export function predictionTrustMetadata() {
|
|
137
|
+
const names = ["chatgpt.com", "ab.chatgpt.com"].map((name) => Buffer.concat([Buffer.from([10, Buffer.byteLength(name)]), Buffer.from(name)]));
|
|
138
|
+
const constraints = Buffer.concat(names);
|
|
139
|
+
return Buffer.concat([Buffer.from([10, 2, 8, 3, 18, constraints.length]), constraints]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function updatePredictionTrust(store, certificate, { remove = false, priorHash = null } = {}) {
|
|
143
|
+
if (remove && !fs.existsSync(store)) return false;
|
|
144
|
+
if (fs.existsSync(store) && !fs.lstatSync(store).isFile()) throw new Error("Unexpected certificate-store file; skipped");
|
|
145
|
+
fs.mkdirSync(path.dirname(store), { recursive: true, mode: 0o700 });
|
|
146
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
147
|
+
const db = new DatabaseSync(store);
|
|
148
|
+
try {
|
|
149
|
+
db.exec("PRAGMA busy_timeout=1000; BEGIN IMMEDIATE");
|
|
150
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((item) => item.name);
|
|
151
|
+
if (!tables.length && !remove) {
|
|
152
|
+
db.exec("CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY,value LONGVARCHAR); CREATE TABLE certificates(sha256hash_hex TEXT PRIMARY KEY,der_cert BLOB NOT NULL,trust_settings BLOB NOT NULL)");
|
|
153
|
+
db.prepare("INSERT INTO meta VALUES(?,?)").run("version", "1");
|
|
154
|
+
db.prepare("INSERT INTO meta VALUES(?,?)").run("last_compatible_version", "1");
|
|
155
|
+
}
|
|
156
|
+
const meta = db.prepare("SELECT value FROM meta WHERE key='version'").get();
|
|
157
|
+
const columns = db.prepare("PRAGMA table_info(certificates)").all().map((item) => item.name);
|
|
158
|
+
if (String(meta?.value) !== "1" || columns.join(",") !== "sha256hash_hex,der_cert,trust_settings") throw new Error("Unknown certificate-store schema; skipped");
|
|
159
|
+
const metadata = predictionTrustMetadata();
|
|
160
|
+
const existing = db.prepare("SELECT der_cert,trust_settings FROM certificates WHERE sha256hash_hex=?").get(certificate.hash);
|
|
161
|
+
if (existing && (!Buffer.from(existing.der_cert).equals(certificate.der) || !Buffer.from(existing.trust_settings).equals(metadata))) throw new Error("Prediction trust was modified; it was preserved");
|
|
162
|
+
if (remove) {
|
|
163
|
+
if (existing) db.prepare("DELETE FROM certificates WHERE sha256hash_hex=?").run(certificate.hash);
|
|
164
|
+
} else {
|
|
165
|
+
if (priorHash && priorHash !== certificate.hash) {
|
|
166
|
+
const previous = db.prepare("SELECT trust_settings FROM certificates WHERE sha256hash_hex=?").get(priorHash);
|
|
167
|
+
if (previous && !Buffer.from(previous.trust_settings).equals(metadata)) throw new Error("Previous prediction trust was modified; it was preserved");
|
|
168
|
+
db.prepare("DELETE FROM certificates WHERE sha256hash_hex=?").run(priorHash);
|
|
169
|
+
}
|
|
170
|
+
if (!existing) db.prepare("INSERT INTO certificates VALUES(?,?,?)").run(certificate.hash, certificate.der, metadata);
|
|
171
|
+
}
|
|
172
|
+
db.exec("COMMIT");
|
|
173
|
+
return remove ? Boolean(existing) : !existing;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
try { db.exec("ROLLBACK"); } catch {}
|
|
176
|
+
throw error;
|
|
177
|
+
} finally { db.close(); }
|
|
178
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { redactDiagnosticText } from "./diagnostic-text.mjs";
|
|
2
|
+
import { operationErrorDetails } from "./operation-error.mjs";
|
|
3
|
+
|
|
4
|
+
export function createPredictionState({ now = () => new Date().toISOString() } = {}) {
|
|
5
|
+
let state = { configured: false, bridge: "stopped", client_route: "unknown", candidate: "unknown", reason: null,
|
|
6
|
+
configuration: {}, counters: { configuration_responses: 0, modified: 0, tls_errors: 0, upstream_errors: 0 }, last_event_at: null };
|
|
7
|
+
return {
|
|
8
|
+
configure(enabled) { state.configured = enabled === true; },
|
|
9
|
+
running(proxy) { state.bridge = "running"; state.reason = null; state.proxy = proxy; },
|
|
10
|
+
stopped() { state.bridge = "stopped"; },
|
|
11
|
+
unavailable(error) {
|
|
12
|
+
state.bridge = "unavailable";
|
|
13
|
+
state.reason = redactDiagnosticText(String(error?.message || "Prediction setup failed")).slice(0, 400);
|
|
14
|
+
state.error = operationErrorDetails(error);
|
|
15
|
+
},
|
|
16
|
+
event(event) {
|
|
17
|
+
state.last_event_at = now();
|
|
18
|
+
if (event.type === "configuration") {
|
|
19
|
+
state.client_route = "observed";
|
|
20
|
+
state.counters.configuration_responses++;
|
|
21
|
+
if (event.changed) state.counters.modified++;
|
|
22
|
+
state.configuration[event.route] = { status: event.status, modified: event.changed === true, official: event.official === true };
|
|
23
|
+
} else if (event.type === "tls_error") state.counters.tls_errors++;
|
|
24
|
+
else if (event.type === "upstream_error") state.counters.upstream_errors++;
|
|
25
|
+
if (event.error) state.last_error = { code: event.error.code, source: event.error.source, message: event.error.message, status: event.error.status };
|
|
26
|
+
},
|
|
27
|
+
snapshot() { return structuredClone(state); },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { gunzip, inflate, brotliDecompress } from "node:zlib";
|
|
5
|
+
import { networkPredictionPac } from "./network-route.mjs";
|
|
6
|
+
import { operationErrorDetails } from "./operation-error.mjs";
|
|
7
|
+
|
|
8
|
+
export const PREDICTION_HOSTS = Object.freeze(["chatgpt.com", "ab.chatgpt.com"]);
|
|
9
|
+
const CONFIG_LIMIT = 16 * 1024 * 1024;
|
|
10
|
+
const REQUEST_LIMIT = 512 * 1024;
|
|
11
|
+
const decoders = { gzip: promisify(gunzip), deflate: promisify(inflate), br: promisify(brotliDecompress) };
|
|
12
|
+
|
|
13
|
+
export function predictionRoute(host, pathname) {
|
|
14
|
+
if (host === "chatgpt.com" && pathname === "/backend-api/wham/statsig/bootstrap") return "bootstrap";
|
|
15
|
+
if (host === "chatgpt.com" && pathname === "/backend-api/wham/predictions/config") return "prediction";
|
|
16
|
+
if (host === "ab.chatgpt.com" && pathname === "/v1/initialize") return "refresh";
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function predictionPac(port, networkRoute) {
|
|
21
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid prediction proxy port");
|
|
22
|
+
if (networkRoute) return networkPredictionPac(port, networkRoute);
|
|
23
|
+
return `function FindProxyForURL(url, host) { if (url.indexOf('https:') === 0 && (host === 'chatgpt.com' || host === 'ab.chatgpt.com')) return 'PROXY 127.0.0.1:${port}; DIRECT'; return 'DIRECT'; }\n`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cleanHeaders(headers) {
|
|
27
|
+
const result = { ...headers };
|
|
28
|
+
for (const name of String(headers.connection || "").split(",")) delete result[name.trim().toLowerCase()];
|
|
29
|
+
for (const name of ["connection", "proxy-authorization", "proxy-authenticate", "keep-alive", "te", "trailer", "transfer-encoding", "upgrade"]) delete result[name];
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function changedHeaders(headers, length) {
|
|
34
|
+
const result = cleanHeaders(headers);
|
|
35
|
+
for (const name of ["content-encoding", "etag", "content-md5", "digest", "content-digest"]) delete result[name];
|
|
36
|
+
result["content-length"] = String(length);
|
|
37
|
+
result["cache-control"] = "no-store";
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function decode(bytes, encoding, limit) {
|
|
42
|
+
if (!encoding || encoding === "identity") return bytes;
|
|
43
|
+
if (!decoders[encoding]) throw new Error("Unsupported configuration encoding");
|
|
44
|
+
return decoders[encoding](bytes, { maxOutputLength: limit });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function listen(server) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
server.once("error", reject);
|
|
50
|
+
server.listen(0, "127.0.0.1", () => {
|
|
51
|
+
server.removeListener("error", reject);
|
|
52
|
+
resolve(server.address().port);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Only selected configuration bodies are buffered. Other traffic is streamed,
|
|
58
|
+
// without retries, redirects, credential storage or request/response logging.
|
|
59
|
+
export async function startPredictionTransport({
|
|
60
|
+
key, cert, overlay, normalizeRequest = (value) => value,
|
|
61
|
+
enabled = () => true, requestImpl = https.request,
|
|
62
|
+
agent, networkRoute, onEvent = () => {},
|
|
63
|
+
timeoutMs = 30_000, maxConnections = 128,
|
|
64
|
+
}) {
|
|
65
|
+
const sockets = new Set();
|
|
66
|
+
const upstreams = new Set();
|
|
67
|
+
let closing = false;
|
|
68
|
+
const gateway = https.createServer({ key, cert, minVersion: "TLSv1.2" }, (req, res) => {
|
|
69
|
+
let target;
|
|
70
|
+
try {
|
|
71
|
+
target = new URL(req.url, `https://${req.headers.host}`);
|
|
72
|
+
if (!PREDICTION_HOSTS.includes(target.hostname) || target.port || target.protocol !== "https:"
|
|
73
|
+
|| target.username || target.password || !req.url.startsWith("/") || req.url.startsWith("//")
|
|
74
|
+
|| req.headers.host !== target.hostname || req.socket.servername !== target.hostname) throw new Error("Invalid target");
|
|
75
|
+
} catch {
|
|
76
|
+
res.writeHead(403); res.end(); return;
|
|
77
|
+
}
|
|
78
|
+
const route = predictionRoute(target.hostname, target.pathname);
|
|
79
|
+
const headers = cleanHeaders(req.headers);
|
|
80
|
+
// No decoding/re-encoding on pass-through paths.
|
|
81
|
+
if (route && enabled()) headers["accept-encoding"] = "identity";
|
|
82
|
+
let upstream;
|
|
83
|
+
const fail = (error) => {
|
|
84
|
+
onEvent({ type: "upstream_error", error: operationErrorDetails(error) });
|
|
85
|
+
if (!res.headersSent) { res.writeHead(502); res.end(); } else res.destroy();
|
|
86
|
+
};
|
|
87
|
+
const send = (body, streamRemainder = false) => {
|
|
88
|
+
if (res.destroyed || closing) return;
|
|
89
|
+
upstream = requestImpl({
|
|
90
|
+
protocol: "https:", hostname: target.hostname, port: 443,
|
|
91
|
+
servername: target.hostname, method: req.method,
|
|
92
|
+
path: req.url, headers,
|
|
93
|
+
rejectUnauthorized: true,
|
|
94
|
+
...(agent ? { agent } : {}),
|
|
95
|
+
}, (reply) => {
|
|
96
|
+
const pass = () => {
|
|
97
|
+
res.writeHead(reply.statusCode, cleanHeaders(reply.headers));
|
|
98
|
+
reply.pipe(res);
|
|
99
|
+
};
|
|
100
|
+
reply.on("error", fail);
|
|
101
|
+
// Error and non-JSON responses are forwarded as received. The overlay
|
|
102
|
+
// decides whether the narrowly allowed missing-config response applies.
|
|
103
|
+
const mediaType = String(reply.headers["content-type"] || "").split(";", 1)[0].trim();
|
|
104
|
+
if (!route || !enabled() || mediaType !== "application/json") {
|
|
105
|
+
if (route) onEvent({ type: "configuration", route, status: reply.statusCode, changed: false });
|
|
106
|
+
pass(); return;
|
|
107
|
+
}
|
|
108
|
+
let size = 0;
|
|
109
|
+
const parts = [];
|
|
110
|
+
let streaming = false;
|
|
111
|
+
reply.on("data", (chunk) => {
|
|
112
|
+
if (streaming) return;
|
|
113
|
+
parts.push(chunk); size += chunk.length;
|
|
114
|
+
if (size > CONFIG_LIMIT) {
|
|
115
|
+
streaming = true;
|
|
116
|
+
res.writeHead(reply.statusCode, cleanHeaders(reply.headers));
|
|
117
|
+
reply.pause();
|
|
118
|
+
const buffered = Buffer.concat(parts); parts.length = 0;
|
|
119
|
+
if (res.write(buffered)) reply.pipe(res);
|
|
120
|
+
else res.once("drain", () => reply.pipe(res));
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
reply.on("end", () => void (async () => {
|
|
124
|
+
if (streaming || res.destroyed) return;
|
|
125
|
+
const original = Buffer.concat(parts);
|
|
126
|
+
let next = null;
|
|
127
|
+
try {
|
|
128
|
+
const decoded = await decode(original, reply.headers["content-encoding"], CONFIG_LIMIT);
|
|
129
|
+
if (decoded.length <= CONFIG_LIMIT) next = overlay({
|
|
130
|
+
route, host: target.hostname, pathname: target.pathname,
|
|
131
|
+
status: reply.statusCode, body: decoded.toString("utf8"),
|
|
132
|
+
enabled: enabled(),
|
|
133
|
+
});
|
|
134
|
+
} catch { /* Preserve unexpected upstream payloads byte-for-byte. */ }
|
|
135
|
+
if (next?.body !== undefined && (next.body !== original.toString("utf8") || next.status !== reply.statusCode)) {
|
|
136
|
+
onEvent({ type: "configuration", route, status: next.status ?? reply.statusCode, changed: true });
|
|
137
|
+
const bytes = Buffer.from(next.body);
|
|
138
|
+
res.writeHead(next.status ?? reply.statusCode, changedHeaders(reply.headers, bytes.length));
|
|
139
|
+
res.end(bytes);
|
|
140
|
+
} else {
|
|
141
|
+
onEvent({ type: "configuration", route, status: reply.statusCode, changed: false, official: next?.official });
|
|
142
|
+
res.writeHead(reply.statusCode, cleanHeaders(reply.headers)); res.end(original);
|
|
143
|
+
}
|
|
144
|
+
})().catch(fail));
|
|
145
|
+
});
|
|
146
|
+
upstreams.add(upstream);
|
|
147
|
+
upstream.once("close", () => upstreams.delete(upstream));
|
|
148
|
+
if (route && enabled()) upstream.setTimeout(timeoutMs, () => upstream.destroy(new Error("Upstream timeout")));
|
|
149
|
+
upstream.on("error", fail);
|
|
150
|
+
if (streamRemainder) {
|
|
151
|
+
if (upstream.write(body)) req.pipe(upstream);
|
|
152
|
+
else upstream.once("drain", () => req.pipe(upstream));
|
|
153
|
+
} else if (body !== undefined) upstream.end(body); else req.pipe(upstream);
|
|
154
|
+
};
|
|
155
|
+
req.on("aborted", () => upstream?.destroy());
|
|
156
|
+
req.on("error", () => upstream?.destroy());
|
|
157
|
+
res.on("close", () => { if (!res.writableFinished) upstream?.destroy(); });
|
|
158
|
+
if (route !== "refresh" || req.method !== "POST" || !enabled()) { send(); return; }
|
|
159
|
+
// Statsig deltas use a checksum of prior values. Ask for a complete response
|
|
160
|
+
// so the client never merges an upstream delta with locally changed values.
|
|
161
|
+
void (async () => {
|
|
162
|
+
const parts = []; let size = 0;
|
|
163
|
+
for await (const part of req.iterator({ destroyOnReturn: false })) {
|
|
164
|
+
size += part.length;
|
|
165
|
+
parts.push(part);
|
|
166
|
+
if (size > REQUEST_LIMIT) { send(Buffer.concat(parts), true); return; }
|
|
167
|
+
}
|
|
168
|
+
let body = Buffer.concat(parts);
|
|
169
|
+
try {
|
|
170
|
+
const decoded = await decode(body, headers["content-encoding"], REQUEST_LIMIT);
|
|
171
|
+
const encoded = target.searchParams.get("se") === "1";
|
|
172
|
+
const text = encoded ? Buffer.from(decoded.toString().split("").reverse().join(""), "base64").toString() : decoded.toString();
|
|
173
|
+
const parsed = JSON.parse(text);
|
|
174
|
+
const normalized = normalizeRequest(parsed);
|
|
175
|
+
if (normalized !== parsed) {
|
|
176
|
+
const json = JSON.stringify(normalized);
|
|
177
|
+
body = Buffer.from(encoded ? Buffer.from(json).toString("base64").split("").reverse().join("") : json);
|
|
178
|
+
delete headers["content-encoding"];
|
|
179
|
+
headers["content-length"] = String(body.length);
|
|
180
|
+
}
|
|
181
|
+
} catch { /* Unknown request encoding stays untouched. */ }
|
|
182
|
+
send(body);
|
|
183
|
+
})().catch(fail);
|
|
184
|
+
});
|
|
185
|
+
gateway.on("secureConnection", (socket) => socket.setTimeout(0));
|
|
186
|
+
gateway.on("upgrade", (req, socket, head) => {
|
|
187
|
+
const host = req.headers.host;
|
|
188
|
+
if (!PREDICTION_HOSTS.includes(host) || req.socket.servername !== host
|
|
189
|
+
|| !req.url.startsWith("/") || req.url.startsWith("//")) { socket.destroy(); return; }
|
|
190
|
+
const upstream = requestImpl({
|
|
191
|
+
protocol: "https:", hostname: host, port: 443, servername: host,
|
|
192
|
+
method: req.method, path: req.url, rejectUnauthorized: true,
|
|
193
|
+
...(agent ? { agent } : {}),
|
|
194
|
+
headers: { ...cleanHeaders(req.headers), connection: "Upgrade", upgrade: req.headers.upgrade },
|
|
195
|
+
});
|
|
196
|
+
upstreams.add(upstream);
|
|
197
|
+
upstream.on("error", () => socket.destroy());
|
|
198
|
+
upstream.on("response", (reply) => {
|
|
199
|
+
const headers = Object.entries(cleanHeaders(reply.headers)).flatMap(([name, values]) =>
|
|
200
|
+
(Array.isArray(values) ? values : [values]).map((value) => `${name}: ${value}`));
|
|
201
|
+
socket.write(`HTTP/1.1 ${reply.statusCode} ${reply.statusMessage}\r\n${headers.join("\r\n")}\r\nConnection: close\r\n\r\n`);
|
|
202
|
+
reply.on("error", () => socket.destroy()); reply.pipe(socket);
|
|
203
|
+
});
|
|
204
|
+
upstream.on("upgrade", (reply, peer, peerHead) => {
|
|
205
|
+
upstreams.delete(upstream);
|
|
206
|
+
sockets.add(peer); peer.once("close", () => sockets.delete(peer));
|
|
207
|
+
const headers = Object.entries(reply.headers).flatMap(([name, values]) =>
|
|
208
|
+
(Array.isArray(values) ? values : [values]).map((value) => `${name}: ${value}`));
|
|
209
|
+
socket.write(`HTTP/1.1 ${reply.statusCode} ${reply.statusMessage}\r\n${headers.join("\r\n")}\r\n\r\n`);
|
|
210
|
+
if (peerHead.length) socket.write(peerHead);
|
|
211
|
+
if (head.length) peer.write(head);
|
|
212
|
+
socket.on("error", () => peer.destroy()); peer.on("error", () => socket.destroy());
|
|
213
|
+
socket.once("close", () => peer.destroy()); peer.once("close", () => socket.destroy());
|
|
214
|
+
socket.pipe(peer); peer.pipe(socket);
|
|
215
|
+
});
|
|
216
|
+
socket.once("close", () => { upstreams.delete(upstream); upstream.destroy(); });
|
|
217
|
+
upstream.end();
|
|
218
|
+
});
|
|
219
|
+
gateway.on("tlsClientError", (error, socket) => { onEvent({ type: "tls_error", error: operationErrorDetails(error) }); socket.destroy(); });
|
|
220
|
+
gateway.on("clientError", (_error, socket) => socket.destroy());
|
|
221
|
+
const proxy = http.createServer((req, res) => {
|
|
222
|
+
if (req.method === "GET" && req.url === "/proxy.pac" && !req.headers.origin
|
|
223
|
+
&& ["127.0.0.1", "localhost"].includes(String(req.headers.host || "").split(":")[0])) {
|
|
224
|
+
res.writeHead(200, { "Content-Type": "application/x-ns-proxy-autoconfig", "Cache-Control": "no-store" });
|
|
225
|
+
res.end(predictionPac(proxy.address().port, networkRoute)); return;
|
|
226
|
+
}
|
|
227
|
+
res.writeHead(403); res.end();
|
|
228
|
+
});
|
|
229
|
+
proxy.on("connection", (socket) => {
|
|
230
|
+
if (closing || sockets.size >= maxConnections) { socket.destroy(); return; }
|
|
231
|
+
sockets.add(socket); socket.once("close", () => sockets.delete(socket));
|
|
232
|
+
});
|
|
233
|
+
proxy.on("connect", (req, socket, head) => {
|
|
234
|
+
if (closing || !PREDICTION_HOSTS.some((host) => req.url === `${host}:443`) || req.headers.origin) {
|
|
235
|
+
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); return;
|
|
236
|
+
}
|
|
237
|
+
socket.setTimeout(timeoutMs, () => socket.destroy());
|
|
238
|
+
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
239
|
+
if (head.length) socket.unshift(head);
|
|
240
|
+
gateway.emit("connection", socket);
|
|
241
|
+
});
|
|
242
|
+
proxy.on("clientError", (_error, socket) => socket.destroy());
|
|
243
|
+
const port = await listen(proxy);
|
|
244
|
+
return {
|
|
245
|
+
port, pacUrl: `http://127.0.0.1:${port}/proxy.pac`,
|
|
246
|
+
async close() {
|
|
247
|
+
closing = true;
|
|
248
|
+
for (const upstream of upstreams) upstream.destroy();
|
|
249
|
+
agent?.destroy();
|
|
250
|
+
for (const socket of sockets) socket.destroy();
|
|
251
|
+
await new Promise((resolve) => proxy.close(resolve));
|
|
252
|
+
gateway.close();
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { predictionPaths, readPredictionSettings, writePredictionSettings, assertPredictionProxyCompatible,
|
|
5
|
+
assertCodexClosed, installedPredictionApp, predictionAppMatches, readPredictionCertificate,
|
|
6
|
+
createPredictionCertificate, updatePredictionTrust } from "./native-prediction-profile.mjs";
|
|
7
|
+
import { retirePredictionIntegration } from "./prediction-retirement.mjs";
|
|
8
|
+
import { networkRouteSnapshot } from "./network-route.mjs";
|
|
9
|
+
import { withFileLock } from "./lock.mjs";
|
|
10
|
+
|
|
11
|
+
function systemProxy(exec = execFileSync) {
|
|
12
|
+
return exec("/usr/sbin/scutil", ["--proxy"], { encoding: "utf8", timeout: 2000, maxBuffer: 64 * 1024 });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function certificateDirectory(settings, paths) {
|
|
16
|
+
if (!settings.credentialsDirectory) return paths.directory;
|
|
17
|
+
const directory = path.resolve(settings.credentialsDirectory);
|
|
18
|
+
if (path.dirname(directory) !== paths.directory || !/^credentials-[A-Za-z0-9]+$/.test(path.basename(directory))) {
|
|
19
|
+
throw new Error("Unexpected prediction key directory; it was not changed");
|
|
20
|
+
}
|
|
21
|
+
return directory;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function runPredictionCommand(options = {}) {
|
|
25
|
+
if (!options.action || options.action === "status") return runPredictionCommandUnlocked(options);
|
|
26
|
+
if ((options.platform || process.platform) !== "darwin") return runPredictionCommandUnlocked(options);
|
|
27
|
+
const { settings } = predictionPaths(options);
|
|
28
|
+
return withFileLock(`${settings}.lock`, () => runPredictionCommandUnlocked(options), { timeoutMs: 2000 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function runPredictionCommandUnlocked({ action = "status", env = process.env,
|
|
32
|
+
platform = process.platform, output = console.log, resolveApp = installedPredictionApp,
|
|
33
|
+
writeSettings = writePredictionSettings, runtimeSnapshot, ...options } = {}) {
|
|
34
|
+
const opts = { ...options, env };
|
|
35
|
+
const settings = readPredictionSettings(opts);
|
|
36
|
+
if (action === "status") {
|
|
37
|
+
output(`Native prediction preference: ${settings.enabled ? "on" : "off"} (default: off)`);
|
|
38
|
+
output("Uses the conversation's model. No prediction MCP, Hook, or panel.");
|
|
39
|
+
if (settings.enabled && !predictionAppMatches(settings.app)) output("Codex App changed; quit it and run ccdx prediction on to recheck compatibility.");
|
|
40
|
+
if (runtimeSnapshot) {
|
|
41
|
+
try {
|
|
42
|
+
const runtime = (await runtimeSnapshot())?.client_integrations?.prediction;
|
|
43
|
+
if (runtime) {
|
|
44
|
+
output(`Bridge: ${runtime.bridge}; client route: ${runtime.client_route}; visible candidate: ${runtime.candidate}`);
|
|
45
|
+
if (runtime.reason) output(runtime.reason);
|
|
46
|
+
for (const [name, event] of Object.entries(runtime.configuration || {})) output(`${name}: HTTP ${event.status}${event.modified ? " (local compatibility)" : " (upstream)"}`);
|
|
47
|
+
} else output("Runtime prediction state: unavailable; status is a saved preference, not proof of a visible suggestion.");
|
|
48
|
+
} catch { output("Runtime prediction state: unavailable; start ccdx and check again."); }
|
|
49
|
+
}
|
|
50
|
+
return settings;
|
|
51
|
+
}
|
|
52
|
+
if (!["on", "off"].includes(action)) throw new Error("Prediction action must be on, off, or status");
|
|
53
|
+
if (platform !== "darwin") throw new Error("Native prediction setup currently supports macOS only; no configuration changed");
|
|
54
|
+
const paths = predictionPaths(opts);
|
|
55
|
+
if (action === "off") {
|
|
56
|
+
// Disable the response overlay first, even when App-owned trust is busy.
|
|
57
|
+
if (settings.enabled) writeSettings({ ...settings, enabled: false }, opts);
|
|
58
|
+
if (settings.certificateHash && settings.store) {
|
|
59
|
+
try {
|
|
60
|
+
assertCodexClosed(options);
|
|
61
|
+
const certificate = readPredictionCertificate(certificateDirectory(settings, paths), { allowExpired: true });
|
|
62
|
+
if (certificate.hash !== settings.certificateHash) throw new Error("Prediction certificate changed; trust cleanup was skipped");
|
|
63
|
+
await updatePredictionTrust(settings.store, certificate, { remove: true });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
output(`Predictions disabled. ${error.message}. Quit Codex and run ccdx prediction off again to finish cleanup.`);
|
|
66
|
+
return { ...settings, enabled: false, cleanupPending: true };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const { store, certificateHash, app, ...disabled } = settings;
|
|
70
|
+
if (store || certificateHash || app) writeSettings({ ...disabled, enabled: false }, opts);
|
|
71
|
+
try { retirePredictionIntegration(opts); }
|
|
72
|
+
catch (error) { output(`Legacy prediction cleanup skipped: ${error.message}`); }
|
|
73
|
+
output("Native predictions off. Restart Codex to clear cached predictions and its temporary route.");
|
|
74
|
+
return { ...settings, enabled: false };
|
|
75
|
+
}
|
|
76
|
+
assertCodexClosed(options);
|
|
77
|
+
assertPredictionProxyCompatible({ env, systemProxy: systemProxy(options.exec) });
|
|
78
|
+
const app = resolveApp(options);
|
|
79
|
+
if (settings.store && settings.store !== paths.store) throw new Error("Codex profile changed; disable the previous profile before enabling another");
|
|
80
|
+
let certificate, previous, generated = false;
|
|
81
|
+
let credentialsDirectory = certificateDirectory(settings, paths);
|
|
82
|
+
try { certificate = readPredictionCertificate(credentialsDirectory); }
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error.code !== "ENOENT" && !error.message.includes("needs renewal")) throw error;
|
|
85
|
+
if (settings.certificateHash) {
|
|
86
|
+
previous = readPredictionCertificate(credentialsDirectory, { allowExpired: true });
|
|
87
|
+
if (previous.hash !== settings.certificateHash) throw new Error("Existing prediction certificate changed; it was preserved");
|
|
88
|
+
}
|
|
89
|
+
fs.mkdirSync(paths.directory, { recursive: true, mode: 0o700 });
|
|
90
|
+
credentialsDirectory = fs.mkdtempSync(path.join(paths.directory, "credentials-"));
|
|
91
|
+
generated = true;
|
|
92
|
+
try { certificate = createPredictionCertificate(credentialsDirectory, options); }
|
|
93
|
+
catch (failure) { fs.rmSync(credentialsDirectory, { recursive: true, force: true }); throw failure; }
|
|
94
|
+
}
|
|
95
|
+
const next = { version: 1, enabled: true, app, store: paths.store, certificateHash: certificate.hash, credentialsDirectory };
|
|
96
|
+
let trustAdded = false;
|
|
97
|
+
try {
|
|
98
|
+
// Add the new trust before committing activation; retain the old record
|
|
99
|
+
// until the commit succeeds so a failed write cannot break an existing setup.
|
|
100
|
+
trustAdded = await updatePredictionTrust(paths.store, certificate);
|
|
101
|
+
writeSettings(next, opts);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (trustAdded) await updatePredictionTrust(paths.store, certificate, { remove: true }).catch(() => {});
|
|
105
|
+
if (generated) fs.rmSync(credentialsDirectory, { recursive: true, force: true });
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
if (previous && previous.hash !== certificate.hash) {
|
|
109
|
+
await updatePredictionTrust(paths.store, previous, { remove: true });
|
|
110
|
+
const previousDirectory = certificateDirectory(settings, paths);
|
|
111
|
+
if (previousDirectory !== paths.directory) fs.rmSync(previousDirectory, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
try { retirePredictionIntegration(opts); }
|
|
114
|
+
catch (error) { output(`Legacy prediction cleanup skipped: ${error.message}`); }
|
|
115
|
+
output("Native predictions on. Restart ccdx to launch Codex with the local bridge.");
|
|
116
|
+
output("Only chatgpt.com and ab.chatgpt.com use the App-only route. Their HTTPS traffic is decrypted locally; only prediction configuration is changed. No credentials are stored, no system certificate is installed, and client program files are unchanged.");
|
|
117
|
+
output("Existing official prediction configuration is preserved. The conversation's model generates predictions through your configured CCDX API.");
|
|
118
|
+
return next;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function startNativePrediction({ env = process.env, platform = process.platform,
|
|
122
|
+
warning = () => {}, state, ...options } = {}) {
|
|
123
|
+
const opts = { ...options, env };
|
|
124
|
+
const settings = readPredictionSettings(opts);
|
|
125
|
+
state?.configure(settings.enabled);
|
|
126
|
+
if (!settings.enabled) return null;
|
|
127
|
+
if (platform !== "darwin") throw new Error("Native predictions were skipped: unsupported platform");
|
|
128
|
+
const networkRoute = assertPredictionProxyCompatible({ env, systemProxy: systemProxy(options.exec) });
|
|
129
|
+
if (!predictionAppMatches(settings.app)) throw new Error("Codex App changed; run ccdx prediction on after quitting it to recheck compatibility");
|
|
130
|
+
const paths = predictionPaths(opts);
|
|
131
|
+
if (settings.store !== paths.store || !fs.existsSync(paths.store)) throw new Error("Prediction profile trust is unavailable; run ccdx prediction on after quitting Codex");
|
|
132
|
+
const certificate = readPredictionCertificate(certificateDirectory(settings, paths));
|
|
133
|
+
if (certificate.hash !== settings.certificateHash) throw new Error("Prediction certificate changed; setup must be checked again");
|
|
134
|
+
const [{ startPredictionTransport }, { applyNativePredictionOverlay, normalizeStatsigRequest }, { createProxyAgent }] = await Promise.all([
|
|
135
|
+
import("./native-prediction-transport.mjs"), import("./native-prediction-overlay.mjs"),
|
|
136
|
+
import("./proxy-tunnel.mjs"),
|
|
137
|
+
]);
|
|
138
|
+
// An in-memory switch is refreshed independently of request bodies and never
|
|
139
|
+
// touches the model path. A failed read turns off this optional overlay.
|
|
140
|
+
let enabled = true;
|
|
141
|
+
const timer = setInterval(() => {
|
|
142
|
+
try { enabled = readPredictionSettings(opts).enabled; state?.configure(enabled); }
|
|
143
|
+
catch { enabled = false; }
|
|
144
|
+
}, 1000);
|
|
145
|
+
timer.unref();
|
|
146
|
+
let transport;
|
|
147
|
+
try {
|
|
148
|
+
transport = await startPredictionTransport({ ...certificate, enabled: () => enabled,
|
|
149
|
+
networkRoute, agent: networkRoute.https ? createProxyAgent(networkRoute) : undefined,
|
|
150
|
+
onEvent: (event) => state?.event(event),
|
|
151
|
+
overlay: ({ host, pathname, status, body, enabled }) => {
|
|
152
|
+
const result = applyNativePredictionOverlay({ hostname: host, pathname, statusCode: status, body, enabled });
|
|
153
|
+
return result.changed ? { body: result.body, status: result.statusCode } : { official: result.official };
|
|
154
|
+
},
|
|
155
|
+
normalizeRequest: (parsed) => {
|
|
156
|
+
const result = normalizeStatsigRequest({ hostname: "ab.chatgpt.com", pathname: "/v1/initialize", body: JSON.stringify(parsed), enabled });
|
|
157
|
+
return result.changed ? JSON.parse(result.body) : parsed;
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
} catch (error) { clearInterval(timer); throw error; }
|
|
161
|
+
state?.running(networkRouteSnapshot(networkRoute));
|
|
162
|
+
warning("Native prediction bridge enabled; only the two configuration hosts use its App-only route.");
|
|
163
|
+
warning("If Codex is already running, quit it and run ccdx again to apply the route.");
|
|
164
|
+
return {
|
|
165
|
+
app: settings.app.app,
|
|
166
|
+
pacUrl: transport.pacUrl,
|
|
167
|
+
args: [`--proxy-pac-url=${transport.pacUrl}`],
|
|
168
|
+
async close() { clearInterval(timer); await transport.close(); state?.stopped(); },
|
|
169
|
+
};
|
|
170
|
+
}
|