@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.0

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.
Files changed (173) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +47 -22
  3. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  4. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/discovery.ts +4 -1
  10. package/src/adapters/cursor/effort-map.ts +3 -0
  11. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  12. package/src/adapters/cursor/protobuf-events.ts +24 -2
  13. package/src/adapters/cursor/protobuf-request.ts +1 -2
  14. package/src/adapters/cursor/tool-definitions.ts +68 -29
  15. package/src/adapters/google-wire-compiler.ts +4 -0
  16. package/src/adapters/google.ts +128 -2
  17. package/src/adapters/identity.ts +12 -2
  18. package/src/adapters/kiro.ts +64 -7
  19. package/src/adapters/mimo-free.ts +2 -0
  20. package/src/adapters/openai-responses.ts +246 -59
  21. package/src/claude/agents-inject.ts +5 -0
  22. package/src/claude/alias.ts +94 -14
  23. package/src/claude/inbound.ts +26 -9
  24. package/src/claude/outbound.ts +6 -3
  25. package/src/cli/account-auth.ts +1 -1
  26. package/src/cli/agent-driven.ts +37 -0
  27. package/src/cli/catalog-prewarm.ts +24 -0
  28. package/src/cli/claude.ts +35 -10
  29. package/src/cli/doctor.ts +71 -19
  30. package/src/cli/help.ts +42 -6
  31. package/src/cli/index.ts +93 -19
  32. package/src/cli/interactive-confirm.ts +133 -0
  33. package/src/cli/opencode.ts +701 -0
  34. package/src/cli/provider-runtime.ts +3 -0
  35. package/src/cli/provider.ts +31 -10
  36. package/src/cli/star-prompt.ts +79 -18
  37. package/src/cli/status.ts +47 -13
  38. package/src/cli/v2.ts +10 -1
  39. package/src/codex/account-id.ts +34 -0
  40. package/src/codex/account-lifecycle.ts +4 -1
  41. package/src/codex/account-namespace-match.ts +63 -0
  42. package/src/codex/account-namespaces.ts +149 -0
  43. package/src/codex/account-pause.ts +20 -0
  44. package/src/codex/account-store.ts +2 -0
  45. package/src/codex/account-usability.ts +6 -1
  46. package/src/codex/app-server-processes.ts +511 -0
  47. package/src/codex/auth-api.ts +293 -34
  48. package/src/codex/auth-collision.ts +2 -1
  49. package/src/codex/auth-context.ts +60 -17
  50. package/src/codex/catalog/bundled.ts +9 -2
  51. package/src/codex/catalog/parsing.ts +42 -2
  52. package/src/codex/catalog/provider-fetch.ts +264 -70
  53. package/src/codex/catalog/sync.ts +45 -8
  54. package/src/codex/catalog.ts +2 -2
  55. package/src/codex/features.ts +524 -5
  56. package/src/codex/history-provider.ts +145 -1
  57. package/src/codex/inject.ts +114 -14
  58. package/src/codex/main-account.ts +2 -8
  59. package/src/codex/pool-rotation.ts +186 -0
  60. package/src/codex/quota.ts +92 -2
  61. package/src/codex/routing.ts +695 -106
  62. package/src/codex/runtime.ts +10 -1
  63. package/src/codex/shim.ts +4 -1
  64. package/src/codex/subagent-defaults.ts +550 -0
  65. package/src/codex/subagent-model-fallback.ts +2 -0
  66. package/src/codex/sync.ts +3 -0
  67. package/src/config.ts +574 -25
  68. package/src/generated/jawcode-model-metadata.ts +12 -12
  69. package/src/github/star-state.ts +191 -0
  70. package/src/images/artifacts.ts +516 -0
  71. package/src/images/fulfill-video.ts +163 -0
  72. package/src/images/fulfill.ts +111 -0
  73. package/src/images/index.ts +4 -0
  74. package/src/images/loop.ts +789 -0
  75. package/src/images/plan.ts +133 -0
  76. package/src/images/synthetic-tool.ts +133 -0
  77. package/src/images/types.ts +41 -0
  78. package/src/images/xai-client.ts +141 -0
  79. package/src/images/xai-video-client.ts +163 -0
  80. package/src/lib/admin-secrets.ts +25 -0
  81. package/src/lib/bun-binary-validator.d.mts +3 -0
  82. package/src/lib/bun-binary-validator.mjs +18 -0
  83. package/src/lib/bun-runtime.ts +6 -20
  84. package/src/lib/config-ownership.ts +327 -0
  85. package/src/lib/crash-guard.ts +2 -0
  86. package/src/lib/destination-policy.ts +132 -7
  87. package/src/lib/pinned-http.ts +151 -0
  88. package/src/lib/process-control.ts +2 -2
  89. package/src/lib/provider-outbound.ts +167 -0
  90. package/src/lib/provider-url.ts +14 -0
  91. package/src/lib/proxy-env.ts +18 -0
  92. package/src/lib/shadow-call.ts +30 -0
  93. package/src/lib/test-home-guard.ts +90 -0
  94. package/src/lib/win-exec.ts +12 -2
  95. package/src/lib/windows-elevation.ts +81 -3
  96. package/src/lib/windows-secret-acl.ts +189 -12
  97. package/src/lib/winsw.ts +2 -0
  98. package/src/oauth/anthropic-routing.ts +570 -0
  99. package/src/oauth/health.ts +6 -0
  100. package/src/oauth/index.ts +310 -75
  101. package/src/oauth/key-providers.ts +38 -8
  102. package/src/oauth/kimi.ts +2 -0
  103. package/src/oauth/kiro-credentials.ts +373 -12
  104. package/src/oauth/kiro.ts +424 -43
  105. package/src/oauth/login-cli.ts +33 -6
  106. package/src/oauth/store.ts +56 -4
  107. package/src/oauth/types.ts +11 -0
  108. package/src/providers/alibaba-region-migration.ts +16 -3
  109. package/src/providers/antigravity-models.ts +3 -0
  110. package/src/providers/api-keys.ts +13 -6
  111. package/src/providers/derive.ts +8 -2
  112. package/src/providers/key-failover.ts +24 -4
  113. package/src/providers/model-discovery.ts +356 -0
  114. package/src/providers/quota.ts +233 -29
  115. package/src/providers/registry.ts +125 -3
  116. package/src/responses/parser.ts +11 -0
  117. package/src/responses/state.ts +22 -8
  118. package/src/responses/tool-groups.ts +19 -0
  119. package/src/router.ts +19 -7
  120. package/src/server/auth-cors.ts +114 -24
  121. package/src/server/claude-messages.ts +8 -1
  122. package/src/server/gui-static.ts +30 -6
  123. package/src/server/images.ts +303 -9
  124. package/src/server/index.ts +77 -9
  125. package/src/server/lifecycle.ts +25 -1
  126. package/src/server/live.ts +75 -25
  127. package/src/server/management/agent-settings-routes.ts +106 -8
  128. package/src/server/management/combo-routes.ts +7 -0
  129. package/src/server/management/config-routes.ts +22 -7
  130. package/src/server/management/context.ts +11 -1
  131. package/src/server/management/logs-usage-routes.ts +167 -3
  132. package/src/server/management/model-routes.ts +46 -13
  133. package/src/server/management/oauth-account-routes.ts +163 -17
  134. package/src/server/management/provider-routes.ts +73 -10
  135. package/src/server/management/shared.ts +2 -2
  136. package/src/server/management/sidebar-routes.ts +39 -0
  137. package/src/server/management/system-restart.ts +172 -0
  138. package/src/server/management/system-routes.ts +33 -10
  139. package/src/server/management-api.ts +5 -3
  140. package/src/server/management-auth.ts +216 -0
  141. package/src/server/proxy-liveness.ts +14 -3
  142. package/src/server/responses/compact.ts +21 -13
  143. package/src/server/responses/core.ts +614 -172
  144. package/src/server/responses/upstream-error.ts +48 -0
  145. package/src/server/responses-image-gen-repair.ts +118 -0
  146. package/src/server/responses-item-id-repair.ts +10 -85
  147. package/src/server/sse-payload-rewrite.ts +116 -0
  148. package/src/server/startup-action-control.ts +30 -14
  149. package/src/server/system-env.ts +28 -10
  150. package/src/service.ts +284 -19
  151. package/src/storage/cleanup-job.ts +57 -0
  152. package/src/storage/cleanup.ts +1504 -28
  153. package/src/storage/policy-job.ts +387 -0
  154. package/src/storage/policy-scheduler.ts +40 -0
  155. package/src/storage/policy-worker.ts +53 -0
  156. package/src/storage/policy.ts +522 -0
  157. package/src/storage/restore-job.ts +253 -0
  158. package/src/storage/restore-worker.ts +52 -0
  159. package/src/storage/storage-mutation-coordinator.ts +109 -0
  160. package/src/storage/worker-lifecycle.ts +81 -0
  161. package/src/tray/windows.ts +34 -4
  162. package/src/types.ts +107 -1
  163. package/src/update/badge.ts +72 -0
  164. package/src/update/index.ts +36 -18
  165. package/src/update/job.ts +111 -16
  166. package/src/update/npm-invocation.d.mts +23 -0
  167. package/src/update/npm-invocation.mjs +94 -0
  168. package/src/usage/debug.ts +2 -0
  169. package/src/usage/expected-prices.ts +6 -5
  170. package/src/usage/log.ts +12 -0
  171. package/src/web-search/loop.ts +57 -16
  172. package/gui/dist/assets/index-CjKFJHSC.js +0 -65
  173. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -0,0 +1,151 @@
