@bitkyc08/opencodex 2.7.9-preview.20260712.1 → 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 +61 -24
- 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-Csp2AZYr.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.
|
|
@@ -241,6 +244,11 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
241
244
|
return jsonResponse({ enabled: isClaudeDebugEnabled(), entries: getClaudeInboundDebugEntries() });
|
|
242
245
|
}
|
|
243
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
|
+
|
|
244
252
|
if (url.pathname === "/api/debug" && req.method === "PUT") {
|
|
245
253
|
let body: { debug?: unknown; usage?: unknown; injection?: unknown; claude?: unknown; reset?: unknown };
|
|
246
254
|
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
@@ -309,6 +317,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
309
317
|
return jsonResponse(Object.entries(config.providers).map(([name, p]) => ({
|
|
310
318
|
name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel,
|
|
311
319
|
hasApiKey: !!p.apiKey,
|
|
320
|
+
allowPrivateNetwork: p.allowPrivateNetwork === true,
|
|
312
321
|
disabled: p.disabled === true,
|
|
313
322
|
})));
|
|
314
323
|
}
|
|
@@ -329,6 +338,10 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
329
338
|
}
|
|
330
339
|
const providerError = providerManagementConfigError(name, prov);
|
|
331
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);
|
|
332
345
|
// Catalog providers (e.g. ollama-cloud) carry a models + vision/reasoning classification the GUI
|
|
333
346
|
// doesn't send — merge it in so the sidecars are gated correctly.
|
|
334
347
|
enrichProviderFromCatalog(name, prov);
|
|
@@ -722,8 +735,15 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
722
735
|
// supersede tiers; auto-context supersedes the max-context pair; effort rides
|
|
723
736
|
// regardless on 2.1.207). PUT keeps validating them so hand-written configs
|
|
724
737
|
// and older GUIs stay safe; GUI saves omit them and the spread preserves them.
|
|
725
|
-
let
|
|
726
|
-
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 };
|
|
727
747
|
const next = { ...(config.claudeCode ?? {}) };
|
|
728
748
|
if (body.enabled !== undefined) {
|
|
729
749
|
if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400);
|
|
@@ -785,18 +805,23 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
785
805
|
}
|
|
786
806
|
if (body.tierModels !== undefined) {
|
|
787
807
|
// CONFIG-ONLY back-compat (GUI pickers removed — roster agents supersede tiers).
|
|
788
|
-
if (
|
|
789
|
-
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
const value
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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;
|
|
797
824
|
}
|
|
798
|
-
if (Object.keys(tiers).length > 0) next.tierModels = tiers;
|
|
799
|
-
else delete next.tierModels;
|
|
800
825
|
}
|
|
801
826
|
if (body.fastMode !== undefined) {
|
|
802
827
|
if (body.fastMode !== true && body.fastMode !== false && body.fastMode !== null) {
|
|
@@ -812,26 +837,38 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
812
837
|
else next[field] = value.trim();
|
|
813
838
|
}
|
|
814
839
|
if (body.modelMap !== undefined) {
|
|
815
|
-
if (
|
|
816
|
-
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
if (typeof v !== "string" || k.trim() === "" || v.trim() === "") {
|
|
821
|
-
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);
|
|
822
845
|
}
|
|
823
|
-
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;
|
|
824
855
|
}
|
|
825
|
-
if (Object.keys(map).length > 0) next.modelMap = map;
|
|
826
|
-
else delete next.modelMap;
|
|
827
856
|
}
|
|
828
857
|
config.claudeCode = next;
|
|
829
858
|
const { saveConfig: save } = await import("../config");
|
|
830
859
|
save(config);
|
|
860
|
+
const warnings: string[] = [];
|
|
861
|
+
if (body.systemEnv !== undefined) {
|
|
862
|
+
try {
|
|
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
|
+
}
|
|
867
|
+
}
|
|
831
868
|
// Keep the file-backed live registry symmetric: OFF prunes immediately, while
|
|
832
869
|
// ON and config changes restore definitions without requiring a restart.
|
|
833
870
|
await syncClaudeAgentDefsBestEffort();
|
|
834
|
-
return jsonResponse({ ok: true, enabled: next.enabled !== false });
|
|
871
|
+
return jsonResponse({ ok: true, enabled: next.enabled !== false, warnings });
|
|
835
872
|
}
|
|
836
873
|
|
|
837
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;
|