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,102 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
|
|
3
|
+
const OFFICIAL_API = "https://chatgpt.com/backend-api";
|
|
4
|
+
|
|
5
|
+
function proxy(value, source) {
|
|
6
|
+
if (!value) return null;
|
|
7
|
+
let url;
|
|
8
|
+
try { url = new URL(value); } catch { throw new Error(`Invalid proxy address in ${source}`); }
|
|
9
|
+
if (!["http:", "https:", "socks5:", "socks5h:"].includes(url.protocol)
|
|
10
|
+
|| !url.hostname || !["", "/"].includes(url.pathname) || url.search || url.hash) {
|
|
11
|
+
throw new Error(`Unsupported proxy address in ${source}; expected HTTP, HTTPS or SOCKS5`);
|
|
12
|
+
}
|
|
13
|
+
return { url, source };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function variable(env, lower, upper) {
|
|
17
|
+
const key = String(env[lower] || "").trim() ? lower : upper;
|
|
18
|
+
return { value: String(env[key] || "").trim(), source: key };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveNetworkRoute({ env = process.env, systemProxy = "", checkApi = true } = {}) {
|
|
22
|
+
const api = String(env.CODEX_API_BASE_URL || "").trim().replace(/\/+$/, "");
|
|
23
|
+
if (checkApi && api && api !== OFFICIAL_API) {
|
|
24
|
+
throw new Error("Native prediction setup skipped: CODEX_API_BASE_URL selects a custom API route; it was preserved");
|
|
25
|
+
}
|
|
26
|
+
const settings = Object.fromEntries([...systemProxy.matchAll(/^\s*([A-Za-z]+)\s*:\s*(.+?)\s*$/gm)].map((m) => [m[1], m[2]]));
|
|
27
|
+
for (const key of ["ProxyAutoConfigEnable", "ProxyAutoDiscoveryEnable"]) {
|
|
28
|
+
if (settings[key] === "1") throw new Error(`Native prediction setup skipped: system ${key === "ProxyAutoConfigEnable" ? "PAC" : "WPAD"} (${key}) needs dynamic routing; its rules were preserved`);
|
|
29
|
+
}
|
|
30
|
+
const system = (prefix, scheme) => {
|
|
31
|
+
if (settings[`${prefix}Enable`] !== "1") return null;
|
|
32
|
+
const host = settings[`${prefix}Proxy`], port = settings[`${prefix}Port`];
|
|
33
|
+
if (!host || !/^\d+$/.test(port || "") || Number(port) < 1 || Number(port) > 65535) {
|
|
34
|
+
throw new Error(`Invalid system ${prefix} proxy settings`);
|
|
35
|
+
}
|
|
36
|
+
return proxy(`${scheme}://${net.isIP(host) === 6 ? `[${host}]` : host}:${port}`, `system ${prefix}`);
|
|
37
|
+
};
|
|
38
|
+
const httpEnv = variable(env, "http_proxy", "HTTP_PROXY"), httpsEnv = variable(env, "https_proxy", "HTTPS_PROXY");
|
|
39
|
+
const allEnv = variable(env, "all_proxy", "ALL_PROXY");
|
|
40
|
+
const all = proxy(allEnv.value, allEnv.source);
|
|
41
|
+
const systemSocks = system("SOCKS", "socks5");
|
|
42
|
+
const systemHttp = system("HTTP", "http"), systemHttps = system("HTTPS", "http");
|
|
43
|
+
const http = proxy(httpEnv.value, httpEnv.source) || all || systemHttp || systemSocks;
|
|
44
|
+
const https = proxy(httpsEnv.value, httpsEnv.source) || all || systemHttps || systemSocks;
|
|
45
|
+
const noProxy = variable(env, "no_proxy", "NO_PROXY").value;
|
|
46
|
+
const exceptions = /ExceptionsList\s*:\s*<array>\s*\{([^}]+)\}/.exec(systemProxy)?.[1] || "";
|
|
47
|
+
const bypass = [...new Set([...noProxy.split(","), ...[...exceptions.matchAll(/\d+\s*:\s*([^\n]+)/g)].map((m) => m[1])]
|
|
48
|
+
.map((s) => s.trim().toLowerCase()).filter(Boolean))];
|
|
49
|
+
if (bypass.some((rule) => rule.includes("/"))) throw new Error("Unsupported proxy bypass CIDR rule in NO_PROXY or system exceptions; existing routes were preserved");
|
|
50
|
+
return { http, https, bypass, excludeSimple: settings.ExcludeSimpleHostnames === "1" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function bypassNetworkProxy(hostname, port, plan) {
|
|
54
|
+
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
55
|
+
if (["localhost", "127.0.0.1", "::1"].includes(host) || (plan.excludeSimple && !host.includes("."))) return true;
|
|
56
|
+
return plan.bypass.some((raw) => {
|
|
57
|
+
if (raw === "*") return true;
|
|
58
|
+
const match = /^(.*):(\d+)$/.exec(raw);
|
|
59
|
+
const rule = match ? match[1] : raw;
|
|
60
|
+
if (match && Number(match[2]) !== Number(port)) return false;
|
|
61
|
+
const suffix = rule.replace(/^\*?\./, "");
|
|
62
|
+
if (rule.includes("*")) {
|
|
63
|
+
const expression = rule.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*");
|
|
64
|
+
return new RegExp(`^${expression}$`).test(host);
|
|
65
|
+
}
|
|
66
|
+
return host === suffix || host.endsWith(`.${suffix}`);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function networkProxyFor(url, plan) {
|
|
71
|
+
if (bypassNetworkProxy(url.hostname, url.port || (url.protocol === "https:" ? 443 : 80), plan)) return null;
|
|
72
|
+
return url.protocol === "https:" ? plan.https : plan.http;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function networkRouteSnapshot(plan) {
|
|
76
|
+
const route = plan.https;
|
|
77
|
+
return { source: route?.source || "direct", type: route?.url.protocol.replace(":", "") || "direct",
|
|
78
|
+
bypass_rules: plan.bypass.length, proxy_credentials: Boolean(route?.url.username || route?.url.password) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function pacProxy(route) {
|
|
82
|
+
if (!route) return "DIRECT";
|
|
83
|
+
const type = route.url.protocol.startsWith("socks5") ? "SOCKS5" : route.url.protocol === "https:" ? "HTTPS" : "PROXY";
|
|
84
|
+
const port = route.url.port || (type === "HTTPS" ? 443 : type === "SOCKS5" ? 1080 : 80);
|
|
85
|
+
return `${type} ${route.url.hostname}:${port}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function networkPredictionPac(port, plan) {
|
|
89
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid prediction proxy port");
|
|
90
|
+
// Embed only routing rules, never proxy passwords or API credentials.
|
|
91
|
+
return `var ccdxRoute=${JSON.stringify({ http: pacProxy(plan.http), https: pacProxy(plan.https), bypass: plan.bypass, excludeSimple: plan.excludeSimple })};
|
|
92
|
+
function FindProxyForURL(url,host) {
|
|
93
|
+
var secure=url.indexOf('https:')===0, p=secure?443:80;
|
|
94
|
+
var authority=url.split('/')[2]||'', explicit=/:([0-9]+)$/.exec(authority);if(explicit)p=Number(explicit[1]);
|
|
95
|
+
var skip=host==='localhost'||host==='127.0.0.1'||host==='::1'||(ccdxRoute.excludeSimple&&host.indexOf('.')<0);
|
|
96
|
+
for(var i=0;i<ccdxRoute.bypass.length&&!skip;i++){var raw=ccdxRoute.bypass[i],m=/^(.*):([0-9]+)$/.exec(raw),r=m?m[1]:raw;if(m&&Number(m[2])!==p)continue;
|
|
97
|
+
var suffix=r.indexOf('*.')===0?r.slice(2):r[0]==='.'?r.slice(1):r;skip=r==='*'||(r.indexOf('*')>=0?shExpMatch(host,r):host===suffix||dnsDomainIs(host,'.'+suffix));}
|
|
98
|
+
var original=skip?'DIRECT':secure?ccdxRoute.https:ccdxRoute.http;
|
|
99
|
+
if(secure&&(host==='chatgpt.com'||host==='ab.chatgpt.com'))return 'PROXY 127.0.0.1:${port}; '+original;
|
|
100
|
+
return original;
|
|
101
|
+
}\n`;
|
|
102
|
+
}
|
package/src/observability.mjs
CHANGED
|
@@ -159,6 +159,7 @@ export function runtimeStatusPayload({
|
|
|
159
159
|
codexClient,
|
|
160
160
|
codexModelRegistry,
|
|
161
161
|
codexModelCatalog,
|
|
162
|
+
clientIntegrations,
|
|
162
163
|
} = {}) {
|
|
163
164
|
const memory = process.memoryUsage();
|
|
164
165
|
const codexRuntime = safeClientRuntimeStatus(codexClient);
|
|
@@ -196,6 +197,7 @@ export function runtimeStatusPayload({
|
|
|
196
197
|
response_history: history,
|
|
197
198
|
image_optimization: imageOptimizationStats(),
|
|
198
199
|
image_generation: imageGeneration || null,
|
|
200
|
+
client_integrations: clientIntegrations || null,
|
|
199
201
|
logging: { usage: usageLoggingStats(), debug: debugLoggingStats() },
|
|
200
202
|
image_history_pressure: imagePressure?.snapshot?.() || null,
|
|
201
203
|
response_failures: responseFailures?.snapshot?.() || null,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { redactDiagnosticText } from "./diagnostic-text.mjs";
|
|
2
|
+
|
|
3
|
+
export function operationError(code, message, { status = null, retryAt = null } = {}) {
|
|
4
|
+
return Object.assign(new Error(message), { code, status, retryAt });
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function operationErrorDetails(error) {
|
|
8
|
+
const code = String(error?.code || error?.cause?.code || "unknown");
|
|
9
|
+
let source = "unknown";
|
|
10
|
+
if (/proxy/i.test(code)) source = "proxy";
|
|
11
|
+
else if (/cert|tls|ssl/i.test(code)) source = "tls";
|
|
12
|
+
else if (/timeout|abort/i.test(code) || error?.name === "AbortError" || error?.name === "TimeoutError") source = "timeout";
|
|
13
|
+
else if (/limit|429/.test(code)) source = "rate_limit";
|
|
14
|
+
else if (/config|format|route|redirect/.test(code)) source = "configuration";
|
|
15
|
+
else if (/HTTP|http/.test(code)) source = "upstream";
|
|
16
|
+
else if (/ENOTFOUND|ECONN|EAI_/.test(code)) source = "network";
|
|
17
|
+
return { code: /^[A-Za-z0-9_-]{1,80}$/.test(code) ? code : "unknown", source,
|
|
18
|
+
message: redactDiagnosticText(String(error?.message || "Operation failed")).slice(0, 400),
|
|
19
|
+
status: Number.isInteger(error?.status) ? error.status : null };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Error classifiers consume decoded string fields; responses retain their
|
|
23
|
+
// original bytes. Only escaped, bounded JSON errors need this extra work.
|
|
24
|
+
export function errorEvidenceText(value) {
|
|
25
|
+
const text = String(value || "");
|
|
26
|
+
if (!text.includes("\\u") || text.length > 16 * 1024) return text;
|
|
27
|
+
try {
|
|
28
|
+
const strings = [];
|
|
29
|
+
const visit = (node, depth) => {
|
|
30
|
+
if (depth > 4 || strings.length >= 32) return;
|
|
31
|
+
if (typeof node === "string") strings.push(node);
|
|
32
|
+
else if (node && typeof node === "object") for (const child of Object.values(node)) visit(child, depth + 1);
|
|
33
|
+
};
|
|
34
|
+
visit(JSON.parse(text), 0);
|
|
35
|
+
return `${text}\n${strings.join("\n")}`;
|
|
36
|
+
} catch { return text; }
|
|
37
|
+
}
|
package/src/package-update.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createInterface } from "node:readline/promises";
|
|
3
|
-
import {
|
|
3
|
+
import { lookupLatestRelease, githubReleaseVersion } from "./version.mjs";
|
|
4
4
|
|
|
5
5
|
const PACKAGE_NAME = "codex-copilot-dx";
|
|
6
6
|
const UPDATE_SPECS = {
|
|
@@ -95,8 +95,7 @@ export async function runPackageUpdateCommand({
|
|
|
95
95
|
let release;
|
|
96
96
|
if (selectedSource === "github") {
|
|
97
97
|
output.write("Checking the latest stable GitHub release...\n");
|
|
98
|
-
release = await
|
|
99
|
-
if (!release) throw new Error("Could not read the latest stable GitHub release. No update was installed.");
|
|
98
|
+
release = await lookupLatestRelease({ fetchImpl, timeoutMs: 10000 });
|
|
100
99
|
}
|
|
101
100
|
const update = globalUpdateCommand(selectedSource, { platform, releaseTag: release?.tagName });
|
|
102
101
|
const sourceLabel = selectedSource === "npm" ? "npm registry" : `GitHub release ${release.tagName}`;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
import { atomicWriteFileIfChangedSync, atomicWriteFilePairSync } from "./atomic-file.mjs";
|
|
7
|
+
import { codexTomlStatements, parseCodexToml, validateManagedConfigEdit } from "./config-toml.mjs";
|
|
8
|
+
import { userSettingsPath } from "./user-settings.mjs";
|
|
9
|
+
import { codexPaths } from "./codex-paths.mjs";
|
|
10
|
+
import { withFileLockSync } from "./lock.mjs";
|
|
11
|
+
|
|
12
|
+
const SERVER_PATH = ["mcp_servers", "ccdx_suggestion"];
|
|
13
|
+
const same = (left, right) => isDeepStrictEqual(left, right);
|
|
14
|
+
|
|
15
|
+
export function computeRetiredPredictionConfig(content, filePath) {
|
|
16
|
+
const before = parseCodexToml(content);
|
|
17
|
+
const expected = {
|
|
18
|
+
command: process.execPath,
|
|
19
|
+
args: [fileURLToPath(new URL("./suggestion-mcp.mjs", import.meta.url))],
|
|
20
|
+
env: { CCDX_SUGGESTION_CONFIG: filePath },
|
|
21
|
+
tool_timeout_sec: 40n,
|
|
22
|
+
};
|
|
23
|
+
const existing = before.mcp_servers?.ccdx_suggestion;
|
|
24
|
+
const owned = existing && typeof existing.enabled === "boolean"
|
|
25
|
+
&& same({ ...existing, enabled: undefined }, { ...expected, enabled: undefined });
|
|
26
|
+
const statements = codexTomlStatements(content);
|
|
27
|
+
const declarations = statements.filter(item => ["table", "value"].includes(item.kind)
|
|
28
|
+
&& same(item.keys.slice(0, 2), SERVER_PATH));
|
|
29
|
+
const ranges = owned ? declarations.map(item => {
|
|
30
|
+
const start = content.lastIndexOf("\n", item.start - 1) + 1;
|
|
31
|
+
const end = content.indexOf("\n", item.end);
|
|
32
|
+
return [start, end < 0 ? content.length : end + 1];
|
|
33
|
+
}) : [];
|
|
34
|
+
// Only remove the exact block emitted by 0.9.23. Modified handlers and
|
|
35
|
+
// Codex's own trust records remain untouched.
|
|
36
|
+
const quote = value => `'${value.replaceAll("'", "'\\''")}'`;
|
|
37
|
+
const command = `CCDX_SUGGESTION_CONFIG=${quote(filePath)} ${quote(process.execPath)} ${quote(fileURLToPath(new URL("./suggestion-auto.mjs", import.meta.url)))}`;
|
|
38
|
+
const block = `# ccdx:suggestion-auto:start\n[[hooks.Stop]]\n[[hooks.Stop.hooks]]\ntype = "command"\ncommand = ${JSON.stringify(command)}\nasync = true\ntimeout = 35\n# ccdx:suggestion-auto:end\n`;
|
|
39
|
+
const hookStart = content.indexOf(block);
|
|
40
|
+
const hookOwned = hookStart >= 0 && content.indexOf(block, hookStart + block.length) < 0
|
|
41
|
+
&& statements.some(item => item.kind === "comment" && item.start === hookStart && item.source === "# ccdx:suggestion-auto:start");
|
|
42
|
+
if (hookOwned) ranges.push([hookStart, hookStart + block.length]);
|
|
43
|
+
let updated = content;
|
|
44
|
+
for (const [start, end] of ranges.sort((a, b) => b[0] - a[0])) updated = updated.slice(0, start) + updated.slice(end);
|
|
45
|
+
validateManagedConfigEdit(before, updated, [SERVER_PATH, ["hooks", "Stop"]]);
|
|
46
|
+
const after = parseCodexToml(updated);
|
|
47
|
+
const remaining = [...(before.hooks?.Stop || [])];
|
|
48
|
+
if (hookOwned) {
|
|
49
|
+
const index = remaining.findIndex(group => same(group, { hooks: [{ type: "command", command, async: true, timeout: 35n }] }));
|
|
50
|
+
if (index < 0) throw new Error("Retired prediction hook could not be safely removed");
|
|
51
|
+
remaining.splice(index, 1);
|
|
52
|
+
}
|
|
53
|
+
if (!same(after.hooks?.Stop || [], remaining)) throw new Error("Unrelated hooks changed; prediction cleanup was skipped");
|
|
54
|
+
return { content: updated, changed: updated !== content, preserved: Boolean(existing && !owned) || (content.includes("# ccdx:suggestion-auto:start") && !hookOwned) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function retirePredictionIntegration(options = {}) {
|
|
58
|
+
if (options.dryRun) return retirePredictionIntegrationUnlocked(options);
|
|
59
|
+
const { config } = codexPaths(options);
|
|
60
|
+
// No registration/settings means no lock directory or other write on startup.
|
|
61
|
+
const env = options.env || process.env;
|
|
62
|
+
const settings = path.resolve(env.CCDX_SUGGESTION_CONFIG || path.join(path.dirname(userSettingsPath(options)), "suggestion.json"));
|
|
63
|
+
if (!fs.existsSync(config) && !fs.existsSync(settings)) return { changed: false, preserved: false };
|
|
64
|
+
return withFileLockSync(`${config}.ccdx.lock`, () => retirePredictionIntegrationUnlocked(options));
|
|
65
|
+
}
|
|
66
|
+
function retirePredictionIntegrationUnlocked({ env = process.env, home = os.homedir(), dryRun = false } = {}) {
|
|
67
|
+
const settings = path.resolve(env.CCDX_SUGGESTION_CONFIG || path.join(path.dirname(userSettingsPath({ env, home })), "suggestion.json"));
|
|
68
|
+
const codex = codexPaths({ env, home }).config;
|
|
69
|
+
let content;
|
|
70
|
+
try { content = new TextDecoder("utf-8", { fatal: true }).decode(fs.readFileSync(codex)); }
|
|
71
|
+
catch (error) { if (error.code !== "ENOENT") throw new Error("Cannot safely read prediction configuration"); }
|
|
72
|
+
let serialized;
|
|
73
|
+
try {
|
|
74
|
+
const preference = JSON.parse(fs.readFileSync(settings, "utf8"));
|
|
75
|
+
if (!preference || typeof preference !== "object" || Array.isArray(preference)
|
|
76
|
+
|| typeof preference.enabled !== "boolean" || typeof preference.model !== "string") throw new Error();
|
|
77
|
+
if (preference.enabled || preference.automatic) serialized = JSON.stringify({ ...preference, enabled: false, automatic: false }, null, 2) + "\n";
|
|
78
|
+
} catch (error) { if (error.code !== "ENOENT") throw new Error("Prediction settings are invalid; cleanup was skipped"); }
|
|
79
|
+
const result = content?.includes("ccdx_suggestion") || content?.includes("ccdx:suggestion-auto:start")
|
|
80
|
+
? computeRetiredPredictionConfig(content, settings) : { content, changed: false, preserved: false };
|
|
81
|
+
if (dryRun) return { changed: result.changed || Boolean(serialized), preserved: result.preserved };
|
|
82
|
+
if (result.changed && serialized) atomicWriteFilePairSync(codex, result.content, settings, serialized);
|
|
83
|
+
else if (result.changed) atomicWriteFileIfChangedSync(codex, result.content);
|
|
84
|
+
else if (serialized) atomicWriteFileIfChangedSync(settings, serialized);
|
|
85
|
+
return { changed: result.changed || Boolean(serialized), preserved: result.preserved };
|
|
86
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import tls from "node:tls";
|
|
3
|
+
import https from "node:https";
|
|
4
|
+
import { networkProxyFor } from "./network-route.mjs";
|
|
5
|
+
|
|
6
|
+
// A bounded read preserves bytes following the handshake for the TLS parser.
|
|
7
|
+
function readSocket(socket, ready, maxBytes = 16 * 1024) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
let chunks = [], size = 0;
|
|
10
|
+
const cleanup = () => { socket.off("data", data); socket.off("error", error); socket.off("close", closed); };
|
|
11
|
+
const error = (e) => { cleanup(); reject(e); };
|
|
12
|
+
const closed = () => error(new Error("Proxy closed the connection"));
|
|
13
|
+
const data = (chunk) => {
|
|
14
|
+
chunks.push(chunk); size += chunk.length;
|
|
15
|
+
if (size > maxBytes) { error(new Error("Proxy handshake exceeds its limit")); return; }
|
|
16
|
+
const bytes = Buffer.concat(chunks, size), end = ready(bytes);
|
|
17
|
+
if (end !== null) {
|
|
18
|
+
socket.pause(); cleanup();
|
|
19
|
+
if (bytes.length > end) socket.unshift(bytes.subarray(end));
|
|
20
|
+
resolve(bytes.subarray(0, end));
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
socket.on("data", data); socket.once("error", error); socket.once("close", closed); socket.resume();
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function connectProxy(target, route, timeoutMs, pending) {
|
|
28
|
+
const proxy = route.url;
|
|
29
|
+
const proxyHost = proxy.hostname.replace(/^\[|\]$/g, "");
|
|
30
|
+
const socket = proxy.protocol === "https:"
|
|
31
|
+
? tls.connect({ host: proxyHost, port: Number(proxy.port || 443), servername: net.isIP(proxyHost) ? undefined : proxyHost, rejectUnauthorized: true })
|
|
32
|
+
: net.connect({ host: proxyHost, port: Number(proxy.port || (proxy.protocol.startsWith("socks") ? 1080 : 80)) });
|
|
33
|
+
pending.add(socket);
|
|
34
|
+
const timer = setTimeout(() => socket.destroy(new Error("Proxy connection timed out")), timeoutMs);
|
|
35
|
+
socket.once("close", () => pending.delete(socket));
|
|
36
|
+
try {
|
|
37
|
+
await new Promise((resolve, reject) => {
|
|
38
|
+
const event = proxy.protocol === "https:" ? "secureConnect" : "connect";
|
|
39
|
+
const failed = (error) => { socket.off(event, connected); reject(error); };
|
|
40
|
+
const connected = () => { socket.off("error", failed); resolve(); };
|
|
41
|
+
socket.once(event, connected); socket.once("error", failed);
|
|
42
|
+
});
|
|
43
|
+
if (proxy.protocol.startsWith("socks5")) {
|
|
44
|
+
const user = Buffer.from(decodeURIComponent(proxy.username)), password = Buffer.from(decodeURIComponent(proxy.password));
|
|
45
|
+
if (user.length > 255 || password.length > 255) throw new Error("SOCKS5 proxy credentials are too long");
|
|
46
|
+
const greeting = readSocket(socket, b => b.length >= 2 ? 2 : null);
|
|
47
|
+
socket.write(user.length ? Buffer.from([5, 2, 0, 2]) : Buffer.from([5, 1, 0]));
|
|
48
|
+
const choice = await greeting;
|
|
49
|
+
if (choice[0] !== 5 || ![0, 2].includes(choice[1]) || (choice[1] === 2 && !user.length)) throw new Error("SOCKS5 proxy authentication is unsupported");
|
|
50
|
+
if (choice[1] === 2) {
|
|
51
|
+
const auth = readSocket(socket, b => b.length >= 2 ? 2 : null);
|
|
52
|
+
socket.write(Buffer.concat([Buffer.from([1, user.length]), user, Buffer.from([password.length]), password]));
|
|
53
|
+
const result = await auth; if (result[0] !== 1 || result[1] !== 0) throw new Error("SOCKS5 proxy authentication failed");
|
|
54
|
+
}
|
|
55
|
+
const host = Buffer.from(target.hostname);
|
|
56
|
+
if (host.length > 255) throw new Error("Proxy target hostname is too long");
|
|
57
|
+
const response = readSocket(socket, b => {
|
|
58
|
+
if (b.length < 5) return null;
|
|
59
|
+
const size = b[3] === 1 ? 10 : b[3] === 4 ? 22 : b[3] === 3 ? 7 + b[4] : 5;
|
|
60
|
+
return b.length >= size ? size : null;
|
|
61
|
+
});
|
|
62
|
+
socket.write(Buffer.concat([Buffer.from([5, 1, 0, 3, host.length]), host, Buffer.from([1, 187])]));
|
|
63
|
+
const result = await response;
|
|
64
|
+
if (result[0] !== 5 || result[1] !== 0 || ![1, 3, 4].includes(result[3])) throw new Error("SOCKS5 proxy rejected the connection");
|
|
65
|
+
} else {
|
|
66
|
+
const response = readSocket(socket, b => { const end = b.indexOf("\r\n\r\n"); return end < 0 ? null : end + 4; });
|
|
67
|
+
const credentials = proxy.username || proxy.password
|
|
68
|
+
? `Proxy-Authorization: Basic ${Buffer.from(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`).toString("base64")}\r\n` : "";
|
|
69
|
+
socket.write(`CONNECT ${target.hostname}:443 HTTP/1.1\r\nHost: ${target.hostname}:443\r\n${credentials}\r\n`);
|
|
70
|
+
const headers = (await response).toString("latin1");
|
|
71
|
+
const status = /^HTTP\/1\.[01] (\d{3})/.exec(headers)?.[1];
|
|
72
|
+
if (status !== "200") throw new Error(`HTTP proxy rejected CONNECT (${status || "invalid response"})`);
|
|
73
|
+
}
|
|
74
|
+
clearTimeout(timer); pending.delete(socket);
|
|
75
|
+
return socket;
|
|
76
|
+
} catch (error) { clearTimeout(timer); socket.destroy(); throw error; }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createProxyAgent(plan, { timeoutMs = 30_000 } = {}) {
|
|
80
|
+
const pending = new Set();
|
|
81
|
+
let closed = false;
|
|
82
|
+
const agent = new https.Agent({ keepAlive: true, maxSockets: 32, maxFreeSockets: 4 });
|
|
83
|
+
const direct = agent.createConnection.bind(agent);
|
|
84
|
+
agent.createConnection = (options, callback) => {
|
|
85
|
+
if (closed) { queueMicrotask(() => callback(new Error("Proxy agent is closed"))); return undefined; }
|
|
86
|
+
const target = new URL(`https://${options.host}`), route = networkProxyFor(target, plan);
|
|
87
|
+
if (!route) return direct(options, callback);
|
|
88
|
+
void connectProxy(target, route, timeoutMs, pending).then(socket => {
|
|
89
|
+
if (closed) { socket.destroy(); callback(new Error("Proxy agent is closed")); return; }
|
|
90
|
+
const secure = tls.connect({ ...options, socket, rejectUnauthorized: true });
|
|
91
|
+
pending.add(secure);
|
|
92
|
+
const timer = setTimeout(() => secure.destroy(new Error("Proxy target TLS timed out")), timeoutMs);
|
|
93
|
+
const fail = (error) => { clearTimeout(timer); pending.delete(secure); secure.off("secureConnect", done); callback(error); };
|
|
94
|
+
const done = () => { clearTimeout(timer); pending.delete(secure); secure.off("error", fail); callback(null, secure); };
|
|
95
|
+
secure.once("error", fail); secure.once("secureConnect", done); secure.resume();
|
|
96
|
+
}, callback);
|
|
97
|
+
return undefined;
|
|
98
|
+
};
|
|
99
|
+
const destroy = agent.destroy.bind(agent);
|
|
100
|
+
agent.destroy = () => { closed = true; for (const socket of pending) socket.destroy(new Error("Proxy agent closed")); pending.clear(); destroy(); };
|
|
101
|
+
return agent;
|
|
102
|
+
}
|
|
@@ -309,7 +309,8 @@ export function prepareResponsesRequest(reqBody, {
|
|
|
309
309
|
assertActive?.();
|
|
310
310
|
const body = mutate ? reqBody : cloneJson(reqBody);
|
|
311
311
|
assertActive?.();
|
|
312
|
-
|
|
312
|
+
// body is already an owned clone unless the caller explicitly allows mutation.
|
|
313
|
+
const currentInputItems = responsesInputItems(body.input, { assertActive, clone: false });
|
|
313
314
|
const currentInputRefs = new Set(
|
|
314
315
|
currentInputItems.filter((item) => item && typeof item === "object"),
|
|
315
316
|
);
|
|
@@ -359,7 +360,9 @@ export function prepareResponsesRequest(reqBody, {
|
|
|
359
360
|
// concrete Copilot RoutePlan.
|
|
360
361
|
if (copilotBoundary) applyCopilotResponsesRequestPolicies(body);
|
|
361
362
|
stripInternalResponsesInputFields(body.input, { assertActive });
|
|
362
|
-
|
|
363
|
+
// The first pass already covers shared current items. Only an independent
|
|
364
|
+
// pre-adaptation snapshot needs another pass.
|
|
365
|
+
if (historyInputItems !== currentInputItems) stripInternalResponsesInputFields(historyInputItems, { assertActive });
|
|
363
366
|
assertActive?.();
|
|
364
367
|
const retainedCurrentInputStart = body.input.findIndex((item) => currentInputRefs.has(item));
|
|
365
368
|
|
package/src/suggestion-auto.mjs
CHANGED
|
@@ -1,107 +1,7 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
4
1
|
import { pathToFileURL } from "node:url";
|
|
5
|
-
import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
|
|
6
|
-
import { readSuggestionConfig, suggestionConfigPath } from "./suggestion-config.mjs";
|
|
7
|
-
import { readSuggestionContext } from "./suggestion-context.mjs";
|
|
8
|
-
import { generateSuggestion } from "./suggestion-provider.mjs";
|
|
9
2
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
function keyFile(filePath, threadId) {
|
|
14
|
-
if (!UUID.test(threadId)) throw new Error("Invalid suggestion conversation ID");
|
|
15
|
-
return path.join(storeDirectory(filePath), `${createHash("sha256").update(threadId).digest("hex")}.json`);
|
|
16
|
-
}
|
|
17
|
-
function readRecord(file) {
|
|
18
|
-
try {
|
|
19
|
-
const stat=fs.lstatSync(file);
|
|
20
|
-
if (!stat.isFile() || stat.size > 8192) return null;
|
|
21
|
-
const record = JSON.parse(fs.readFileSync(file,"utf8"));
|
|
22
|
-
return record && typeof record === "object" && !Array.isArray(record) ? record : null;
|
|
23
|
-
} catch { return null; }
|
|
24
|
-
}
|
|
25
|
-
export function readAutomaticSuggestion(filePath, threadId) { return readRecord(keyFile(filePath, threadId)); }
|
|
26
|
-
function save(filePath, threadId, record) { atomicWriteFileIfChangedSync(keyFile(filePath,threadId),JSON.stringify(record)+"\n"); }
|
|
27
|
-
function cleanup(directory, now) {
|
|
28
|
-
const files = fs.readdirSync(directory).filter(name=>/^[a-f0-9]{64}\.json$/.test(name));
|
|
29
|
-
const retained = [];
|
|
30
|
-
for (const name of files) {
|
|
31
|
-
const file=path.join(directory,name),record=readRecord(file);
|
|
32
|
-
if (!record || !Number.isFinite(record.at) || now-record.at>RETENTION_MS) fs.unlinkSync(file);
|
|
33
|
-
else retained.push({file,at:record.at});
|
|
34
|
-
}
|
|
35
|
-
retained.sort((a,b)=>b.at-a.at);
|
|
36
|
-
for(const record of retained.slice(31))fs.unlinkSync(record.file);
|
|
37
|
-
}
|
|
38
|
-
export function automaticSuggestionStatus(filePath, now = Date.now()) {
|
|
39
|
-
try {
|
|
40
|
-
const directory=storeDirectory(filePath);
|
|
41
|
-
const records=fs.readdirSync(directory).filter(name=>/^[a-f0-9]{64}\.json$/.test(name)).slice(0,64)
|
|
42
|
-
.map(name=>readRecord(path.join(directory,name))).filter(record=>record && Number.isFinite(record.at)&&now-record.at<=RETENTION_MS);
|
|
43
|
-
const recent=records.sort((a,b)=>b.at-a.at)[0];
|
|
44
|
-
return recent ? { state:["generating","ready","empty","failed","cancelled"].includes(recent.state)?recent.state:"unknown",
|
|
45
|
-
at:new Date(recent.at).toISOString(),reason:["context_unavailable","model_failed","settings_changed","turn_changed","timeout"].includes(recent.reason)?recent.reason:null } : {state:"idle",at:null,reason:null};
|
|
46
|
-
} catch { return {state:"idle",at:null,reason:null}; }
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export async function runAutomaticSuggestion(event, {
|
|
50
|
-
filePath = suggestionConfigPath(), readContext = readSuggestionContext, generate = generateSuggestion,
|
|
51
|
-
now = Date.now, timeoutMs = 30_000,
|
|
52
|
-
} = {}) {
|
|
53
|
-
const config=readSuggestionConfig({filePath});
|
|
54
|
-
if(!config.enabled || !config.automatic || event?.hook_event_name!=="Stop" || event.stop_hook_active===true
|
|
55
|
-
|| !UUID.test(event.session_id||"") || !UUID.test(event.turn_id||"")) return {skipped:true};
|
|
56
|
-
const threadId=event.session_id,turnId=event.turn_id,directory=storeDirectory(filePath);
|
|
57
|
-
fs.mkdirSync(directory,{recursive:true,mode:0o700});
|
|
58
|
-
if(!fs.lstatSync(directory).isDirectory())return {skipped:true};
|
|
59
|
-
cleanup(directory,now());
|
|
60
|
-
const prior=readAutomaticSuggestion(filePath,threadId);
|
|
61
|
-
if(prior?.turnId===turnId)return {skipped:true};
|
|
62
|
-
// One local slot bounds independent asynchronous hook processes. Only owned
|
|
63
|
-
// lock files are reclaimed after their 30-second operation deadline.
|
|
64
|
-
let lock;
|
|
65
|
-
for(let slot=0;slot<1;slot++){
|
|
66
|
-
const file=path.join(directory,`slot-${slot}.lock`);
|
|
67
|
-
try{if(now()-fs.statSync(file).mtimeMs>60_000)fs.unlinkSync(file)}catch(error){if(error.code!=="ENOENT")return {skipped:true}}
|
|
68
|
-
try{const fd=fs.openSync(file,"wx",0o600);fs.closeSync(fd);lock=file;break}catch(error){if(error.code!=="EEXIST")return {skipped:true}}
|
|
69
|
-
}
|
|
70
|
-
if(!lock)return {skipped:true,busy:true};
|
|
71
|
-
const abort=new AbortController(),timer=setTimeout(()=>abort.abort(),timeoutMs);
|
|
72
|
-
let stage="context_unavailable";
|
|
73
|
-
try {
|
|
74
|
-
// The slot acquisition and marker commit are synchronous within this process.
|
|
75
|
-
if(readAutomaticSuggestion(filePath,threadId)?.turnId===turnId)return {skipped:true};
|
|
76
|
-
save(filePath,threadId,{state:"generating",at:now(),turnId,model:config.model});
|
|
77
|
-
const page=await readContext(threadId,config,{signal:abort.signal,includePage:true});
|
|
78
|
-
if(page.latestTurnId!==turnId){save(filePath,threadId,{state:"cancelled",at:now(),turnId,model:config.model,reason:"turn_changed"});return {skipped:true};}
|
|
79
|
-
const current=readSuggestionConfig({filePath});
|
|
80
|
-
if(!current.enabled||!current.automatic||current.model!==config.model||current.adapter_url!==config.adapter_url){stage="settings_changed";throw new Error();}
|
|
81
|
-
stage="model_failed";
|
|
82
|
-
const suggestion=await generate(page.messages,config,{signal:abort.signal});
|
|
83
|
-
abort.signal.throwIfAborted();
|
|
84
|
-
const latest=readSuggestionConfig({filePath});
|
|
85
|
-
if(!latest.enabled||!latest.automatic||latest.model!==config.model||latest.adapter_url!==config.adapter_url){stage="settings_changed";throw new Error();}
|
|
86
|
-
stage="context_unavailable";
|
|
87
|
-
const finalPage=await readContext(threadId,config,{signal:abort.signal,includePage:true});
|
|
88
|
-
if(finalPage.latestTurnId!==turnId){stage="turn_changed";throw new Error();}
|
|
89
|
-
const result={state:suggestion?"ready":"empty",at:now(),turnId,model:config.model,suggestion,candidate_id:suggestion?randomUUID():null};
|
|
90
|
-
save(filePath,threadId,result);return result;
|
|
91
|
-
} catch {
|
|
92
|
-
const result={state:abort.signal.aborted?"cancelled":"failed",at:now(),turnId,model:config.model,reason:abort.signal.aborted?"timeout":stage};
|
|
93
|
-
// Don't overwrite a newer hook's marker.
|
|
94
|
-
if(readAutomaticSuggestion(filePath,threadId)?.turnId===turnId)save(filePath,threadId,result);
|
|
95
|
-
return result;
|
|
96
|
-
} finally {clearTimeout(timer);try{fs.unlinkSync(lock)}catch{}}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async function main(){
|
|
100
|
-
let chunks=[],bytes=0;
|
|
101
|
-
for await(const chunk of process.stdin){bytes+=chunk.length;if(bytes>64*1024)return;chunks.push(chunk)}
|
|
102
|
-
let event;try{event=JSON.parse(Buffer.concat(chunks).toString("utf8"))}catch{return}
|
|
103
|
-
// Informational hook: no continuation, warnings, approval or user messages.
|
|
104
|
-
await runAutomaticSuggestion(event);
|
|
3
|
+
// Older configurations may still invoke this retired hook after an upgrade.
|
|
4
|
+
// Do not consume stdin, read session data, or request another model response.
|
|
5
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
105
6
|
process.stdout.write("{}\n");
|
|
106
7
|
}
|
|
107
|
-
if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href)main().catch(()=>{process.stdout.write("{}\n")});
|