@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.
@@ -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: req.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 body: { 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 };
703
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
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 (!body.tierModels || typeof body.tierModels !== "object" || Array.isArray(body.tierModels)) {
766
- return jsonResponse({ error: "tierModels must be an object" }, 400);
767
- }
768
- const tiers: Record<string, string> = {};
769
- for (const tier of ["opus", "sonnet", "haiku", "fable"] as const) {
770
- const value = (body.tierModels as Record<string, unknown>)[tier];
771
- if (value === undefined || value === null) continue;
772
- if (typeof value !== "string") return jsonResponse({ error: `tierModels.${tier} must be a string` }, 400);
773
- if (value.trim() !== "") tiers[tier] = value.trim();
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 (!body.modelMap || typeof body.modelMap !== "object" || Array.isArray(body.modelMap)) {
793
- return jsonResponse({ error: "modelMap must be an object of string->string" }, 400);
794
- }
795
- const map: Record<string, string> = {};
796
- for (const [k, v] of Object.entries(body.modelMap as Record<string, unknown>)) {
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[k.trim()] = v.trim();
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
- // Immediate prune when injection turns off (audit 071 #3): stale ocx-* agent
809
- // definitions must stop loading in future sessions without waiting for the
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
- const { injectClaudeAgentDefs } = await import("../claude/agents-inject");
814
- injectClaudeAgentDefs(config, {});
815
- } catch { /* best-effort */ }
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
- return jsonResponse({ ok: true, enabled: next.enabled !== false });
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 ${MAX_DECOMPRESSED_BODY_BYTES} bytes`);
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
- export function decodeRequestBody(raw: Uint8Array<ArrayBuffer>, contentEncoding: string | null): Uint8Array {
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
- if (encoding === "zstd") decoded = Bun.zstdDecompressSync(raw);
38
- else if (encoding === "gzip" || encoding === "x-gzip") decoded = Bun.gunzipSync(raw);
39
- else if (encoding === "deflate") decoded = Bun.inflateSync(raw);
40
- // Multi-codings ("zstd, gzip") and unknown tokens are rejected rather than guessed.
41
- else throw new UnsupportedContentEncodingError(encoding);
42
- if (decoded.byteLength > MAX_DECOMPRESSED_BODY_BYTES) throw new DecompressedBodyTooLargeError(decoded.byteLength);
43
- return decoded;
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
  }
@@ -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()) console.log(`[opencodex] ${route.modelId}: multi-agent guidance injected (surface=${collabSurface(parsed)}, ${guidance.length} chars)`);
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
- console.log(`[opencodex] ${route.modelId}: collab surface=${collabSurface(parsed)}, guidance silent (effort=${parsed.options.reasoning ?? "unset"}, injectionModel=${config.injectionModel ?? "unset"})`);
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
- console.log(`[opencodex] ${route.modelId}: effort cap applied (${capped.from} -> ${capped.to}, ${capped.subagent ? "sub-agent" : "main"} turn)`);
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
- console.log(`[opencodex] ${route.modelId}: effort cap skipped (surface=${surface ?? "none"}, v2 feature only)`);
538
+ injectionDebugLog(`[opencodex] ${route.modelId}: effort cap skipped (surface=${surface ?? "none"}, v2 feature only)`);
538
539
  }
539
540
  }
540
541
 
@@ -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="http://127.0.0.1:${port}"`,
22
- `export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`,
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="${config.apiKeys[0].key.replace(/"/g, '\\"')}"`);
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}="${value.replace(/"/g, '\\"')}"`;
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="${modelEnv.ANTHROPIC_MODEL.replace(/"/g, '\\"')}"`);
37
+ lines.push(`export ANTHROPIC_MODEL=${shellValue(modelEnv.ANTHROPIC_MODEL)}`);
34
38
  } else if (config.claudeCode?.model) {
35
- lines.push(`export ANTHROPIC_MODEL="${config.claudeCode.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
- setLaunchctlEnv("ANTHROPIC_BASE_URL", ownedBaseUrl(port));
196
- setLaunchctlEnv("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", "1");
197
- const injectedKeys = [
198
- "ANTHROPIC_BASE_URL",
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
- const { refreshGatewayModelCacheFromProxy } = await import("../claude/gateway-cache");
245
- await refreshGatewayModelCacheFromProxy(port);
246
- } catch { /* best-effort */ }
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
- // Roster agent definitions (devlog 070): same launch-time sync for plain `claude`.
249
- // Reuses the window map computed above (audit 071 #5 — no second acquisition).
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
- mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
256
- writeFileSync(getSystemEnvTrackingPath(), JSON.stringify({
257
- pid: process.pid,
258
- port,
259
- injectedAt: new Date().toISOString(),
260
- injectedKeys,
261
- }), { encoding: "utf8", mode: 0o600 });
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
- if (launchctlGetenv("ANTHROPIC_BASE_URL") !== ownedBaseUrl(tracking.port)) {
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
- * 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: true when `enabled` is not false. macOS only.
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;