@bitkyc08/opencodex 2.7.9-preview.20260712 → 2.7.9-preview.20260712.2
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 +3 -1
- package/gui/dist/assets/index-SnN_1Qr9.js +40 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/cursor/transport-retry.ts +5 -3
- package/src/adapters/google-errors.ts +9 -19
- package/src/adapters/google-http.ts +29 -66
- package/src/adapters/kiro-errors.ts +10 -23
- package/src/adapters/kiro-retry.ts +26 -58
- package/src/adapters/upstream-http-error.ts +48 -0
- package/src/claude/gateway-cache.ts +3 -3
- package/src/claude/outbound.ts +40 -35
- package/src/cli/claude.ts +36 -4
- package/src/config.ts +54 -3
- package/src/lib/destination-policy.ts +167 -0
- package/src/lib/injection-debug-log.ts +34 -0
- package/src/lib/upstream-retry.ts +53 -3
- package/src/lib/windows-secret-acl.ts +173 -0
- package/src/oauth/store.ts +1 -0
- package/src/providers/registry.ts +5 -3
- package/src/router.ts +6 -1
- package/src/server/auth-cors.ts +4 -0
- package/src/server/claude-messages.ts +7 -1
- package/src/server/management-api.ts +85 -31
- package/src/server/request-decompress.ts +45 -12
- package/src/server/responses.ts +5 -4
- package/src/server/system-env.ts +110 -68
- package/src/service.ts +4 -0
- package/src/types.ts +8 -3
- package/gui/dist/assets/index-BcaDQD3i.js +0 -40
|
@@ -178,15 +178,21 @@ async function anthropicNativePassthrough(
|
|
|
178
178
|
});
|
|
179
179
|
headers.set("content-type", "application/json");
|
|
180
180
|
|
|
181
|
+
const timeoutSignal = AbortSignal.timeout(config.connectTimeoutMs ?? 120_000);
|
|
182
|
+
const upstreamSignal = AbortSignal.any([req.signal, timeoutSignal]);
|
|
181
183
|
let upstream: Response;
|
|
182
184
|
try {
|
|
183
185
|
upstream = await fetch(`${base}${pathname}${search}`, {
|
|
184
186
|
method: "POST",
|
|
185
187
|
headers,
|
|
186
188
|
body: JSON.stringify(body),
|
|
187
|
-
signal:
|
|
189
|
+
signal: upstreamSignal,
|
|
188
190
|
});
|
|
189
191
|
} catch (err) {
|
|
192
|
+
if (timeoutSignal.aborted && upstreamSignal.reason === timeoutSignal.reason) {
|
|
193
|
+
finalize(504, { closeReason: "non_stream" });
|
|
194
|
+
return anthropicErrorResponse(504, "anthropic passthrough timed out waiting for response headers", "timeout_error");
|
|
195
|
+
}
|
|
190
196
|
finalize(502, { closeReason: "non_stream" });
|
|
191
197
|
return anthropicErrorResponse(502, `anthropic passthrough failed: ${err instanceof Error ? err.message : String(err)}`, "api_error");
|
|
192
198
|
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
upsertOAuthProvider,
|
|
20
20
|
} from "../oauth";
|
|
21
21
|
import { removeCredential } from "../oauth/store";
|
|
22
|
+
import { providerDestinationResolvedError } from "../lib/destination-policy";
|
|
22
23
|
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../oauth/key-providers";
|
|
23
24
|
import { deriveProviderPresets } from "../providers/derive";
|
|
24
25
|
import { fetchProviderQuotaReports } from "../providers/quota";
|
|
@@ -28,6 +29,7 @@ import { getUsageDebugLogEntries } from "../usage/debug";
|
|
|
28
29
|
import { parseRange, summarizeUsage } from "../usage/summary";
|
|
29
30
|
import { stripCodexRuntimeProviderFields } from "../codex/auth-context";
|
|
30
31
|
import { getDebugLogEntries } from "../lib/debug-log-buffer";
|
|
32
|
+
import { getInjectionDebugLogEntries } from "../lib/injection-debug-log";
|
|
31
33
|
import {
|
|
32
34
|
clearDebugSettings,
|
|
33
35
|
clearDebugSetting,
|
|
@@ -39,6 +41,7 @@ import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
|
39
41
|
import { drainAndShutdown } from "./lifecycle";
|
|
40
42
|
import { filterRequestLogs, getRequestLogEntries } from "./request-log";
|
|
41
43
|
import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "./auth-cors";
|
|
44
|
+
import { applySystemEnvToggle } from "./system-env";
|
|
42
45
|
|
|
43
46
|
// Single source of truth = package.json (../ from src/), so /healthz + the GUI badge match the
|
|
44
47
|
// installed npm version instead of a stale hardcode.
|
|
@@ -86,6 +89,28 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
86
89
|
}
|
|
87
90
|
}
|
|
88
91
|
|
|
92
|
+
async function syncClaudeAgentDefsBestEffort(): Promise<void> {
|
|
93
|
+
try {
|
|
94
|
+
const { injectClaudeAgentDefs } = await import("../claude/agents-inject");
|
|
95
|
+
if (config.claudeCode?.enabled === false || config.claudeCode?.injectAgents === false) {
|
|
96
|
+
injectClaudeAgentDefs(config, {});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const [models, { buildClaudeContextWindows }, { visibleNativeSlugs }] = await Promise.all([
|
|
101
|
+
fetchAllModels(config),
|
|
102
|
+
import("../claude/context-windows"),
|
|
103
|
+
import("../codex/catalog"),
|
|
104
|
+
]);
|
|
105
|
+
injectClaudeAgentDefs(config, buildClaudeContextWindows([...visibleNativeSlugs(config)], models));
|
|
106
|
+
} catch {
|
|
107
|
+
// Keep routes available through a provider-discovery blip. A later
|
|
108
|
+
// launch-time sync restores any context markers missing from this pass.
|
|
109
|
+
injectClaudeAgentDefs(config, {});
|
|
110
|
+
}
|
|
111
|
+
} catch { /* best-effort */ }
|
|
112
|
+
}
|
|
113
|
+
|
|
89
114
|
if (url.pathname === "/api/config" && req.method === "GET") {
|
|
90
115
|
return jsonResponse(safeConfigDTO(config));
|
|
91
116
|
}
|
|
@@ -219,6 +244,11 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
219
244
|
return jsonResponse({ enabled: isClaudeDebugEnabled(), entries: getClaudeInboundDebugEntries() });
|
|
220
245
|
}
|
|
221
246
|
|
|
247
|
+
if (url.pathname === "/api/debug/injection-logs" && req.method === "GET") {
|
|
248
|
+
const { after, limit } = parseDebugLogQuery(url);
|
|
249
|
+
return jsonResponse(getInjectionDebugLogEntries({ after, limit }));
|
|
250
|
+
}
|
|
251
|
+
|
|
222
252
|
if (url.pathname === "/api/debug" && req.method === "PUT") {
|
|
223
253
|
let body: { debug?: unknown; usage?: unknown; injection?: unknown; claude?: unknown; reset?: unknown };
|
|
224
254
|
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
@@ -287,6 +317,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
287
317
|
return jsonResponse(Object.entries(config.providers).map(([name, p]) => ({
|
|
288
318
|
name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel,
|
|
289
319
|
hasApiKey: !!p.apiKey,
|
|
320
|
+
allowPrivateNetwork: p.allowPrivateNetwork === true,
|
|
290
321
|
disabled: p.disabled === true,
|
|
291
322
|
})));
|
|
292
323
|
}
|
|
@@ -307,6 +338,10 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
307
338
|
}
|
|
308
339
|
const providerError = providerManagementConfigError(name, prov);
|
|
309
340
|
if (providerError) return jsonResponse({ error: providerError }, 400);
|
|
341
|
+
// Hostname destinations additionally get a DNS-resolved SSRF check at write time —
|
|
342
|
+
// the sync check above only classifies literal IPs (review finding, PR #96).
|
|
343
|
+
const resolvedError = await providerDestinationResolvedError(name, prov);
|
|
344
|
+
if (resolvedError) return jsonResponse({ error: resolvedError }, 400);
|
|
310
345
|
// Catalog providers (e.g. ollama-cloud) carry a models + vision/reasoning classification the GUI
|
|
311
346
|
// doesn't send — merge it in so the sidecars are gated correctly.
|
|
312
347
|
enrichProviderFromCatalog(name, prov);
|
|
@@ -645,6 +680,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
645
680
|
const { saveConfig: save } = await import("../config");
|
|
646
681
|
save(config);
|
|
647
682
|
await refreshCodexCatalogBestEffort();
|
|
683
|
+
await syncClaudeAgentDefsBestEffort();
|
|
648
684
|
return jsonResponse({ ok: true, applied: chosen });
|
|
649
685
|
}
|
|
650
686
|
|
|
@@ -699,8 +735,15 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
699
735
|
// supersede tiers; auto-context supersedes the max-context pair; effort rides
|
|
700
736
|
// regardless on 2.1.207). PUT keeps validating them so hand-written configs
|
|
701
737
|
// and older GUIs stay safe; GUI saves omit them and the spread preserves them.
|
|
702
|
-
let
|
|
703
|
-
try {
|
|
738
|
+
let parsedBody: unknown;
|
|
739
|
+
try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
740
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
|
741
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
742
|
+
const prototype = Object.getPrototypeOf(value);
|
|
743
|
+
return prototype === Object.prototype || prototype === null;
|
|
744
|
+
};
|
|
745
|
+
if (!isPlainObject(parsedBody)) return jsonResponse({ error: "body must be an object" }, 400);
|
|
746
|
+
const body = parsedBody as { enabled?: unknown; model?: unknown; smallFastModel?: unknown; modelMap?: unknown; systemEnv?: unknown; fastMode?: unknown; maxContextTokens?: unknown; alwaysEnableEffort?: unknown; tierModels?: unknown; autoContext?: unknown; autoCompactWindow?: unknown; blockedSkills?: unknown; injectAgents?: unknown };
|
|
704
747
|
const next = { ...(config.claudeCode ?? {}) };
|
|
705
748
|
if (body.enabled !== undefined) {
|
|
706
749
|
if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400);
|
|
@@ -762,18 +805,23 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
762
805
|
}
|
|
763
806
|
if (body.tierModels !== undefined) {
|
|
764
807
|
// CONFIG-ONLY back-compat (GUI pickers removed — roster agents supersede tiers).
|
|
765
|
-
if (
|
|
766
|
-
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
const value
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
808
|
+
if (body.tierModels === null) {
|
|
809
|
+
delete next.tierModels;
|
|
810
|
+
} else if (!isPlainObject(body.tierModels)) {
|
|
811
|
+
return jsonResponse({ error: "tierModels must be an object with string values, or null" }, 400);
|
|
812
|
+
} else {
|
|
813
|
+
for (const [tier, value] of Object.entries(body.tierModels)) {
|
|
814
|
+
if (typeof value !== "string") return jsonResponse({ error: `tierModels.${tier} must be a string` }, 400);
|
|
815
|
+
}
|
|
816
|
+
const tierModels = body.tierModels as Record<string, string>;
|
|
817
|
+
const tiers: Record<string, string> = {};
|
|
818
|
+
for (const tier of ["opus", "sonnet", "haiku", "fable"] as const) {
|
|
819
|
+
const value = tierModels[tier];
|
|
820
|
+
if (value !== undefined && value.trim() !== "") tiers[tier] = value.trim();
|
|
821
|
+
}
|
|
822
|
+
if (Object.keys(tiers).length > 0) next.tierModels = tiers;
|
|
823
|
+
else delete next.tierModels;
|
|
774
824
|
}
|
|
775
|
-
if (Object.keys(tiers).length > 0) next.tierModels = tiers;
|
|
776
|
-
else delete next.tierModels;
|
|
777
825
|
}
|
|
778
826
|
if (body.fastMode !== undefined) {
|
|
779
827
|
if (body.fastMode !== true && body.fastMode !== false && body.fastMode !== null) {
|
|
@@ -789,32 +837,38 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
789
837
|
else next[field] = value.trim();
|
|
790
838
|
}
|
|
791
839
|
if (body.modelMap !== undefined) {
|
|
792
|
-
if (
|
|
793
|
-
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
if (typeof v !== "string" || k.trim() === "" || v.trim() === "") {
|
|
798
|
-
return jsonResponse({ error: "modelMap entries must be non-empty strings" }, 400);
|
|
840
|
+
if (body.modelMap === null) {
|
|
841
|
+
delete next.modelMap;
|
|
842
|
+
} else {
|
|
843
|
+
if (!isPlainObject(body.modelMap)) {
|
|
844
|
+
return jsonResponse({ error: "modelMap must be an object of string->string, or null" }, 400);
|
|
799
845
|
}
|
|
800
|
-
map
|
|
846
|
+
const map: Record<string, string> = {};
|
|
847
|
+
for (const [k, v] of Object.entries(body.modelMap)) {
|
|
848
|
+
if (typeof v !== "string" || k.trim() === "" || v.trim() === "") {
|
|
849
|
+
return jsonResponse({ error: "modelMap entries must be non-empty strings" }, 400);
|
|
850
|
+
}
|
|
851
|
+
map[k.trim()] = v.trim();
|
|
852
|
+
}
|
|
853
|
+
if (Object.keys(map).length > 0) next.modelMap = map;
|
|
854
|
+
else delete next.modelMap;
|
|
801
855
|
}
|
|
802
|
-
if (Object.keys(map).length > 0) next.modelMap = map;
|
|
803
|
-
else delete next.modelMap;
|
|
804
856
|
}
|
|
805
857
|
config.claudeCode = next;
|
|
806
858
|
const { saveConfig: save } = await import("../config");
|
|
807
859
|
save(config);
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
// next launch hook. Best-effort; the disabled gate inside prunes owned files.
|
|
811
|
-
if (next.injectAgents === false || next.enabled === false) {
|
|
860
|
+
const warnings: string[] = [];
|
|
861
|
+
if (body.systemEnv !== undefined) {
|
|
812
862
|
try {
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
863
|
+
await applySystemEnvToggle(config, config.port);
|
|
864
|
+
} catch (err) {
|
|
865
|
+
warnings.push(`Failed to apply system environment setting: ${err instanceof Error ? err.message : String(err)}`);
|
|
866
|
+
}
|
|
816
867
|
}
|
|
817
|
-
|
|
868
|
+
// Keep the file-backed live registry symmetric: OFF prunes immediately, while
|
|
869
|
+
// ON and config changes restore definitions without requiring a restart.
|
|
870
|
+
await syncClaudeAgentDefsBestEffort();
|
|
871
|
+
return jsonResponse({ ok: true, enabled: next.enabled !== false, warnings });
|
|
818
872
|
}
|
|
819
873
|
|
|
820
874
|
// Per-provider catalog allowlist (issue #52): when a provider has a non-empty selectedModels list,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { gunzipSync, inflateRawSync, inflateSync, zstdDecompressSync } from "node:zlib";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Request-body decompression for the /v1/responses data plane.
|
|
3
5
|
*
|
|
@@ -25,28 +27,59 @@ export class UnsupportedContentEncodingError extends Error {
|
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export class DecompressedBodyTooLargeError extends Error {
|
|
28
|
-
constructor(readonly bytes: number) {
|
|
29
|
-
super(`Decompressed request body exceeds ${
|
|
30
|
+
constructor(readonly bytes: number, limit: number = MAX_DECOMPRESSED_BODY_BYTES) {
|
|
31
|
+
super(`Decompressed request body exceeds ${limit} bytes`);
|
|
30
32
|
}
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
function assertBodySizeWithinLimit(body: Uint8Array, maxBytes: number): Uint8Array {
|
|
36
|
+
if (body.byteLength > maxBytes) throw new DecompressedBodyTooLargeError(body.byteLength, maxBytes);
|
|
37
|
+
return body;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function inflateDeflateBody(compressed: Uint8Array<ArrayBuffer>, opts: { maxOutputLength: number }): Uint8Array {
|
|
41
|
+
// HTTP "deflate" appears both zlib-wrapped and raw in the wild (Bun.deflateSync emits raw,
|
|
42
|
+
// which the previous Bun.inflateSync accepted). Try zlib-wrapped first, fall back to raw —
|
|
43
|
+
// but never swallow the size-cap abort.
|
|
44
|
+
try {
|
|
45
|
+
return inflateSync(compressed, opts);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if ((err as NodeJS.ErrnoException | null)?.code === "ERR_BUFFER_TOO_LARGE") throw err;
|
|
48
|
+
return inflateRawSync(compressed, opts);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function decodeRequestBody(
|
|
53
|
+
raw: Uint8Array,
|
|
54
|
+
contentEncoding: string | null,
|
|
55
|
+
maxBytes: number = MAX_DECOMPRESSED_BODY_BYTES,
|
|
56
|
+
): Uint8Array {
|
|
34
57
|
const encoding = (contentEncoding ?? "").trim().toLowerCase();
|
|
35
|
-
if (encoding === "" || encoding === "identity") return raw;
|
|
58
|
+
if (encoding === "" || encoding === "identity") return assertBodySizeWithinLimit(raw, maxBytes);
|
|
59
|
+
const compressed = raw as Uint8Array<ArrayBuffer>;
|
|
60
|
+
// `maxOutputLength` makes zlib abort DURING inflation (ERR_BUFFER_TOO_LARGE), so a
|
|
61
|
+
// decompression bomb never allocates beyond the cap — checking after the fact would
|
|
62
|
+
// already have paid the full allocation (review finding, PR #96).
|
|
63
|
+
const opts = { maxOutputLength: maxBytes };
|
|
36
64
|
let decoded: Uint8Array;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
65
|
+
try {
|
|
66
|
+
if (encoding === "zstd") decoded = zstdDecompressSync(compressed, opts);
|
|
67
|
+
else if (encoding === "gzip" || encoding === "x-gzip") decoded = gunzipSync(compressed, opts);
|
|
68
|
+
else if (encoding === "deflate") decoded = inflateDeflateBody(compressed, opts);
|
|
69
|
+
// Multi-codings ("zstd, gzip") and unknown tokens are rejected rather than guessed.
|
|
70
|
+
else throw new UnsupportedContentEncodingError(encoding);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if ((err as NodeJS.ErrnoException | null)?.code === "ERR_BUFFER_TOO_LARGE") {
|
|
73
|
+
throw new DecompressedBodyTooLargeError(maxBytes + 1, maxBytes);
|
|
74
|
+
}
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
return assertBodySizeWithinLimit(decoded, maxBytes);
|
|
44
78
|
}
|
|
45
79
|
|
|
46
80
|
/** Parse a JSON request body, transparently decoding compressed payloads. */
|
|
47
81
|
export async function readJsonRequestBody(req: Request): Promise<unknown> {
|
|
48
82
|
const encoding = req.headers.get("content-encoding");
|
|
49
|
-
if (!encoding || encoding.trim().toLowerCase() === "identity") return await req.json();
|
|
50
83
|
const decoded = decodeRequestBody(new Uint8Array(await req.arrayBuffer()), encoding);
|
|
51
84
|
return JSON.parse(new TextDecoder().decode(decoded));
|
|
52
85
|
}
|
package/src/server/responses.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
|
10
10
|
import { expandPreviousResponseInput, previousResponseConversationId, rememberResponseState } from "../responses/state";
|
|
11
11
|
import { routeModel } from "../router";
|
|
12
12
|
import { isInjectionDebugEnabled } from "../lib/debug-settings";
|
|
13
|
+
import { injectionDebugLog } from "../lib/injection-debug-log";
|
|
13
14
|
import { modelInList, namespacedToolName } from "../types";
|
|
14
15
|
import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
|
|
15
16
|
import {
|
|
@@ -505,9 +506,9 @@ export async function handleResponses(
|
|
|
505
506
|
const guidance = await multiAgentGuidanceText(parsed, config.injectionModel, config.injectionEffort, config.subagentModels, config.injectionPrompt);
|
|
506
507
|
if (guidance) {
|
|
507
508
|
injectDeveloperMessage(parsed, guidance);
|
|
508
|
-
if (isInjectionDebugEnabled())
|
|
509
|
+
if (isInjectionDebugEnabled()) injectionDebugLog(`[opencodex] ${route.modelId}: multi-agent guidance injected (surface=${collabSurface(parsed)}, ${guidance.length} chars)`);
|
|
509
510
|
} else if (isInjectionDebugEnabled() && collabSurface(parsed) !== null) {
|
|
510
|
-
|
|
511
|
+
injectionDebugLog(`[opencodex] ${route.modelId}: collab surface=${collabSurface(parsed)}, guidance silent (effort=${parsed.options.reasoning ?? "unset"}, injectionModel=${config.injectionModel ?? "unset"})`);
|
|
511
512
|
}
|
|
512
513
|
}
|
|
513
514
|
|
|
@@ -530,11 +531,11 @@ export async function handleResponses(
|
|
|
530
531
|
if (capped) {
|
|
531
532
|
logCtx.requestedEffort = `${capped.from}->${capped.to}`;
|
|
532
533
|
if (isInjectionDebugEnabled()) {
|
|
533
|
-
|
|
534
|
+
injectionDebugLog(`[opencodex] ${route.modelId}: effort cap applied (${capped.from} -> ${capped.to}, ${capped.subagent ? "sub-agent" : "main"} turn)`);
|
|
534
535
|
}
|
|
535
536
|
}
|
|
536
537
|
} else if (isInjectionDebugEnabled() && (config.effortCap || config.subagentEffortCap)) {
|
|
537
|
-
|
|
538
|
+
injectionDebugLog(`[opencodex] ${route.modelId}: effort cap skipped (surface=${surface ?? "none"}, v2 feature only)`);
|
|
538
539
|
}
|
|
539
540
|
}
|
|
540
541
|
|
package/src/server/system-env.ts
CHANGED
|
@@ -15,24 +15,28 @@ export function getShellEnvFilePath(): string {
|
|
|
15
15
|
return join(getConfigDir(), "claude-env.sh");
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
function shellValue(value: string): string {
|
|
19
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
20
|
+
}
|
|
21
|
+
|
|
18
22
|
function writeShellEnvFile(port: number, config: OcxConfig, modelEnv: Record<string, string> = {}, auto?: AutoContextMode): void {
|
|
19
23
|
const lines = [
|
|
20
24
|
`# Generated by opencodex — do not edit manually`,
|
|
21
|
-
`export ANTHROPIC_BASE_URL
|
|
22
|
-
`export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY
|
|
25
|
+
`export ANTHROPIC_BASE_URL=${shellValue(`http://127.0.0.1:${port}`)}`,
|
|
26
|
+
`export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=${shellValue("1")}`,
|
|
23
27
|
];
|
|
24
28
|
if (config.apiKeys?.length) {
|
|
25
|
-
lines.push(`export ANTHROPIC_AUTH_TOKEN
|
|
29
|
+
lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(config.apiKeys[0].key)}`);
|
|
26
30
|
}
|
|
27
31
|
// New lever keys are CONDITIONAL exports (audit 139 R2#1): a value the user already
|
|
28
32
|
// exported in their shell wins even though launchctl knows nothing about it.
|
|
29
33
|
const conditional = (name: string, value: string) =>
|
|
30
|
-
`[ -z "\${${name}+x}" ] && export ${name}
|
|
34
|
+
`[ -z "\${${name}+x}" ] && export ${name}=${shellValue(value)}`;
|
|
31
35
|
// Model slots (default + tiers + legacy small-fast) with [1m] applied (devlog 260712 B2).
|
|
32
36
|
if (modelEnv.ANTHROPIC_MODEL) {
|
|
33
|
-
lines.push(`export ANTHROPIC_MODEL
|
|
37
|
+
lines.push(`export ANTHROPIC_MODEL=${shellValue(modelEnv.ANTHROPIC_MODEL)}`);
|
|
34
38
|
} else if (config.claudeCode?.model) {
|
|
35
|
-
lines.push(`export ANTHROPIC_MODEL
|
|
39
|
+
lines.push(`export ANTHROPIC_MODEL=${shellValue(config.claudeCode.model)}`);
|
|
36
40
|
}
|
|
37
41
|
for (const [name, value] of Object.entries(modelEnv)) {
|
|
38
42
|
if (name === "ANTHROPIC_MODEL") continue;
|
|
@@ -159,6 +163,34 @@ function ownedBaseUrl(port: number): string {
|
|
|
159
163
|
return `http://127.0.0.1:${port}`;
|
|
160
164
|
}
|
|
161
165
|
|
|
166
|
+
function writeTracking(port: number, injectedKeys: string[]): void {
|
|
167
|
+
mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
|
|
168
|
+
writeFileSync(getSystemEnvTrackingPath(), JSON.stringify({
|
|
169
|
+
pid: process.pid,
|
|
170
|
+
port,
|
|
171
|
+
injectedAt: new Date().toISOString(),
|
|
172
|
+
injectedKeys,
|
|
173
|
+
}), { encoding: "utf8", mode: 0o600 });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function rollbackInjectedKeys(port: number, injectedKeys: string[]): void {
|
|
177
|
+
const rollbackFailed: string[] = [];
|
|
178
|
+
for (const name of [...injectedKeys].reverse()) {
|
|
179
|
+
try {
|
|
180
|
+
unsetLaunchctlEnv(name);
|
|
181
|
+
} catch {
|
|
182
|
+
rollbackFailed.unshift(name);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (rollbackFailed.length > 0) {
|
|
187
|
+
writeTracking(port, rollbackFailed);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try { unlinkSync(getSystemEnvTrackingPath()); } catch { /* already gone */ }
|
|
192
|
+
}
|
|
193
|
+
|
|
162
194
|
/**
|
|
163
195
|
* In-process effective model-env (default + tier slots, [1m] applied) under the shared
|
|
164
196
|
* 3s bound (audit R4#3). Returns {} on timeout/failure so injection degrades safely.
|
|
@@ -192,77 +224,86 @@ export async function injectSystemEnv(port: number, config: OcxConfig): Promise<
|
|
|
192
224
|
return { injected: false, reason: `another instance owns env (port ${existingTracking.port})` };
|
|
193
225
|
}
|
|
194
226
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY",
|
|
200
|
-
];
|
|
201
|
-
if (config.apiKeys?.length) {
|
|
202
|
-
setLaunchctlEnv("ANTHROPIC_AUTH_TOKEN", config.apiKeys[0].key);
|
|
203
|
-
injectedKeys.push("ANTHROPIC_AUTH_TOKEN");
|
|
204
|
-
}
|
|
205
|
-
// Lever keys (devlog 136 B6): user-wins — skip any key the user already set in the
|
|
206
|
-
// launchd domain, and track ONLY the keys we actually injected so revert cannot
|
|
207
|
-
// delete a pre-existing user value (audit 139 #3).
|
|
208
|
-
const injectLever = (name: string, value: string) => {
|
|
209
|
-
if (launchctlGetenv(name) !== undefined) return;
|
|
227
|
+
const injectedKeys: string[] = existingTracking
|
|
228
|
+
? [...(existingTracking.injectedKeys ?? SYSTEM_ENV_NAMES)]
|
|
229
|
+
: [];
|
|
230
|
+
const inject = (name: string, value: string) => {
|
|
210
231
|
setLaunchctlEnv(name, value);
|
|
211
|
-
injectedKeys.push(name);
|
|
232
|
+
if (!injectedKeys.includes(name)) injectedKeys.push(name);
|
|
233
|
+
writeTracking(port, injectedKeys);
|
|
212
234
|
};
|
|
213
|
-
// Model slots (default + tier defaults + legacy small-fast) with [1m] auto-marking
|
|
214
|
-
// (devlog 260712 B2, audit R2#3/R4#3): in-process context-window computation under
|
|
215
|
-
// the same 3s bound; on timeout the tier keys are simply not injected this run.
|
|
216
|
-
// Auto-context: a user-owned launchd value drives the marking predicate so the
|
|
217
|
-
// marker and threshold never separate (audit 021 #2); injectLever's user-wins
|
|
218
|
-
// check below keeps that value untouched.
|
|
219
|
-
const userAutoCompact = launchctlGetenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW");
|
|
220
|
-
const auto = resolveAutoContext(config.claudeCode, userAutoCompact);
|
|
221
|
-
const { modelEnv, windows } = await computeEffectiveModelEnv(config, auto);
|
|
222
|
-
for (const [name, value] of Object.entries(modelEnv)) {
|
|
223
|
-
if (name === "ANTHROPIC_MODEL") continue; // legacy slot handled by shell file only (back-compat)
|
|
224
|
-
injectLever(name, value);
|
|
225
|
-
}
|
|
226
|
-
const maxCtx = config.claudeCode?.maxContextTokens;
|
|
227
|
-
if (typeof maxCtx === "number" && Number.isFinite(maxCtx) && maxCtx > 0) {
|
|
228
|
-
injectLever("CLAUDE_CODE_MAX_CONTEXT_TOKENS", String(Math.floor(maxCtx)));
|
|
229
|
-
injectLever("DISABLE_COMPACT", "1");
|
|
230
|
-
}
|
|
231
|
-
// Auto-context (devlog 260712 020): user-wins lever, inert when maxContextTokens set.
|
|
232
|
-
if (auto.enabled) injectLever("CLAUDE_CODE_AUTO_COMPACT_WINDOW", String(auto.compactWindow));
|
|
233
|
-
if (config.claudeCode?.alwaysEnableEffort === true) {
|
|
234
|
-
injectLever("CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", "1");
|
|
235
|
-
}
|
|
236
235
|
|
|
237
|
-
// Shell-hook env file: works for new shells in already-running Terminal.app.
|
|
238
|
-
writeShellEnvFile(port, config, modelEnv, auto);
|
|
239
|
-
|
|
240
|
-
// Gateway-model cache pre-write (devlog 030): plain `claude` sessions read the
|
|
241
|
-
// picker list from ~/.claude/cache/gateway-models.json and cannot refresh it
|
|
242
|
-
// without a token — keep it in sync with this proxy's /v1/models. Best-effort.
|
|
243
236
|
try {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
237
|
+
inject("ANTHROPIC_BASE_URL", ownedBaseUrl(port));
|
|
238
|
+
inject("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", "1");
|
|
239
|
+
if (config.apiKeys?.length) {
|
|
240
|
+
inject("ANTHROPIC_AUTH_TOKEN", config.apiKeys[0].key);
|
|
241
|
+
}
|
|
242
|
+
// Lever keys (devlog 136 B6): user-wins — skip any key the user already set in the
|
|
243
|
+
// launchd domain, and track ONLY the keys we actually injected so revert cannot
|
|
244
|
+
// delete a pre-existing user value (audit 139 #3).
|
|
245
|
+
const injectLever = (name: string, value: string) => {
|
|
246
|
+
if (launchctlGetenv(name) !== undefined) return;
|
|
247
|
+
inject(name, value);
|
|
248
|
+
};
|
|
249
|
+
// Model slots (default + tier defaults + legacy small-fast) with [1m] auto-marking
|
|
250
|
+
// (devlog 260712 B2, audit R2#3/R4#3): in-process context-window computation under
|
|
251
|
+
// the same 3s bound; on timeout the tier keys are simply not injected this run.
|
|
252
|
+
// Auto-context: a user-owned launchd value drives the marking predicate so the
|
|
253
|
+
// marker and threshold never separate (audit 021 #2); injectLever's user-wins
|
|
254
|
+
// check below keeps that value untouched.
|
|
255
|
+
const userAutoCompact = launchctlGetenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW");
|
|
256
|
+
const auto = resolveAutoContext(config.claudeCode, userAutoCompact);
|
|
257
|
+
const { modelEnv, windows } = await computeEffectiveModelEnv(config, auto);
|
|
258
|
+
for (const [name, value] of Object.entries(modelEnv)) {
|
|
259
|
+
if (name === "ANTHROPIC_MODEL") continue; // legacy slot handled by shell file only (back-compat)
|
|
260
|
+
injectLever(name, value);
|
|
261
|
+
}
|
|
262
|
+
const maxCtx = config.claudeCode?.maxContextTokens;
|
|
263
|
+
if (typeof maxCtx === "number" && Number.isFinite(maxCtx) && maxCtx > 0) {
|
|
264
|
+
injectLever("CLAUDE_CODE_MAX_CONTEXT_TOKENS", String(Math.floor(maxCtx)));
|
|
265
|
+
injectLever("DISABLE_COMPACT", "1");
|
|
266
|
+
}
|
|
267
|
+
// Auto-context (devlog 260712 020): user-wins lever, inert when maxContextTokens set.
|
|
268
|
+
if (auto.enabled) injectLever("CLAUDE_CODE_AUTO_COMPACT_WINDOW", String(auto.compactWindow));
|
|
269
|
+
if (config.claudeCode?.alwaysEnableEffort === true) {
|
|
270
|
+
injectLever("CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", "1");
|
|
271
|
+
}
|
|
247
272
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
try {
|
|
251
|
-
const { injectClaudeAgentDefs } = await import("../claude/agents-inject");
|
|
252
|
-
injectClaudeAgentDefs(config, windows);
|
|
253
|
-
} catch { /* best-effort */ }
|
|
273
|
+
// Shell-hook env file: works for new shells in already-running Terminal.app.
|
|
274
|
+
writeShellEnvFile(port, config, modelEnv, auto);
|
|
254
275
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
276
|
+
// Gateway-model cache pre-write (devlog 030): plain `claude` sessions read the
|
|
277
|
+
// picker list from ~/.claude/cache/gateway-models.json and cannot refresh it
|
|
278
|
+
// without a token — keep it in sync with this proxy's /v1/models. Best-effort.
|
|
279
|
+
try {
|
|
280
|
+
const { refreshGatewayModelCacheFromProxy } = await import("../claude/gateway-cache");
|
|
281
|
+
await refreshGatewayModelCacheFromProxy(port);
|
|
282
|
+
} catch { /* best-effort */ }
|
|
283
|
+
|
|
284
|
+
// Roster agent definitions (devlog 070): same launch-time sync for plain `claude`.
|
|
285
|
+
// Reuses the window map computed above (audit 071 #5 — no second acquisition).
|
|
286
|
+
try {
|
|
287
|
+
const { injectClaudeAgentDefs } = await import("../claude/agents-inject");
|
|
288
|
+
injectClaudeAgentDefs(config, windows);
|
|
289
|
+
} catch { /* best-effort */ }
|
|
290
|
+
|
|
291
|
+
writeTracking(port, injectedKeys);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
rollbackInjectedKeys(port, injectedKeys);
|
|
294
|
+
removeShellEnvFile();
|
|
295
|
+
console.error("Failed to inject system environment; rolled back launchctl changes:", error);
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
262
298
|
|
|
263
299
|
return { injected: true };
|
|
264
300
|
}
|
|
265
301
|
|
|
302
|
+
export async function applySystemEnvToggle(config: OcxConfig, port: number): Promise<SystemEnvResult | RevertResult> {
|
|
303
|
+
if (config.claudeCode?.systemEnv === true) return injectSystemEnv(port, config);
|
|
304
|
+
return revertSystemEnv();
|
|
305
|
+
}
|
|
306
|
+
|
|
266
307
|
export function revertSystemEnv(): RevertResult {
|
|
267
308
|
if (process.platform !== "darwin") return { reverted: false, reason: "not macOS" };
|
|
268
309
|
|
|
@@ -270,7 +311,8 @@ export function revertSystemEnv(): RevertResult {
|
|
|
270
311
|
if (!tracking) return { reverted: false, reason: "no tracking file" };
|
|
271
312
|
|
|
272
313
|
try {
|
|
273
|
-
|
|
314
|
+
const tracksBaseUrl = tracking.injectedKeys?.includes("ANTHROPIC_BASE_URL") ?? true;
|
|
315
|
+
if (tracksBaseUrl && launchctlGetenv("ANTHROPIC_BASE_URL") !== ownedBaseUrl(tracking.port)) {
|
|
274
316
|
return { reverted: false, reason: "ownership mismatch" };
|
|
275
317
|
}
|
|
276
318
|
|
package/src/service.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { isWslRuntime } from "./codex/home";
|
|
|
16
16
|
import { durableBunPath, durableBunRuntime } from "./lib/bun-runtime";
|
|
17
17
|
import { isProcessAlive, stopProxy } from "./lib/process-control";
|
|
18
18
|
import { serviceApiTokenFilePath } from "./lib/service-secrets";
|
|
19
|
+
import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
|
|
19
20
|
import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths";
|
|
20
21
|
|
|
21
22
|
const LABEL = "com.opencodex.proxy";
|
|
@@ -103,6 +104,7 @@ function writeServiceInstallState(): void {
|
|
|
103
104
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
104
105
|
writeFileSync(path, JSON.stringify(state, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
105
106
|
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
107
|
+
if (process.platform === "win32") hardenSecretPath(path, { required: true });
|
|
106
108
|
}
|
|
107
109
|
}
|
|
108
110
|
|
|
@@ -169,8 +171,10 @@ function writeServiceApiTokenFile(): string | null {
|
|
|
169
171
|
const path = serviceApiTokenFilePath();
|
|
170
172
|
const dir = getConfigDir();
|
|
171
173
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
174
|
+
if (process.platform === "win32") hardenSecretDir(dir, { required: true });
|
|
172
175
|
writeFileSync(path, `${token}\n`, { encoding: "utf8", mode: 0o600 });
|
|
173
176
|
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
177
|
+
if (process.platform === "win32") hardenSecretPath(path, { required: true });
|
|
174
178
|
return path;
|
|
175
179
|
}
|
|
176
180
|
|
package/src/types.ts
CHANGED
|
@@ -265,9 +265,9 @@ export interface OcxClaudeCodeConfig {
|
|
|
265
265
|
/** Inbound model id remaps: exact id first, then date-stripped (`-\d{8}$`). */
|
|
266
266
|
modelMap?: Record<string, string>;
|
|
267
267
|
/**
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
* on stop/shutdown. Default:
|
|
268
|
+
* Inject ANTHROPIC_BASE_URL etc. into the macOS user domain via `launchctl setenv`
|
|
269
|
+
* so plain `claude` commands route through the proxy without `ocx claude`. Reverted
|
|
270
|
+
* on stop/shutdown. Default: false (opt-in). macOS only.
|
|
271
271
|
*/
|
|
272
272
|
systemEnv?: boolean;
|
|
273
273
|
/**
|
|
@@ -514,6 +514,11 @@ export interface OcxWebSearchSidecarConfig {
|
|
|
514
514
|
export interface OcxProviderConfig {
|
|
515
515
|
adapter: string;
|
|
516
516
|
baseUrl: string;
|
|
517
|
+
/**
|
|
518
|
+
* Explicit opt-in for non-registry private-network destinations such as localhost, RFC1918,
|
|
519
|
+
* link-local, or unique-local upstreams. Metadata endpoints remain blocked.
|
|
520
|
+
*/
|
|
521
|
+
allowPrivateNetwork?: boolean;
|
|
517
522
|
/** Keep provider settings on disk but exclude it from routing and model/catalog listings. */
|
|
518
523
|
disabled?: boolean;
|
|
519
524
|
apiKey?: string;
|