@bitkyc08/opencodex 2.8.0 → 2.8.2-preview.20260731

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.
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-BDjpkcRN.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-GC0Vlu1Z.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-BHsKRFh9.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.8.0",
3
+ "version": "2.8.2-preview.20260731",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -250,6 +250,40 @@ function usesNativeAnthropicEndpoint(provider: OcxProviderConfig): boolean {
250
250
  }
251
251
  }
252
252
 
253
+ /** Normalize provider baseUrl paths ending in `/`, `/v1`, or `/v1/messages` to `{origin}/v1/messages`. */
254
+ export function anthropicMessagesUrl(baseUrl: string): string {
255
+ try {
256
+ new URL(baseUrl);
257
+ } catch {
258
+ throw new Error(`anthropic provider has malformed baseUrl: ${baseUrl}`);
259
+ }
260
+ const trimmed = baseUrl.trim().replace(/\/+$/, "");
261
+ const root = trimmed.replace(/\/v1\/messages\/?$/i, "").replace(/\/v1\/?$/i, "").replace(/\/+$/, "");
262
+ return `${root}/v1/messages`;
263
+ }
264
+
265
+ function synthesizeToolUseId(): string {
266
+ return `toolu_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`;
267
+ }
268
+
269
+ function toolUseArguments(input: unknown): string {
270
+ if (typeof input === "string") {
271
+ const trimmed = input.trim();
272
+ if (!trimmed) return "{}";
273
+ try {
274
+ JSON.parse(trimmed);
275
+ return trimmed;
276
+ } catch {
277
+ // A tool call's arguments must be a JSON object. Re-encoding an unparseable string as a
278
+ // JSON *string* is the double-encoding #765 reports: the caller then receives
279
+ // `"get weather"` where an object was required and the tool call is unusable either way.
280
+ // An empty object at least fails in the tool's own argument validation.
281
+ return "{}";
282
+ }
283
+ }
284
+ return JSON.stringify(input ?? {});
285
+ }
286
+
253
287
  function anthropicKeyUsesBearer(provider: OcxProviderConfig): boolean {
254
288
  return provider.apiKeyTransport === "bearer";
255
289
  }
@@ -680,8 +714,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
680
714
  else if (typeof tc === "object" && "name" in tc) body.tool_choice = { type: "tool", name: toolNames.toWire(resolveToolChoiceWireName(parsed.context.tools, tc.name)) };
681
715
  }
682
716
 
683
- const base = provider.baseUrl.replace(/\/v1\/?$/, "");
684
- const url = `${base}/v1/messages`;
717
+ const url = anthropicMessagesUrl(provider.baseUrl);
685
718
  const unresolvedPlaceholder = url.match(/\{[^}]*\}/)?.[0];
686
719
  if (unresolvedPlaceholder) {
687
720
  throw new Error(`anthropic baseUrl contains unresolved ${unresolvedPlaceholder}`);
@@ -773,7 +806,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
773
806
  if (!block) break;
774
807
  currentBlockType = block.type;
775
808
  if (block.type === "tool_use") {
776
- currentToolCallId = block.id ?? "";
809
+ currentToolCallId = block.id ?? synthesizeToolUseId();
777
810
  currentToolCallName = toolNames.fromWire(block.name ?? "");
778
811
  yield { type: "tool_call_start", id: currentToolCallId, name: currentToolCallName };
779
812
  }
@@ -799,7 +832,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
799
832
  // Arrives once, just before the thinking block's content_block_stop; block-scoped
800
833
  // so a stray signature on a non-thinking block can never be captured.
801
834
  yield { type: "thinking_signature", signature: delta.signature };
802
- } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
835
+ } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && currentBlockType === "tool_use") {
803
836
  yield { type: "tool_call_delta", arguments: delta.partial_json };
804
837
  }
805
838
  break;
@@ -831,12 +864,13 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
831
864
  }
832
865
  }