1
+ import http, { type IncomingMessage, type RequestOptions } from "node:http";
2
+ import https from "node:https";
3
+
4
+ export type PinnedAddress = { address: string; family: number };
5
+
6
+ export interface PinnedHttpGetOptions {
7
+ headers?: HeadersInit;
8
+ maxBytes?: number;
9
+ idleTimeoutMs?: number;
10
+ rejectUnauthorized?: boolean;
11
+ context?: string;
12
+ }
13
+
14
+ /**
15
+ * GET a URL through one previously validated address. The original hostname
16
+ * remains authoritative for Host, SNI, and certificate verification.
17
+ */
18
+ export function pinnedHttpGet(
19
+ url: string,
20
+ pinned: PinnedAddress,
21
+ signal?: AbortSignal,
22
+ options?: PinnedHttpGetOptions,
23
+ ): Promise<Response> {
24
+ const parsed = new URL(url);
25
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
26
+ throw new Error(`${options?.context ?? "request"} must use HTTP or HTTPS, got ${parsed.protocol}`);
27
+ }
28
+ const context = options?.context ?? "request";
29
+ const idleTimeoutMs = options?.idleTimeoutMs ?? 60_000;
30
+ const maxBytes = options?.maxBytes;
31
+ const headers = new Headers(options?.headers);
32
+ headers.set("host", parsed.host);
33
+ const requestHeaders: Record<string, string> = {};
34
+ headers.forEach((value, key) => { requestHeaders[key] = value; });
35
+
36
+ return new Promise<Response>((resolve, reject) => {
37
+ if (signal?.aborted) {
38
+ reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
39
+ return;
40
+ }
41
+
42
+ let settled = false;
43
+ const fail = (error: unknown) => {
44
+ try { req.destroy(); } catch { /* ignore */ }
45
+ if (settled) return;
46
+ settled = true;
47
+ reject(error instanceof Error ? error : new Error(String(error)));
48
+ };
49
+ const requestOptions: RequestOptions & { servername?: string } = {
50
+ protocol: parsed.protocol,
51
+ hostname: parsed.hostname,
52
+ port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
53
+ path: `${parsed.pathname}${parsed.search}`,
54
+ method: "GET",
55
+ headers: requestHeaders,
56
+ ...(parsed.protocol === "https:"
57
+ ? {
58
+ servername: parsed.hostname,
59
+ rejectUnauthorized: options?.rejectUnauthorized ?? true,
60
+ }
61
+ : {}),
62
+ lookup(_hostname, lookupOptions, callback) {
63
+ const opts = typeof lookupOptions === "function" ? undefined : lookupOptions;
64
+ const cb = typeof lookupOptions === "function" ? lookupOptions : callback;
65
+ if (!cb) return;
66
+ if (opts && typeof opts === "object" && "all" in opts && opts.all) {
67
+ (cb as (error: NodeJS.ErrnoException | null, addresses: PinnedAddress[]) => void)(
68
+ null,
69
+ [{ address: pinned.address, family: pinned.family }],
70
+ );
71
+ return;
72
+ }
73
+ (cb as (error: NodeJS.ErrnoException | null, address: string, family: 4 | 6) => void)(
74
+ null,
75
+ pinned.address,
76
+ pinned.family as 4 | 6,
77
+ );
78
+ },
79
+ };
80
+
81
+ const onResponse = (response: IncomingMessage) => {
82
+ const status = response.statusCode ?? 0;
83
+ const responseHeaders = new Headers();
84
+ for (const [key, value] of Object.entries(response.headers)) {
85
+ if (value === undefined || value === null) continue;
86
+ if (Array.isArray(value)) {
87
+ for (const item of value) responseHeaders.append(key, String(item));
88
+ } else {
89
+ responseHeaders.set(key, String(value));
90
+ }
91
+ }
92
+
93
+ if (status < 200 || status >= 300) {
94
+ try { response.destroy(); } catch { /* ignore */ }
95
+ try { req.destroy(); } catch { /* ignore */ }
96
+ if (settled) return;
97
+ settled = true;
98
+ resolve(new Response(null, { status, headers: responseHeaders }));
99
+ return;
100
+ }
101
+
102
+ let received = 0;
103
+ const stream = new ReadableStream<Uint8Array>({
104
+ start(controller) {
105
+ response.setTimeout(idleTimeoutMs, () => {
106
+ const error = new Error(`${context} stalled`);
107
+ fail(error);
108
+ try { controller.error(error); } catch { /* closed */ }
109
+ });
110
+ response.on("data", (chunk: Buffer | string) => {
111
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
112
+ received += buffer.byteLength;
113
+ if (maxBytes !== undefined && received > maxBytes) {
114
+ const error = new Error(`${context} exceeds ${maxBytes} byte cap`);
115
+ fail(error);
116
+ try { controller.error(error); } catch { /* closed */ }
117
+ return;
118
+ }
119
+ try { controller.enqueue(buffer); } catch { /* closed */ }
120
+ });
121
+ response.on("end", () => {
122
+ try { controller.close(); } catch { /* closed */ }
123
+ });
124
+ response.on("error", (error: Error) => {
125
+ fail(error);
126
+ try { controller.error(error); } catch { /* closed */ }
127
+ });
128
+ },
129
+ cancel() {
130
+ req.destroy();
131
+ },
132
+ });
133
+
134
+ if (settled) return;
135
+ settled = true;
136
+ resolve(new Response(stream, { status, headers: responseHeaders }));
137
+ };
138
+
139
+ const requestFn = parsed.protocol === "https:" ? https.request : http.request;
140
+ const req = requestFn(requestOptions, onResponse);
141
+ const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error("aborted"));
142
+ signal?.addEventListener("abort", onAbort, { once: true });
143
+ req.setTimeout(idleTimeoutMs, () => fail(new Error(`${context} timed out`)));
144
+ req.on("error", error => {
145
+ signal?.removeEventListener("abort", onAbort);
146
+ fail(error);
147
+ });
148
+ req.on("close", () => signal?.removeEventListener("abort", onAbort));
149
+ req.end();
150
+ });
151
+ }
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { loadConfig, readRuntimePort } from "../config";
3
+ import { configuredAdminToken } from "./admin-secrets";
3
4
 