833
866
  if (!emittedDone) {
867
+ // Fail closed on transport EOF. Compatible providers may omit message_stop after message_delta.stop_reason.
834
868
  if (pendingStopReason !== undefined) {
835
869
  const stopReason = pendingStopReason === "max_tokens"
836
870
  ? "max_tokens"
837
871
  : pendingStopReason === "refusal" || pendingStopReason === "content_filter"
838
872
  ? "content_filter"
839
- : undefined;
873
+ : pendingStopReason;
840
874
  emittedDone = true;
841
875
  yield {
842
876
  type: "done",
@@ -867,8 +901,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
867
901
  } else if (block.type === "redacted_thinking" && typeof block.data === "string") {
868
902
  events.push({ type: "redacted_thinking", data: block.data });
869
903
  } else if (block.type === "tool_use") {
870
- events.push({ type: "tool_call_start", id: block.id ?? "", name: toolNames.fromWire(block.name ?? "") });
871
- events.push({ type: "tool_call_delta", arguments: JSON.stringify(block.input ?? {}) });
904
+ const id = block.id ?? synthesizeToolUseId();
905
+ events.push({ type: "tool_call_start", id, name: toolNames.fromWire(block.name ?? "") });
906
+ events.push({ type: "tool_call_delta", arguments: toolUseArguments(block.input) });
872
907
  events.push({ type: "tool_call_end" });
873
908
  }
874
909
  }
@@ -206,10 +206,10 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
206
206
  }));
207
207
  // "" instead of null: strict validators (xAI: "Each message must have at least one
208
208
  // content element", langchain#34140) reject content-less assistant history entries.
209
- if (!chatMsg.content) chatMsg.content = "";
209
+ if (!chatMsg.content) chatMsg.content = emptyAssistantContent(provider);
210
210
  }
211
211
  if (chatMsg.reasoning_content !== undefined && chatMsg.content === undefined && chatMsg.tool_calls === undefined) {
212
- chatMsg.content = "";
212
+ chatMsg.content = emptyAssistantContent(provider);
213
213
  }
214
214
  out.push(chatMsg);
215
215
  pendingToolCalls = wireToolCalls.map(({ tc, id }) => ({ id, name: namespacedToolName(tc.namespace, tc.name) }));
@@ -238,7 +238,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
238
238
  const name = safeToolName(msg.toolName);
239
239
  out.push({
240
240
  role: "assistant",
241
- content: "",
241
+ content: emptyAssistantContent(provider),
242
242
  tool_calls: [{
243
243
  id: toolCallId,
244
244
  type: "function",
@@ -372,6 +372,43 @@ function isKimiSchemaTarget(provider: OcxProviderConfig): boolean {
372
372
  }
373
373
  }
374
374
 
375
+ // Volcengine Ark regional endpoints. Ark validates an assistant message's text field as a
376
+ // REQUIRED parameter and treats "" as absent, so a tool-call-only assistant in history 400s with
377
+ // `MissingParameter: input.content.text` (#796). Every other OpenAI-compatible provider accepts
378
+ // "", and xAI actively requires it ("Each message must have at least one content element"), so
379
+ // the two contracts are in direct conflict and this cannot be a global change.
380
+ const VOLCENGINE_ARK_HOSTNAMES = new Set([
381
+ "ark.cn-beijing.volces.com",
382
+ "ark.ap-southeast.volces.com",
383
+ ]);
384
+
385
+ function isVolcengineArkTarget(provider: OcxProviderConfig): boolean {
386
+ try {
387
+ return VOLCENGINE_ARK_HOSTNAMES.has(new URL(provider.baseUrl).hostname);
388
+ } catch {
389
+ return false;
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Placeholder content for an assistant history entry carrying only tool calls or reasoning.
395
+ *
396
+ * UNVERIFIED HYPOTHESIS for Ark. The reported error names `input.content.text`, a nested path,
397
+ * which suggests Ark wants the structured content form `[{type:"text",text:""}]` rather than a
398
+ * bare string — no string value, `""` or `" "`, exposes a `content.text` path at all. But Ark's
399
+ * published examples only show array content for MULTIMODAL USER input, never for an assistant
400
+ * history entry, so this shape is inferred from the error message and not confirmed by the docs
401
+ * or by a live request. The empty inner text at least adds no tokens either way.
402
+ *
403
+ * Confirm against a real Ark endpoint before relying on this; #796 records what is still missing.
404
+ *
405
+ * Every other provider keeps the bare `""`, which xAI's validator specifically requires ("Each
406
+ * message must have at least one content element"), so this cannot be applied globally.
407
+ */
408
+ function emptyAssistantContent(provider: OcxProviderConfig): string | { type: "text"; text: string }[] {
409
+ return isVolcengineArkTarget(provider) ? [{ type: "text", text: "" }] : "";
410
+ }
411
+
375
412
  /**
376
413
  * Kimi requires function.parameters.type to be exactly "object" at the root.
377
414
  * Codex tools with oneOf/anyOf schemas omit the root type, causing 400 errors.
@@ -800,12 +837,26 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
800
837
  if (buffer.length > 0) {
801
838
  if ((yield* handleDataLine(buffer)) === "terminate") return;
802
839
  }
803
- yield* flushToolCalls();
804
840
  // Reader EOF. A graceful close shows at least one terminal signal: `[DONE]` (returns above),
805
841
  // a non-null finish_reason (sawFinish), or a trailing usage chunk (providers emit usage only
806
842
  // at end-of-generation). If NONE of those were seen, the stream was cut mid-flight — fail
807
843
  // closed so the bridge emits a classified response.failed rather than a silent truncation.
844
+ //
845
+ // Checked BEFORE flushToolCalls(), because that helper emits tool_call_end and there is no
846
+ // taking it back: a half-assembled argument string would reach the client as a completed
847
+ // call. Tool calls are buffered here (unlike the Anthropic adapter, which forwards
848
+ // fragments live), so this adapter can still decide.
808
849
  const sawFinish = finishReason !== undefined;
850
+ if (!sawFinish && pendingToolCalls.length > 0) {
851
+ debugProviderDiagnostic("openai-chat", "stream-truncated", {
852
+ finishReason: null,
853
+ hadUsage: pendingUsage !== undefined,
854
+ pendingToolCalls: pendingToolCalls.length,
855
+ });
856
+ yield { type: "error", message: "upstream stream ended mid tool call without a terminal signal — possible truncation" };
857
+ return;
858
+ }
859
+ yield* flushToolCalls();
809
860
  if (!sawFinish && pendingUsage === undefined) {
810
861
  debugProviderDiagnostic("openai-chat", "stream-truncated", {
811
862
  finishReason: finishReason ?? null,
@@ -10,7 +10,7 @@ import {
10
10
  type DesktopProfile,
11
11
  } from "../claude/desktop-profile";
12
12
  import { writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p";
13
- import { filterCatalogVisibleModels, visibleNativeSlugs } from "../codex/catalog";
13
+ import { filterCatalogVisibleModels, desktopVisibleNativeSlugs } from "../codex/catalog";
14
14
  import { buildClaudeDesktopState, fetchAllModels } from "../server/management-api";
15
15
  import { findLiveProxy } from "../server/proxy-liveness";
16
16
 
@@ -42,7 +42,7 @@ async function applyProfile(profile: DesktopProfile, mode: Desktop3pConfigMode):
42
42
  }));
43
43
  const result = writeDesktop3pConfig(
44
44
  live?.port ?? config.port ?? 10100,
45
- [...visibleNativeSlugs(config)],
45
+ [...desktopVisibleNativeSlugs(config)],
46
46
  routed,
47
47
  config.apiKeys?.[0]?.key,
48
48
  mode,
package/src/cli/init.ts CHANGED
@@ -8,11 +8,28 @@ import type { OcxConfig, OcxProviderConfig } from "../types";
8
8
 
9
9
  function createPrompt(): { ask(question: string): Promise<string>; close(): void } {
10
10
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
11
+ let closed = false;
12
+ rl.on("close", () => { closed = true; });
11
13
  return {
12
14
  ask(question: string): Promise<string> {
13
- return new Promise(resolve => rl.question(question, resolve));
15
+ return new Promise((resolve, reject) => {
16
+ if (closed) {
17
+ reject(new Error("stdin closed before the prompt could be answered"));
18
+ return;
19
+ }
20
+ const onClose = () => {
21
+ reject(new Error("stdin reached EOF while waiting for input"));
22
+ };
23
+ rl.once("close", onClose);
24
+ rl.question(question, answer => {
25
+ rl.off("close", onClose);
26
+ resolve(answer);
27
+ });
28
+ });
29
+ },
30
+ close() {
31
+ if (!closed) rl.close();
14
32
  },
15
- close() { rl.close(); },
16
33
  };
17
34
  }
18
35
 
@@ -83,115 +100,125 @@ export function cleanupOpenAiTierBackupAfterInit(configPath = getConfigPath()):
83
100
 
84
101
  export async function runInit(): Promise<void> {
85
102
  const prompt = createPrompt();
86
- console.log("\n🔧 opencodex (ocx) setup\n");
87
-
88
- const providers = buildInitProviders();
89
- printMenu(providers);
90
-
91
- const choice = await prompt.ask("\nSelect default provider (number): ");
92
- const idx = parseInt(choice, 10) - 1;
93
-
94
- let providerName: string;
95
- let providerConfig: OcxProviderConfig;
96
- let oauthHint = false;
97
-
98
- if (idx >= 0 && idx < providers.length) {
99
- const p = providers[idx];
100
- providerName = p.id;
101
- console.log(`\n📡 ${p.label}`);
102
- console.log(` Base URL: ${p.baseUrl}`);
103
-
104
- if (p.kind === "forward") {
105
- providerConfig = { adapter: p.adapter, baseUrl: p.baseUrl, authMode: "forward" };
106
- console.log(" No API key needed forwards your existing `codex login`.");
107
- } else if (p.kind === "oauth") {
108
- providerConfig = { adapter: p.adapter, baseUrl: p.baseUrl, authMode: "oauth", ...(p.defaultModel ? { defaultModel: p.defaultModel } : {}) };
109
- oauthHint = true;
110
- } else {
111
- // key + local: collect a key (local usually blank).
112
- if (p.dashboardUrl) console.log(` 🔑 Get your key: ${p.dashboardUrl}`);
113
- // Template URL with placeholders (e.g. Cloudflare's {account_id}) needs a resolved value.
114
- let baseUrl = p.baseUrl;
115
- if (/\{[^}]*\}/.test(baseUrl)) {
116
- const resolved = (await prompt.ask(` Your endpoint URL (${baseUrl}): `)).trim();
117
- if (!resolved) {
118
- console.error(" A resolved URL is required — replace the {placeholder} with your actual value.");
119
- process.exit(1);
103
+ try {
104
+ console.log("\n🔧 opencodex (ocx) setup\n");
105
+
106
+ const providers = buildInitProviders();
107
+ printMenu(providers);
108
+
109
+ const choice = await prompt.ask("\nSelect default provider (number): ");
110
+ const idx = parseInt(choice, 10) - 1;
111
+
112
+ let providerName: string;
113
+ let providerConfig: OcxProviderConfig;
114
+ let oauthHint = false;
115
+
116
+ if (idx >= 0 && idx < providers.length) {
117
+ const p = providers[idx];
118
+ providerName = p.id;
119
+ console.log(`\n📡 ${p.label}`);
120
+ console.log(` Base URL: ${p.baseUrl}`);
121
+
122
+ if (p.kind === "forward") {
123
+ providerConfig = { adapter: p.adapter, baseUrl: p.baseUrl, authMode: "forward" };
124
+ console.log(" No API key needed forwards your existing `codex login`.");
125
+ } else if (p.kind === "oauth") {
126
+ providerConfig = { adapter: p.adapter, baseUrl: p.baseUrl, authMode: "oauth", ...(p.defaultModel ? { defaultModel: p.defaultModel } : {}) };
127
+ oauthHint = true;
128
+ } else {
129
+ // key + local: collect a key (local usually blank).
130
+ if (p.dashboardUrl) console.log(` 🔑 Get your key: ${p.dashboardUrl}`);
131
+ // Template URL with placeholders (e.g. Cloudflare's {account_id}) needs a resolved value.
132
+ let baseUrl = p.baseUrl;
133
+ if (/\{[^}]*\}/.test(baseUrl)) {
134
+ const resolved = (await prompt.ask(` Your endpoint URL (${baseUrl}): `)).trim();
135
+ if (!resolved) {
136
+ console.error(" A resolved URL is required — replace the {placeholder} with your actual value.");
137
+ process.exit(1);
138
+ }
139
+ baseUrl = resolved;
120
140
  }
121
- baseUrl = resolved;
141
+ const env = envKeyFor(p.id);
142
+ const hint = p.kind === "local" ? "API key (usually blank — press Enter): " : `API key (paste, or env var $${env}): `;
143
+ const apiKey = (await prompt.ask(`\n${hint}`)).trim();
144
+ const modelChoice = (await prompt.ask(`Default model${p.defaultModel ? ` [${p.defaultModel}]` : " (optional)"}: `)).trim();
145
+ const defaultModel = modelChoice || p.defaultModel;
146
+ providerConfig = {
147
+ adapter: p.adapter,
148
+ baseUrl,
149
+ ...(p.kind === "key" ? { apiKey: apiKey || `\${${env}}` } : apiKey ? { apiKey } : {}),
150
+ ...(defaultModel ? { defaultModel } : {}),
151
+ };
152
+ // Apply the catalog's models / vision classification (same enrichment as the GUI).
153
+ enrichProviderFromCatalog(p.id, providerConfig);
154
+ }
155
+ } else {
156
+ providerName = (await prompt.ask("Provider name: ")).trim();
157
+ if (!isValidProviderName(providerName)) {
158
+ console.error("Provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key.");
159
+ process.exit(1);
122
160
  }
123
- const env = envKeyFor(p.id);
124
- const hint = p.kind === "local" ? "API key (usually blank — press Enter): " : `API key (paste, or env var $${env}): `;
125
- const apiKey = (await prompt.ask(`\n${hint}`)).trim();
126
- const modelChoice = (await prompt.ask(`Default model${p.defaultModel ? ` [${p.defaultModel}]` : " (optional)"}: `)).trim();
127
- const defaultModel = modelChoice || p.defaultModel;
161
+ const baseUrl = await prompt.ask("Base URL (e.g. http://localhost:11434/v1): ");
162
+ const adapter = await prompt.ask("Adapter [openai-chat]: ") || "openai-chat";
163
+ const apiKey = await prompt.ask("API key (optional): ");
164
+ const defaultModel = await prompt.ask("Default model: ");
128
165
  providerConfig = {
129
- adapter: p.adapter,
130
- baseUrl,
131
- ...(p.kind === "key" ? { apiKey: apiKey || `\${${env}}` } : apiKey ? { apiKey } : {}),
132
- ...(defaultModel ? { defaultModel } : {}),
166
+ adapter: adapter.trim(),
167
+ baseUrl: baseUrl.trim(),
168
+ ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}),
169
+ ...(defaultModel.trim() ? { defaultModel: defaultModel.trim() } : {}),
133
170
  };
134
- // Apply the catalog's models / vision classification (same enrichment as the GUI).
135
- enrichProviderFromCatalog(p.id, providerConfig);
136
- }
137
- } else {
138
- providerName = (await prompt.ask("Provider name: ")).trim();
139
- if (!isValidProviderName(providerName)) {
140
- console.error("Provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key.");
141
- prompt.close();
142
- process.exit(1);
143
171
  }
144
- const baseUrl = await prompt.ask("Base URL (e.g. http://localhost:11434/v1): ");
145
- const adapter = await prompt.ask("Adapter [openai-chat]: ") || "openai-chat";
146
- const apiKey = await prompt.ask("API key (optional): ");
147
- const defaultModel = await prompt.ask("Default model: ");
148
- providerConfig = {
149
- adapter: adapter.trim(),
150
- baseUrl: baseUrl.trim(),
151
- ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}),
152
- ...(defaultModel.trim() ? { defaultModel: defaultModel.trim() } : {}),
153
- };
154
- }
155
172
 
156
- const portStr = await prompt.ask("\nProxy port [10100]: ");
157
- const port = parseInt(portStr, 10) || 10100;
173
+ const portStr = await prompt.ask("\nProxy port [10100]: ");
174
+ const port = parseInt(portStr, 10) || 10100;
158
175
 
159
- const config: OcxConfig = {
160
- ...getDefaultConfig(),
161
- port,
162
- providers: { [providerName]: providerConfig },
163
- defaultProvider: providerName,
164
- };
176
+ const config: OcxConfig = {
177
+ ...getDefaultConfig(),
178
+ port,
179
+ providers: { [providerName]: providerConfig },
180
+ defaultProvider: providerName,
181
+ };
165
182
 
166
- saveConfig(config);
167
- // Init writes a fresh config, so a stale pre-migration backup from a previous
168
- // installation would make the next `ocx start` crash on a stale-backup
169
- // collision (issue #257). But only a STALE backup (unparseable, or already a
170
- // post-migration v2 snapshot) may be deleted; a backup that still parses as a
171
- // valid pre-migration (v1) config is a user-intentional rollback point and is
172
- // preserved by renaming it out of the collision path (sol review 260722).
173
- cleanupOpenAiTierBackupAfterInit();
174
- console.log(`\n✅ Config saved to ~/.opencodex/config.json`);
175
- if (oauthHint) console.log(`🔐 Authenticate this provider with: ocx login ${providerName}`);
176
-
177
- const injectAnswer = await prompt.ask("Inject into Codex config.toml? [Y/n]: ");
178
- if (injectAnswer.trim().toLowerCase() !== "n") {
179
- console.log("Fetching available models from provider...");
180
- const result = await injectCodexConfig(port, config);
181
- console.log(result.success ? `✅ ${result.message}` : `⚠️ ${result.message}`);
182
- }
183
+ saveConfig(config);
184
+ // Init writes a fresh config, so a stale pre-migration backup from a previous
185
+ // installation would make the next `ocx start` crash on a stale-backup
186
+ // collision (issue #257). But only a STALE backup (unparseable, or already a
187
+ // post-migration v2 snapshot) may be deleted; a backup that still parses as a
188
+ // valid pre-migration (v1) config is a user-intentional rollback point and is
189
+ // preserved by renaming it out of the collision path (sol review 260722).
190
+ cleanupOpenAiTierBackupAfterInit();
191
+ console.log(`\n✅ Config saved to ~/.opencodex/config.json`);
192
+ if (oauthHint) console.log(`🔐 Authenticate this provider with: ocx login ${providerName}`);
193
+
194
+ const injectAnswer = await prompt.ask("Inject into Codex config.toml? [Y/n]: ");
195
+ if (injectAnswer.trim().toLowerCase() !== "n") {
196
+ console.log("Fetching available models from provider...");
197
+ const result = await injectCodexConfig(port, config);
198
+ console.log(result.success ? `✅ ${result.message}` : `⚠️ ${result.message}`);
199
+ }
183
200
 
184
- const shimAnswer = await prompt.ask("Install Codex autostart shim? [Y/n]: ");
185
- if (shimAnswer.trim().toLowerCase() !== "n") {
186
- try {
187
- const { installCodexShim } = await import("../codex/shim");
188
- const result = installCodexShim();
189
- console.log(result.installed ? `✅ ${result.message}` : `⚠️ ${result.message}`);
190
- } catch (err) {
191
- console.log(`⚠️ Codex autostart shim skipped: ${err instanceof Error ? err.message : String(err)}`);
201
+ const shimAnswer = await prompt.ask("Install Codex autostart shim? [Y/n]: ");
202
+ if (shimAnswer.trim().toLowerCase() !== "n") {
203
+ try {
204
+ const { installCodexShim } = await import("../codex/shim");
205
+ const result = installCodexShim();
206
+ console.log(result.installed ? `✅ ${result.message}` : `⚠️ ${result.message}`);
207
+ } catch (err) {
208
+ console.log(`⚠️ Codex autostart shim skipped: ${err instanceof Error ? err.message : String(err)}`);
209
+ }
192
210
  }
193
- }
194
211
 
195
- console.log(`\n🚀 Setup complete! Run 'ocx start' to start the proxy.`);
196
- prompt.close();
212
+ console.log(`\n🚀 Setup complete! Run 'ocx start' to start the proxy.`);
213
+ } catch (error) {
214
+ const message = error instanceof Error ? error.message : String(error);
215
+ if (/stdin (closed|reached EOF)/i.test(message)) {
216
+ console.error(`\n❌ ${message}. Re-run \`ocx init\` in an interactive terminal.`);
217
+ process.exitCode = 1;
218
+ return;
219
+ }
220
+ throw error;
221
+ } finally {
222
+ prompt.close();
223
+ }
197
224
  }
@@ -123,6 +123,12 @@ export function visibleNativeSlugs(config: Pick<OcxConfig, "disabledModels">): s
123
123
  return nativeOpenAiSlugs().filter(slug => !disabled.has(slug));
124
124
  }
125
125
 
126
+ /** Native slugs exposed to Claude Desktop show/export/apply (opt-out via claudeCode.desktopNativeModels). */
127
+ export function desktopVisibleNativeSlugs(config: Pick<OcxConfig, "claudeCode" | "disabledModels">): string[] {
128
+ if (config.claudeCode?.desktopNativeModels === false) return [];
129
+ return visibleNativeSlugs(config);
130
+ }
131
+
126
132
  export function nativeModelRows(config: Pick<OcxConfig, "disabledModels">): Array<{ slug: string; disabled: boolean; contextWindow?: number }> {
127
133
  const disabled = disabledNativeSlugs(config);
128
134
  return NATIVE_OPENAI_MODELS.map(slug => {
@@ -1,8 +1,8 @@
1
1
  // AUTO-SPLIT facade: original catalog.ts body moved into ./catalog/* modules.
2
2
  // Public surface preserved exactly; importers keep using "src/codex/catalog".
3
- export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
3
+ export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
4
4
  export type { CatalogModel, MultiAgentMode } from "./catalog/parsing";
5
- export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs } from "./catalog/metadata";
5
+ export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs } from "./catalog/metadata";
6
6
  export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled";
7
7
  export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort";
8
8
  export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch";
@@ -130,7 +130,18 @@ function registryAllowsPrivateNetwork(name: string): boolean {
130
130
  return getProviderRegistryEntry(name)?.allowPrivateNetworkByDefault === true;
131
131
  }
132
132
 
133
- /** True when the provider config or registry default admits private/loopback destinations. */
133
+ /**
134
+ * Whether a provider may reach loopback/private addresses.
135
+ *
136
+ * Two sources, and both have to be consulted at every boundary: the operator's explicit
137
+ * `allowPrivateNetwork`, and the registry's `allowPrivateNetworkByDefault` for entries that are
138
+ * local BY DEFINITION (Ollama, vLLM, LM Studio, LiteLLM). Config validation already read both;
139
+ * outbound discovery read only the first, so a stock Ollama entry passed validation and was then
140
+ * refused at the fetch (#758).
141
+ *
142
+ * This grants nothing new. Metadata, link-local and unspecified destinations are rejected before
143
+ * this is consulted, and a provider without either source still cannot reach a private address.
144
+ */
134
145
  export function providerAllowsPrivateNetwork(
135
146
  name: string,
136
147
  provider: Pick<OcxProviderConfig, "allowPrivateNetwork">,
@@ -117,6 +117,9 @@ export async function providerOutboundGet(
117
117
  if (assessment?.kind === "metadata" || assessment?.kind === "link-local" || assessment?.kind === "unspecified") {
118
118
  throw new ProviderOutboundPolicyError(`provider URL targets ${assessment.detail}`);
119
119
  }
120
+ // Registry defaults count here too, not just the operator flag: a stock Ollama entry is
121
+ // local by definition and previously passed config validation only to be refused at the
122
+ // fetch (#758). Metadata/link-local/unspecified were already rejected above.
120
123
  const allowPrivate = providerAllowsPrivateNetwork(name, provider);
121
124
  if (!allowPrivate) {
122
125
  const destinationError = providerDestinationConfigError(name, {
package/src/lib/winsw.ts CHANGED
@@ -165,6 +165,12 @@ function runWinsw(args: string[]): string {
165
165
 
166
166
  /** `install /p` prompts for the service-account password on the console — stdin must be inherited. */
167
167
  function runWinswInteractive(args: string[]): void {
168
+ if (!process.stdin.isTTY) {
169
+ throw new Error(
170
+ "WinSW install requires an interactive console to prompt for the service account password. "
171
+ + "Run `ocx service install --native` from an elevated Command Prompt or PowerShell window, not a hidden or piped session.",
172
+ );
173
+ }
168
174
  execFileSync(winswExePath(), args, { stdio: "inherit" });
169
175
  }
170
176
 
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { isAbsolute, join, posix, win32 } from "node:path";
5
5
  import { Database } from "bun:sqlite";
@@ -169,6 +169,77 @@ export function resolveKiroCliNativeSessionEntries(
169
169
  return [{ location: "kiro-cli-linux-data", path: posix.join(home, ".local", "share", "kiro-cli", "data.sqlite3") }];
170
170
  }
171
171
 
172
+ /**
173
+ * Resolve the absolute kiro-cli executable for spawn/login helpers.
174
+ *
175
+ * Pure + parameterized like `resolveKiroCliNativeSessionEntries` so Windows install layouts can be
176
+ * covered from any host. PATH remains the first choice; only when bare `kiro-cli` is missing do we
177
+ * fall back to the platform-native install directories next to the session database.
178
+ *
179
+ * Windows: official MSI installs to `C:\Program Files\Kiro-Cli\kiro-cli.exe`, while some local
180
+ * installs keep the binary next to `%LOCALAPPDATA%\Kiro-Cli\data.sqlite3`.
181
+ * macOS/Linux: prefer PATH, then the usual user-local bin directories.
182
+ */
183
+ export function resolveKiroCliExecutable(
184
+ inputs: KiroCliNativeInputs & {
185
+ pathEntries?: string[];
186
+ exists?: (path: string) => boolean;
187
+ isFile?: (path: string) => boolean;
188
+ },
189
+ ): string {
190
+ const exists = inputs.exists ?? existsSync;
191
+ // A directory named `kiro-cli` on PATH satisfies existsSync and would then be handed to
192
+ // spawn(), which fails with EACCES at login instead of falling through to the next candidate.
193
+ // When a caller injects `exists` it owns the whole filesystem view, so the real stat would
194
+ // reject every synthetic path; such callers inject `isFile` too when they care about it.
195
+ const isFile = inputs.isFile ?? (inputs.exists ? () => true : ((path: string) => {
196
+ try {
197
+ return statSync(path).isFile();
198
+ } catch {
199
+ return false;
200
+ }
201
+ }));
202
+ const pathEntries = inputs.pathEntries
203
+ ?? (inputs.env.PATH ?? inputs.env.Path ?? "").split(inputs.platform === "win32" ? ";" : ":")
204
+ .map(entry => entry.trim())
205
+ .filter(Boolean);
206
+
207
+ const pathCandidates = inputs.platform === "win32"
208
+ ? pathEntries.flatMap(entry => [
209
+ win32.join(entry, "kiro-cli.exe"),
210
+ win32.join(entry, "kiro-cli"),
211
+ ])
212
+ : pathEntries.map(entry => posix.join(entry, "kiro-cli"));
213
+
214
+ const installCandidates: string[] = [];
215
+ if (inputs.platform === "win32") {
216
+ const localBase = inputs.env.LOCALAPPDATA?.trim()
217
+ || (inputs.env.USERPROFILE?.trim() ? win32.join(inputs.env.USERPROFILE.trim(), "AppData", "Local") : "")
218
+ || win32.join(inputs.home, "AppData", "Local");
219
+ const programFiles = inputs.env["ProgramFiles"]?.trim() || "C:\\Program Files";
220
+ installCandidates.push(
221
+ win32.join(localBase, "Kiro-Cli", "kiro-cli.exe"),
222
+ win32.join(programFiles, "Kiro-Cli", "kiro-cli.exe"),
223
+ );
224
+ } else if (inputs.platform === "darwin") {
225
+ installCandidates.push(
226
+ posix.join(inputs.home, ".local", "bin", "kiro-cli"),
227
+ "/usr/local/bin/kiro-cli",
228
+ "/opt/homebrew/bin/kiro-cli",
229
+ );
230
+ } else {
231
+ installCandidates.push(
232
+ posix.join(inputs.home, ".local", "bin", "kiro-cli"),
233
+ "/usr/local/bin/kiro-cli",
234
+ );
235
+ }
236
+
237
+ for (const candidate of [...pathCandidates, ...installCandidates]) {
238
+ if (exists(candidate) && isFile(candidate)) return candidate;
239
+ }
240
+ return inputs.platform === "win32" ? "kiro-cli.exe" : "kiro-cli";
241
+ }
242
+
172
243
  function nativeKiroCliSessionEntries(): Array<{ location: KiroCliNativeLocation; path: string }> {
173
244
  // Only the stores that `kiro-cli logout` / `kiro-cli login` themselves mutate. Import fallbacks
174
245
  // (Amazon Q / SSO cache) and KIROCLI_DB_PATH selectors must not be snapshotted for rollback.