4
5
  export function isProcessAlive(pid: number): boolean {
5
6
  try {
@@ -66,8 +67,7 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
66
67
  if (!runtime?.port) return false;
67
68
  const env = io.env ?? process.env;
68
69
  const headers: Record<string, string> = {};
69
- // Non-loopback binds require management auth; loopback ignores the extra header.
70
- const token = env.OPENCODEX_API_AUTH_TOKEN?.trim();
70
+ const token = configuredAdminToken(env.OPENCODEX_HOME?.trim() || undefined, env as NodeJS.ProcessEnv);
71
71
  if (token) headers["x-opencodex-api-key"] = token;
72
72
  const fetchFn = io.fetchFn ?? fetch;
73
73
  try {
@@ -0,0 +1,167 @@
1
+ import type { OcxProviderConfig } from "../types";
2
+ import {
3
+ assessUrlDestination,
4
+ DestinationDnsResolutionError,
5
+ providerAllowsPrivateNetwork,
6
+ providerDestinationConfigError,
7
+ resolvePublicAddresses,
8
+ } from "./destination-policy";
9
+ import { pinnedHttpGet } from "./pinned-http";
10
+ import { outboundProxyConfigured } from "./proxy-env";
11
+ import { publicProviderBaseUrl } from "./provider-url";
12
+
13
+ type ProviderGetInit = Omit<RequestInit, "body" | "method" | "redirect">;
14
+ type ProviderOutboundConfig = Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork"> & {
15
+ fetch?: typeof globalThis.fetch;
16
+ };
17
+ export interface ProviderOutboundDependencies {
18
+ resolveAddresses?: typeof resolvePublicAddresses;
19
+ pinnedGet?: typeof pinnedHttpGet;
20
+ }
21
+
22
+ export class ProviderOutboundPolicyError extends Error {
23
+ override readonly name = "ProviderOutboundPolicyError";
24
+ }
25
+
26
+ function pickPinnedAddress(addresses: Array<{ address: string; family: number }>): { address: string; family: number } {
27
+ return addresses.find(address => address.family === 4) ?? addresses[0]!;
28
+ }
29
+
30
+ function configuredProxyFor(): boolean {
31
+ return outboundProxyConfigured();
32
+ }
33
+
34
+ function normalizeProxyHostname(hostname: string): string {
35
+ const normalized = hostname.trim().toLowerCase().replace(/\.+$/, "");
36
+ return normalized.startsWith("[") && normalized.endsWith("]")
37
+ ? normalized.slice(1, -1)
38
+ : normalized;
39
+ }
40
+
41
+ function noProxyMatches(url: URL): boolean {
42
+ const raw = process.env.NO_PROXY ?? process.env.no_proxy ?? "";
43
+ const hostname = normalizeProxyHostname(url.hostname);
44
+ const port = url.port || (url.protocol === "https:" ? "443" : "80");
45
+ for (const rawEntry of raw.split(",")) {
46
+ let entry = rawEntry.trim().toLowerCase();
47
+ if (!entry) continue;
48
+ if (entry === "*") return true;
49
+ entry = entry.replace(/^https?:\/\//, "").split("/", 1)[0]!;
50
+
51
+ let entryHost = entry;
52
+ let entryPort = "";
53
+ const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(entry);
54
+ if (bracketed) {
55
+ entryHost = bracketed[1]!;
56
+ entryPort = bracketed[2] ?? "";
57
+ } else if ((entry.match(/:/g)?.length ?? 0) === 1) {
58
+ const separator = entry.lastIndexOf(":");
59
+ const possiblePort = entry.slice(separator + 1);
60
+ if (/^\d+$/.test(possiblePort)) {
61
+ entryHost = entry.slice(0, separator);
62
+ entryPort = possiblePort;
63
+ }
64
+ }
65
+ if (entryPort && entryPort !== port) continue;
66
+ entryHost = normalizeProxyHostname(entryHost.replace(/^\*?\./, ""));
67
+ if (!entryHost) continue;
68
+ if (hostname === entryHost || hostname.endsWith(`.${entryHost}`)) return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ let proxyBoundaryWarned = false;
74
+ let proxyDnsDegradationWarned = false;
75
+
76
+ function warnProxyBoundaryOnce(): void {
77
+ if (proxyBoundaryWarned) return;
78
+ proxyBoundaryWarned = true;
79
+ console.warn(
80
+ "[opencodex] Provider outbound proxy mode preserves Bun proxy/NO_PROXY routing and validates "
81
+ + "the URL plus available local DNS results; the final route and peer cannot be pinned locally.",
82
+ );
83
+ }
84
+
85
+ function warnProxyDnsDegradationOnce(): void {
86
+ if (proxyDnsDegradationWarned) return;
87
+ proxyDnsDegradationWarned = true;
88
+ console.warn(
89
+ "[opencodex] Local DNS could not resolve a proxied provider hostname; continuing after URL/literal checks. "
90
+ + "The proxy-selected peer cannot be verified or pinned locally.",
91
+ );
92
+ }
93
+
94
+ export async function providerRedirectError(response: Response, requestUrl: string): Promise<string | null> {
95
+ if (response.status < 300 || response.status >= 400) return null;
96
+ try { await response.body?.cancel(); } catch { /* ignore cancellation failures */ }
97
+ const location = response.headers.get("location");
98
+ let target = "the final upstream URL";
99
+ if (location) {
100
+ try { target = publicProviderBaseUrl(new URL(location, requestUrl).toString()); } catch { /* keep fallback */ }
101
+ }
102
+ return `provider returned ${response.status} redirect to ${target}; configure the final provider URL directly`;
103
+ }
104
+
105
+ export async function providerOutboundGet(
106
+ name: string,
107
+ provider: ProviderOutboundConfig,
108
+ url: string,
109
+ init: ProviderGetInit = {},
110
+ dependencies: ProviderOutboundDependencies = {},
111
+ ): Promise<Response> {
112
+ if (provider.fetch) {
113
+ // A caller-owned executor cannot be peer-pinned here. This branch keeps literal/config
114
+ // checks and redirect blocking, but does not provide the resolved-address guarantees of
115
+ // the built-in transport. Main-request migration must define that executor contract first.
116
+ const assessment = assessUrlDestination(url);
117
+ if (assessment?.kind === "metadata" || assessment?.kind === "link-local" || assessment?.kind === "unspecified") {
118
+ throw new ProviderOutboundPolicyError(`provider URL targets ${assessment.detail}`);
119
+ }
120
+ const allowPrivate = providerAllowsPrivateNetwork(name, provider);
121
+ if (!allowPrivate) {
122
+ const destinationError = providerDestinationConfigError(name, {
123
+ baseUrl: url,
124
+ allowPrivateNetwork: false,
125
+ });
126
+ if (destinationError) throw new ProviderOutboundPolicyError(destinationError);
127
+ }
128
+ return provider.fetch(url, { ...init, method: "GET", redirect: "manual" });
129
+ }
130
+ const parsed = new URL(url);
131
+ const proxyConfigured = configuredProxyFor();
132
+ const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses;
133
+ const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet;
134
+ const allowPrivate = providerAllowsPrivateNetwork(name, provider);
135
+ let resolved: Awaited<ReturnType<typeof resolvePublicAddresses>>;
136
+ try {
137
+ resolved = await resolveAddresses(url, {
138
+ context: "provider URL",
139
+ allowPrivateNetwork: allowPrivate,
140
+ });
141
+ } catch (error) {
142
+ const dnsResolutionFailed = error instanceof DestinationDnsResolutionError
143
+ || (error instanceof Error && error.name === "DestinationDnsResolutionError");
144
+ if (!dnsResolutionFailed) {
145
+ throw new ProviderOutboundPolicyError(error instanceof Error ? error.message : "provider destination was blocked");
146
+ }
147
+ if (!proxyConfigured) throw error;
148
+ warnProxyBoundaryOnce();
149
+ warnProxyDnsDegradationOnce();
150
+ return globalThis.fetch(url, { ...init, method: "GET", redirect: "manual" });
151
+ }
152
+ if (proxyConfigured && !resolved.privateNetwork) {
153
+ warnProxyBoundaryOnce();
154
+ return globalThis.fetch(url, { ...init, method: "GET", redirect: "manual" });
155
+ }
156
+ if (proxyConfigured && resolved.privateNetwork && !noProxyMatches(parsed)) {
157
+ const hostname = normalizeProxyHostname(parsed.hostname);
158
+ throw new Error(
159
+ `provider URL resolves to a private-network destination; add ${hostname} to NO_PROXY before using allowPrivateNetwork with an outbound proxy`,
160
+ );
161
+ }
162
+ return pinnedGet(url, pickPinnedAddress(resolved.addresses), init.signal ?? undefined, {
163
+ headers: init.headers,
164
+ rejectUnauthorized: true,
165
+ context: "provider response",
166
+ });
167
+ }
@@ -0,0 +1,14 @@
1
+ /** Strip credentials and non-routing URL components before displaying a provider URL. */
2
+ export function publicProviderBaseUrl(baseUrl: string): string {
3
+ try {
4
+ const parsed = new URL(baseUrl.trim());
5
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "(invalid URL)";
6
+ parsed.username = "";
7
+ parsed.password = "";
8
+ parsed.search = "";
9
+ parsed.hash = "";
10
+ return parsed.toString().replace(/\/$/, baseUrl.endsWith("/") ? "/" : "");
11
+ } catch {
12
+ return "(invalid URL)";
13
+ }
14
+ }
@@ -0,0 +1,18 @@
1
+ export const OUTBOUND_PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"] as const;
2
+ export const PROXY_ENV_KEYS = [...OUTBOUND_PROXY_ENV_KEYS, "NO_PROXY"] as const;
3
+
4
+ export type ProxyEnvKey = typeof PROXY_ENV_KEYS[number];
5
+ export type ProxyEnvMap = Record<string, string | undefined>;
6
+
7
+ export function proxyEnvPresent(
8
+ key: ProxyEnvKey,
9
+ env: ProxyEnvMap = process.env,
10
+ ): boolean {
11
+ return Boolean(env[key]?.trim() || env[key.toLowerCase()]?.trim());
12
+ }
13
+
14
+ export function outboundProxyConfigured(
15
+ env: ProxyEnvMap = process.env,
16
+ ): boolean {
17
+ return OUTBOUND_PROXY_ENV_KEYS.some(key => proxyEnvPresent(key, env));
18
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shadow-call intercept source models.
3
+ *
4
+ * Codex's hard-coded helper model is not stable across client versions: it was
5
+ * `gpt-5.4-mini` up to 0.144.x and became `gpt-5.6-luna` in 0.145.0. The
6
+ * intercept therefore matches a prefix SET, and every surface that names the
7
+ * intercepted model (management API, GUI badges/tooltips, CLI) reads it from
8
+ * here instead of hard-coding a slug that goes stale on the next client bump.
9
+ */
10
+ export const DEFAULT_SHADOW_SOURCE_MODELS = ["gpt-5.4-mini", "gpt-5.6-luna"] as const;
11
+
12
+ /** Normalize a persisted `sourceModels` override; falls back to the defaults. */
13
+ export function shadowSourceModels(configured?: unknown): string[] {
14
+ const configuredStrings = Array.isArray(configured)
15
+ ? configured
16
+ .filter((v): v is string => typeof v === "string" && v.trim() !== "")
17
+ .map(v => v.trim())
18
+ : [];
19
+ return configuredStrings.length > 0 ? configuredStrings : [...DEFAULT_SHADOW_SOURCE_MODELS];
20
+ }
21
+
22
+ /**
23
+ * True when `modelId` is one of Codex's helper/shadow source models.
24
+ * Routed ids (`provider/model`) are hard-excluded: a shadow call is always a
25
+ * bare native slug, and an explicit routed selection must never be hijacked.
26
+ */
27
+ export function isShadowSourceModel(modelId: string, configured?: unknown): boolean {
28
+ if (modelId.includes("/")) return false;
29
+ return shadowSourceModels(configured).some(prefix => modelId.startsWith(prefix));
30
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Fail-closed protection for the user's REAL OpenCodex home while tests run.
3
+ *
4
+ * A management-route unit test once passed an in-memory fixture config to a handler
5
+ * that persisted it through the process-global writer, replacing a live 41KB,
6
+ * ten-provider `~/.opencodex/config.json` with an 874-byte fixture on a real machine.
7
+ * Credentials survived only because the store files are separate; the providers were
8
+ * recoverable only because an unrelated backup snapshot happened to exist.
9
+ * (devlog `_plan/260730_codex_rs_upstream_v2_live_handoff/070`.)
10
+ *
11
+ * Two properties matter more than breadth here:
12
+ *
13
+ * 1. It must be INERT in production. Guessing "am I a test?" from ecosystem variables
14
+ * like NODE_ENV would brick `NODE_ENV=test ocx ...` for a user who did nothing
15
+ * wrong — worse than the bug it prevents. Arming requires OCX_TEST_HOME_GUARD=1,
16
+ * which only this repository's test preload sets.
17
+ * 2. It must fail CLOSED for code nobody has written yet. So it denies ONE path — the
18
+ * captured production home — instead of allow-listing known-good test directories.
19
+ * An allowlist would have to be opted into, and the test that forgets is exactly
20
+ * how this incident happened.
21
+ */
22
+ import { homedir } from "node:os";
23
+ import { dirname, join, relative, resolve } from "node:path";
24
+ import { realpathSync } from "node:fs";
25
+
26
+ const GUARD_ENV = "OCX_TEST_HOME_GUARD";
27
+ /**
28
+ * Set by `scripts/test.ts` to the ORIGINAL home before it hands the child a rewritten
29
+ * HOME. On that path `homedir()` already points at the sandbox by the time this module
30
+ * loads, so the true home is only knowable from this hand-off.
31
+ */
32
+ const REAL_HOME_ENV = "OCX_REAL_HOME";
33
+
34
+ /**
35
+ * Resolve symlinks so two spellings of one location compare equal — macOS hands out
36
+ * `/var/folders/...` whose realpath is `/private/var/folders/...` — and so a path that
37
+ * merely *points* at the protected home cannot slip past a string comparison. A path
38
+ * that does not exist yet canonicalizes through its nearest existing ancestor, which is
39
+ * the common case for a config file about to be created.
40
+ */
41
+ function canonicalize(path: string): string {
42
+ let current = resolve(path);
43
+ const unresolved: string[] = [];
44
+ for (;;) {
45
+ try {
46
+ return join(realpathSync.native(current), ...unresolved.reverse());
47
+ } catch {
48
+ const parent = dirname(current);
49
+ if (parent === current) return resolve(path);
50
+ unresolved.push(current.slice(parent.length + 1));
51
+ current = parent;
52
+ }
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Captured ONCE at module load, before any harness replaces HOME/USERPROFILE. Reading
58
+ * `homedir()` later would return the sandbox and leave the real home unprotected — the
59
+ * guard would be perfectly inverted while its tests still looked green.
60
+ */
61
+ const PROTECTED_HOME = canonicalize(
62
+ join(process.env[REAL_HOME_ENV]?.trim() || homedir(), ".opencodex"),
63
+ );
64
+
65
+ /** The production home this process protects. Exported for the guard's own tests. */
66
+ export function protectedHomeForTests(): string {
67
+ return PROTECTED_HOME;
68
+ }
69
+
70
+ export function isTestHomeGuardArmed(): boolean {
71
+ return process.env[GUARD_ENV] === "1";
72
+ }
73
+
74
+ /**
75
+ * Throw when an armed test process is about to write the real OpenCodex home.
76
+ *
77
+ * Call FIRST inside a writer, before any mkdir/chmod/write, so a rejected write leaves
78
+ * nothing behind. Silent no-op when disarmed (production) or when `dir` is any other
79
+ * location, including a suite's own `mkdtemp` fixture — no registration required, which
80
+ * is what keeps the 54 existing suites that write config working untouched.
81
+ */
82
+ export function assertNotRealHomeUnderTest(dir: string): void {
83
+ if (!isTestHomeGuardArmed()) return;
84
+ if (canonicalize(dir) !== PROTECTED_HOME) return;
85
+ throw new Error(
86
+ `refusing to write the real OpenCodex home (${PROTECTED_HOME}) from a test process. `
87
+ + "Point OPENCODEX_HOME at a temp directory for this test, or inject persistence "
88
+ + "instead of calling the global writer (see devlog 260730_codex_rs_upstream_v2_live_handoff/070).",
89
+ );
90
+ }
@@ -44,8 +44,18 @@ export function resolveWindowsCommand(command: string, deps: ResolveDeps = {}):
44
44
  if (win32.extname(command) || command.includes("\\") || command.includes("/") || win32.isAbsolute(command)) {
45
45
  return command;
46
46
  }
47
- const exts = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
48
- for (const dir of (env.PATH ?? env.Path ?? "").split(win32.delimiter).filter(Boolean)) {
47
+ // Windows environment variables are case-insensitive, and a spawned child can
48
+ // arrive with `Path`, `PATH`, or both depending on who built its env. Reading
49
+ // only two fixed spellings silently resolved against the wrong list once a
50
+ // caller added a second casing, so match however the key is spelled.
51
+ const lookup = (name: string): string | undefined => {
52
+ const direct = env[name] ?? env[name.toUpperCase()] ?? env[name.toLowerCase()];
53
+ if (direct !== undefined) return direct;
54
+ const key = Object.keys(env).find(k => k.toLowerCase() === name.toLowerCase());
55
+ return key ? env[key] : undefined;
56
+ };
57
+ const exts = (lookup("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
58
+ for (const dir of (lookup("PATH") ?? "").split(win32.delimiter).filter(Boolean)) {
49
59
  for (const ext of exts) {
50
60
  const candidate = win32.join(dir, command + ext.toLowerCase());
51
61
  if (exists(candidate)) return candidate;
@@ -18,9 +18,13 @@ export function setWindowsElevationSpawnForTests(next: ElevationSpawn | null): v
18
18
 
19
19
  type GetSystemDirectoryW = (buffer: Pointer, size: number) => number;
20
20
  type TrustedSystemDirectoryResolver = () => string;
21
+ type IsUserAnAdmin = () => number;
22
+ type WindowsElevationProbe = () => boolean | null;
21
23
 
22
24
  let getSystemDirectoryWFn: GetSystemDirectoryW | null | undefined;
23
25
  let trustedSystemDirectoryResolverForTests: TrustedSystemDirectoryResolver | null = null;
26
+ let isUserAnAdminFn: (() => boolean) | null | undefined;
27
+ let windowsElevationProbeForTests: WindowsElevationProbe | null = null;
24
28
 
25
29
  /** Test-only seam to replace GetSystemDirectoryW-backed resolution. */
26
30
  export function setTrustedWindowsSystemDirectoryResolverForTests(
@@ -29,6 +33,49 @@ export function setTrustedWindowsSystemDirectoryResolverForTests(
29
33
  trustedSystemDirectoryResolverForTests = next;
30
34
  }
31
35
 
36
+ /** Test-only seam for the locale-independent current-token elevation probe. */
37
+ export function setWindowsElevationProbeForTests(next: WindowsElevationProbe | null): void {
38
+ windowsElevationProbeForTests = next;
39
+ }
40
+
41
+ function loadIsUserAnAdmin(): (() => boolean) | null {
42
+ if (isUserAnAdminFn !== undefined) return isUserAnAdminFn;
43
+ if (process.platform !== "win32") {
44
+ isUserAnAdminFn = null;
45
+ return null;
46
+ }
47
+ try {
48
+ const lib = dlopen("shell32.dll", {
49
+ IsUserAnAdmin: {
50
+ args: [],
51
+ // Win32 BOOL is a signed 32-bit integer, not C/C++ bool.
52
+ returns: "i32",
53
+ },
54
+ });
55
+ const isUserAnAdmin = lib.symbols.IsUserAnAdmin as IsUserAnAdmin;
56
+ isUserAnAdminFn = () => isUserAnAdmin() !== 0;
57
+ } catch {
58
+ isUserAnAdminFn = null;
59
+ }
60
+ return isUserAnAdminFn;
61
+ }
62
+
63
+ /**
64
+ * Probe the effective Windows token without parsing localized command output.
65
+ * `null` fails closed: callers must retain the original scheduler error rather
66
+ * than infer that an unknown token state needs elevation.
67
+ */
68
+ export function isCurrentWindowsProcessElevated(): boolean | null {
69
+ if (windowsElevationProbeForTests) return windowsElevationProbeForTests();
70
+ const isUserAnAdmin = loadIsUserAnAdmin();
71
+ if (!isUserAnAdmin) return null;
72
+ try {
73
+ return isUserAnAdmin();
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
32
79
  function loadGetSystemDirectoryW(): GetSystemDirectoryW | null {
33
80
  if (getSystemDirectoryWFn !== undefined) return getSystemDirectoryWFn;
34
81
  if (process.platform !== "win32") {
@@ -252,6 +299,34 @@ export function schtasksOperationFromArgs(args: string[]): WindowsSchtasksOperat
252
299
  return "other";
253
300
  }
254
301
 
302
+ function schedulerExitStatus(error: unknown): number | null {
303
+ if (!error || typeof error !== "object") return null;
304
+ const status = (error as { status?: unknown }).status;
305
+ return typeof status === "number" && Number.isInteger(status) ? status : null;
306
+ }
307
+
308
+ /**
309
+ * The owned scheduler install has a fixed shape. Restrict the locale-independent
310
+ * fallback to that shape so unrelated schtasks failures cannot request elevation.
311
+ */
312
+ function isOwnedSchedulerCreate(args: string[]): boolean {
313
+ const normalized = args.map(arg => arg.toLowerCase());
314
+ const taskIndex = normalized.indexOf("/tn");
315
+ const xmlIndex = normalized.indexOf("/xml");
316
+ return normalized[0] === "/create"
317
+ && normalized[taskIndex + 1] === "opencodex-proxy"
318
+ && taskIndex > 0
319
+ && xmlIndex > 0
320
+ && Boolean(args[xmlIndex + 1])
321
+ && normalized.includes("/f");
322
+ }
323
+
324
+ function isWindowsSchtasksAccessDeniedError(error: unknown, args: string[]): boolean {
325
+ if (!isOwnedSchedulerCreate(args)) return false;
326
+ return isWindowsAccessDeniedError(error)
327
+ || (schedulerExitStatus(error) === 1 && isCurrentWindowsProcessElevated() === false);
328
+ }
329
+
255
330
  /** Structured Task Scheduler failure that survives formatting and process boundaries. */
256
331
  export class WindowsSchtasksError extends Error {
257
332
  readonly code = "WINDOWS_SCHTASKS_ERROR" as const;
@@ -287,7 +362,8 @@ export class WindowsElevationError extends Error {
287
362
  /** Replace raw schtasks access-denied output with dashboard-friendly guidance. */
288
363
  export function formatWindowsSchtasksError(error: unknown, args: string[]): string {
289
364
  const operation = schtasksOperationFromArgs(args);
290
- const accessDenied = isWindowsAccessDeniedError(error);
365
+ const ownedCreateAccessDenied = isWindowsSchtasksAccessDeniedError(error, args);
366
+ const accessDenied = ownedCreateAccessDenied || isWindowsAccessDeniedError(error);
291
367
  if (!accessDenied) {
292
368
  return error instanceof Error ? error.message : String(error);
293
369
  }
@@ -297,7 +373,7 @@ export function formatWindowsSchtasksError(error: unknown, args: string[]): stri
297
373
  `Command: schtasks ${argsText}`,
298
374
  "Approve the Windows UAC prompt to install the background service, or run `ocx service install` from an elevated PowerShell window.",
299
375
  ].join(" ");
300
- if (operation === "create") {
376
+ if (operation === "create" && ownedCreateAccessDenied) {
301
377
  return `${guidance}\n${WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED_MARKER}`;
302
378
  }
303
379
  return guidance;
@@ -306,7 +382,9 @@ export function formatWindowsSchtasksError(error: unknown, args: string[]): stri
306
382
  export function toWindowsSchtasksError(error: unknown, args: string[]): WindowsSchtasksError {
307
383
  if (error instanceof WindowsSchtasksError) return error;
308
384
  const operation = schtasksOperationFromArgs(args);
309
- const reason: WindowsSchtasksFailureReason = isWindowsAccessDeniedError(error) ? "access-denied" : "other";
385
+ const reason: WindowsSchtasksFailureReason = isWindowsSchtasksAccessDeniedError(error, args)
386
+ ? "access-denied"
387
+ : "other";
310
388
  return new WindowsSchtasksError(operation, reason, formatWindowsSchtasksError(error, args));
311
389
  }
312
390