@bitkyc08/opencodex 2.10.1 → 2.10.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.
Files changed (57) hide show
  1. package/bin/ocx.mjs +18 -9
  2. package/gui/dist/assets/index-BKVqyYqT.js +70 -0
  3. package/gui/dist/assets/{index-Cd6_PBKn.css → index-Ca_3269W.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/commandcode-color.svg +1 -0
  6. package/gui/dist/provider-icons/openai.svg +1 -1
  7. package/package.json +1 -1
  8. package/src/adapters/command-code.ts +453 -0
  9. package/src/adapters/google.ts +3 -0
  10. package/src/cli/claude.ts +37 -22
  11. package/src/cli/index.ts +16 -3
  12. package/src/cli/launcher-context.ts +77 -0
  13. package/src/codex/admission.ts +1 -1
  14. package/src/codex/app-server-processes.ts +44 -1
  15. package/src/codex/catalog/sync.ts +22 -3
  16. package/src/codex/catalog-write-serialization.ts +1 -1
  17. package/src/codex/codex-write-lock.ts +1 -1
  18. package/src/codex/convergence-types.ts +1 -1
  19. package/src/codex/desired-state.ts +27 -7
  20. package/src/codex/history-job.ts +1 -1
  21. package/src/codex/history-lock.ts +1 -1
  22. package/src/codex/history-worker.ts +1 -1
  23. package/src/codex/internal/history-writer.ts +1 -1
  24. package/src/codex/transition-state.ts +1 -1
  25. package/src/codex/user-identity.ts +1 -1
  26. package/src/config.ts +6 -1
  27. package/src/integrations/config-io.ts +1 -1
  28. package/src/integrations/journal.ts +1 -1
  29. package/src/integrations/merge.ts +1 -1
  30. package/src/integrations/ownership.ts +1 -1
  31. package/src/integrations/registry.ts +1 -1
  32. package/src/integrations/serialize.ts +1 -1
  33. package/src/integrations/state.ts +1 -1
  34. package/src/integrations/store.ts +1 -1
  35. package/src/integrations/writer.ts +1 -1
  36. package/src/lib/bounded-body.ts +3 -1
  37. package/src/lib/bun-runtime.ts +21 -17
  38. package/src/lib/bun-stream-caps.ts +1 -1
  39. package/src/lib/local-management-attestation.ts +51 -0
  40. package/src/lib/shadow-call.ts +4 -4
  41. package/src/oauth/command-code.ts +239 -0
  42. package/src/oauth/health.ts +46 -2
  43. package/src/oauth/index.ts +38 -3
  44. package/src/providers/command-code-efforts.ts +85 -0
  45. package/src/providers/google-vertex-location.ts +14 -0
  46. package/src/providers/registry.ts +138 -26
  47. package/src/routing/capability.ts +1 -0
  48. package/src/server/adapter-resolve.ts +3 -0
  49. package/src/server/auth-cors.ts +6 -1
  50. package/src/server/index.ts +44 -2
  51. package/src/server/management/integration-routes.ts +1 -1
  52. package/src/server/management/native-integration-routes.ts +2 -2
  53. package/src/server/responses/core.ts +13 -9
  54. package/src/storage/scanner.ts +1 -1
  55. package/src/types.ts +6 -0
  56. package/src/usage/log.ts +1 -1
  57. package/gui/dist/assets/index-ChZQsmBY.js +0 -70
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-ChZQsmBY.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-Cd6_PBKn.css">
19
+ <script type="module" crossorigin src="/assets/index-BKVqyYqT.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-Ca_3269W.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
@@ -0,0 +1 @@
1
+ <svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Command Code</title><g fill="#8C4EDD" transform="translate(1.5,1.5) scale(0.237639) translate(-27.6163,-27.6163)"><path fill-rule="evenodd" clip-rule="evenodd" d="M98.8049 27.6163C89.3295 27.6163 81.6214 35.3243 81.6214 44.7997V52.1641H61.9832V44.7997C61.9832 35.3243 54.2752 27.6163 44.7997 27.6163C35.3243 27.6163 27.6163 35.3243 27.6163 44.7997C27.6163 54.2752 35.3243 61.9832 44.7997 61.9832H52.1641V81.6214H44.7997C35.3243 81.6214 27.6163 89.3295 27.6163 98.8049C27.6163 108.28 35.3243 115.988 44.7997 115.988C54.2752 115.988 61.9832 108.28 61.9832 98.8049V91.4406H81.6214V98.8049C81.6214 108.28 89.3295 115.988 98.8049 115.988C108.28 115.988 115.988 108.28 115.988 98.8049C115.988 89.3295 108.28 81.6214 98.8049 81.6214H91.4406V61.9832H98.8049C108.28 61.9832 115.988 54.2752 115.988 44.7997C115.988 35.3243 108.28 27.6163 98.8049 27.6163ZM91.4406 52.1641V44.7997C91.4406 40.7248 94.73 37.4354 98.8049 37.4354C102.88 37.4354 106.169 40.7248 106.169 44.7997C106.169 48.8747 102.88 52.1641 98.8049 52.1641H91.4406ZM44.7997 52.1641C40.7248 52.1641 37.4354 48.8747 37.4354 44.7997C37.4354 40.7248 40.7248 37.4354 44.7997 37.4354C48.8747 37.4354 52.1641 40.7248 52.1641 44.7997V52.1641H44.7997ZM61.9832 81.6214V61.9832H81.6214V81.6214H61.9832ZM98.8049 106.169C94.73 106.169 91.4406 102.88 91.4406 98.8049V91.4406H98.8049C102.88 91.4406 106.169 94.73 106.169 98.8049C106.169 102.88 102.88 106.169 98.8049 106.169ZM44.7997 106.169C40.7248 106.169 37.4354 102.88 37.4354 98.8049C37.4354 94.73 40.7248 91.4406 44.7997 91.4406H52.1641V98.8049C52.1641 102.88 48.8747 106.169 44.7997 106.169Z"/></g></svg>
@@ -1 +1 @@
1
- <svg fill="#412991" fill-rule="evenodd" style="flex:none;line-height:1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>
1
+ <svg fill="#10A37F" fill-rule="evenodd" style="flex:none;line-height:1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.10.1",
3
+ "version": "2.10.2",
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",
@@ -0,0 +1,453 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { execFile as execFileCallback } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ import { opendir } from "node:fs/promises";
5
+ import type { AdapterEvent, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTool, OcxUsage } from "../types";
6
+ import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice, toolChoiceAliases } from "../types";
7
+ import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base";
8
+ import type { TranslatorBudget } from "../lib/translator-budget";
9
+ import { readBoundedResponseBody } from "../lib/bounded-body";
10
+ import { configuredReasoningEfforts } from "../reasoning-effort";
11
+ import { commandCodeReasoningEfforts, refreshCommandCodeReasoningEfforts } from "../providers/command-code-efforts";
12
+ import { identifyRoutedModel } from "./identity";
13
+ import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge";
14
+ import { parseDataUrl } from "./image";
15
+
16
+ // Retain the short ids emitted by the first local integration. New requests use the live catalog's
17
+ // provider-native IDs directly; this map is compatibility-only and is not a model fallback list.
18
+ const COMMAND_CODE_MODEL_ALIASES: Readonly<Record<string, string>> = {
19
+ "deepseek-v4-flash": "deepseek/deepseek-v4-flash",
20
+ "kimi-k3": "moonshotai/Kimi-K3",
21
+ "glm-5.2": "zai-org/GLM-5.2",
22
+ };
23
+
24
+ /** Flatten tool-result content for the text-only wire output, keeping an `[image]` marker per image part in content order. */
25
+ function toolResultText(content: string | OcxContentPart[]): string {
26
+ if (typeof content === "string") return content;
27
+ return content.map(part => (part.type === "text" ? part.text : "[image]")).join("");
28
+ }
29
+
30
+ /** Best-effort media type from a remote https URL extension, e.g. image/png. */
31
+ function mediaTypeFromUrl(url: string): string | undefined {
32
+ const ext = url.split(/[?#]/)[0]!.match(/\.([a-zA-Z0-9]+)$/)?.[1]?.toLowerCase();
33
+ if (!ext) return undefined;
34
+ const known: Record<string, string> = {
35
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", bmp: "image/bmp",
36
+ };
37
+ return known[ext];
38
+ }
39
+
40
+ /** Wire image part: `{ type, image, mediaType? }` per the /alpha/generate ModelMessage schema. */
41
+ function wireImagePart(imageUrl: string): Record<string, unknown> {
42
+ const parsed = parseDataUrl(imageUrl);
43
+ const mediaType = parsed?.mediaType ?? mediaTypeFromUrl(imageUrl);
44
+ return { type: "image", image: imageUrl, ...(mediaType ? { mediaType } : {}) };
45
+ }
46
+
47
+ function wireMessages(messages: OcxMessage[]): Array<Record<string, unknown>> {
48
+ const out: Array<Record<string, unknown>> = [];
49
+ for (const message of messages) {
50
+ if (message.role === "assistant") {
51
+ const content: Array<Record<string, unknown>> = [];
52
+ for (const part of message.content) {
53
+ if (part.type === "text") content.push({ type: "text", text: part.text });
54
+ else if (part.type === "thinking") content.push({ type: "reasoning", text: part.thinking });
55
+ else content.push({ type: "tool-call", toolCallId: part.id, toolName: namespacedToolName(part.namespace, part.name), input: part.arguments });
56
+ }
57
+ out.push({ role: "assistant", content });
58
+ continue;
59
+ }
60
+ if (message.role === "toolResult") {
61
+ out.push({ role: "tool", content: [{
62
+ type: "tool-result",
63
+ toolCallId: message.toolCallId,
64
+ toolName: namespacedToolName(message.toolNamespace, message.toolName),
65
+ output: { type: message.isError ? "error-text" : "text", value: toolResultText(message.content) },
66
+ }] });
67
+ // The proprietary wire's tool-result output is text-only; image parts returned by a
68
+ // tool (e.g. Codex view_image) cannot live inside it. Carry them in a follow-up user
69
+ // message using the same image encoding as the user branch so the bytes reach the model.
70
+ const images = typeof message.content === "string" ? [] : message.content.filter(part => part.type === "image");
71
+ if (images.length > 0) {
72
+ out.push({ role: "user", content: images.map(part => wireImagePart((part as { imageUrl: string }).imageUrl)) });
73
+ }
74
+ continue;
75
+ }
76
+ const content: Array<Record<string, unknown>> = [];
77
+ if (typeof message.content === "string") content.push({ type: "text", text: message.content });
78
+ else for (const part of message.content) {
79
+ if (part.type === "text") content.push({ type: "text", text: part.text });
80
+ else content.push(wireImagePart(part.imageUrl));
81
+ }
82
+ out.push({ role: "user", content });
83
+ }
84
+ return out;
85
+ }
86
+
87
+ function visibleTools(parsed: OcxParsedRequest): OcxTool[] {
88
+ const choice = parsed.options.toolChoice;
89
+ if (choice === "none") return [];
90
+ const tools = parsed.context.tools ?? [];
91
+ if (isAllowedToolChoice(choice)) {
92
+ const allowed = new Set(choice.allowedTools);
93
+ return tools.filter(tool => toolAllowedByChoice(tool, allowed));
94
+ }
95
+ if (choice && typeof choice !== "string") {
96
+ return tools.filter(tool => toolChoiceAliases(tool).includes(choice.name));
97
+ }
98
+ return tools;
99
+ }
100
+
101
+ function toolChoiceInstruction(parsed: OcxParsedRequest): string | undefined {
102
+ const choice = parsed.options.toolChoice;
103
+ if (choice === "required" || (isAllowedToolChoice(choice) && choice.mode === "required")) {
104
+ return "Tool choice is required for this turn. Make at least one call from the advertised tool catalog before answering.";
105
+ }
106
+ if (choice && typeof choice !== "string" && !isAllowedToolChoice(choice)) {
107
+ // Resolve the forced name (bare, namespace__name, or namespace.name) to the advertised
108
+ // wire name so the instruction names a tool that is actually in the catalog.
109
+ const wireName = resolveToolChoiceWireName(parsed.context.tools, choice.name);
110
+ return `Tool choice is required for this turn. Call the advertised tool named ${wireName} before answering.`;
111
+ }
112
+ return undefined;
113
+ }
114
+
115
+ function wireTools(tools: OcxTool[]): Array<Record<string, unknown>> {
116
+ return tools.map(tool => ({
117
+ name: namespacedToolName(tool.namespace, tool.name),
118
+ description: tool.description,
119
+ input_schema: tool.parameters,
120
+ }));
121
+ }
122
+
123
+ function currentWorkingDirectory(): string | undefined {
124
+ try { return process.cwd(); } catch { return undefined; }
125
+ }
126
+
127
+ /** Cap the workspace listing so a large directory does not ship every entry name upstream. */
128
+ const MAX_WORKSPACE_STRUCTURE_ENTRIES = 64;
129
+ /** Cap how many recent commit subjects the config carries. */
130
+ const MAX_RECENT_COMMITS = 8;
131
+ /** Cap each recent commit entry to keep the request bounded even for long subjects. */
132
+ const MAX_RECENT_COMMIT_LENGTH = 512;
133
+ /** Cap the git status text sent upstream. */
134
+ const MAX_GIT_STATUS_LENGTH = 2048;
135
+ /** Keep collected workspace/git metadata fresh for this long (ms) so repeated requests reuse it. */
136
+ const WORKSPACE_METADATA_TTL_MS = 30_000;
137
+
138
+ /** Derive a bounded project slug from the working directory for the `x-project-slug` header. */
139
+ function projectSlug(cwd: string): string {
140
+ return cwd.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase().slice(0, 64) || "workspace";
141
+ }
142
+
143
+ interface GitWorkspaceInfo {
144
+ isGitRepo: boolean;
145
+ currentBranch: string;
146
+ mainBranch: string;
147
+ gitStatus: string;
148
+ recentCommits: string[];
149
+ }
150
+
151
+ const workspaceMetadataCache = new Map<string, { collectedAt: number; value: GitWorkspaceInfo }>();
152
+
153
+ const execFile = promisify(execFileCallback);
154
+
155
+ /** Best-effort git metadata for the upstream config contract; every read fails safe and stays off the event loop. */
156
+ async function gitWorkspaceInfo(cwd: string | undefined): Promise<GitWorkspaceInfo> {
157
+ const fallback: GitWorkspaceInfo = { isGitRepo: false, currentBranch: "", mainBranch: "", gitStatus: "", recentCommits: [] };
158
+ if (!cwd) return fallback;
159
+ const cached = workspaceMetadataCache.get(cwd);
160
+ if (cached && Date.now() - cached.collectedAt < WORKSPACE_METADATA_TTL_MS) return cached.value;
161
+ const run = async (args: string[]): Promise<string> => {
162
+ try {
163
+ const { stdout } = await execFile("git", args, { cwd, encoding: "utf8", timeout: 2000, windowsHide: true });
164
+ return stdout.trim();
165
+ } catch {
166
+ return "";
167
+ }
168
+ };
169
+ const root = await run(["rev-parse", "--show-toplevel"]);
170
+ const value = root
171
+ ? {
172
+ isGitRepo: true,
173
+ currentBranch: (await run(["rev-parse", "--abbrev-ref", "HEAD"])) || "HEAD",
174
+ mainBranch: (await run(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])).replace(/^origin\//, "") || (await run(["rev-parse", "--abbrev-ref", "HEAD"])) || "",
175
+ gitStatus: (await run(["status", "--porcelain"])).slice(0, MAX_GIT_STATUS_LENGTH),
176
+ recentCommits: (await run(["log", "--oneline", `-${MAX_RECENT_COMMITS}`]))
177
+ .split("\n")
178
+ .filter(Boolean)
179
+ .slice(0, MAX_RECENT_COMMITS)
180
+ .map(commit => commit.slice(0, MAX_RECENT_COMMIT_LENGTH)),
181
+ }
182
+ : fallback;
183
+ workspaceMetadataCache.set(cwd, { collectedAt: Date.now(), value });
184
+ return value;
185
+ }
186
+
187
+ async function commandCodeConfig(cwd: string | undefined): Promise<Record<string, unknown>> {
188
+ let structure: string[] = [];
189
+ if (cwd) {
190
+ try {
191
+ // Iterate and stop after the cap instead of materializing every entry: a directory with a
192
+ // huge number of names must not stall the request path for 64 metadata rows.
193
+ const dir = await opendir(cwd);
194
+ try {
195
+ for await (const entry of dir) {
196
+ if (entry.name.startsWith(".")) continue;
197
+ structure.push(entry.name);
198
+ if (structure.length >= MAX_WORKSPACE_STRUCTURE_ENTRIES) break;
199
+ }
200
+ } finally {
201
+ await dir.close().catch(() => undefined);
202
+ }
203
+ } catch { /* workspace metadata is optional */ }
204
+ }
205
+ const git = await gitWorkspaceInfo(cwd);
206
+ return {
207
+ ...(cwd ? { workingDir: cwd } : {}),
208
+ date: new Date().toISOString().slice(0, 10),
209
+ environment: process.platform,
210
+ structure,
211
+ ...git,
212
+ };
213
+ }
214
+
215
+ function usage(value: unknown): OcxUsage | undefined {
216
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
217
+ const row = value as Record<string, unknown>;
218
+ const inputTokens = typeof row.inputTokens === "number" ? row.inputTokens : 0;
219
+ const outputTokens = typeof row.outputTokens === "number" ? row.outputTokens : 0;
220
+ const details = row.inputTokenDetails && typeof row.inputTokenDetails === "object" && !Array.isArray(row.inputTokenDetails)
221
+ ? row.inputTokenDetails as Record<string, unknown> : {};
222
+ const cachedInputTokens = typeof details.cacheReadTokens === "number" ? details.cacheReadTokens : undefined;
223
+ const cacheCreationInputTokens = typeof details.cacheWriteTokens === "number" ? details.cacheWriteTokens : undefined;
224
+ return {
225
+ inputTokens, outputTokens, totalTokens: inputTokens + outputTokens,
226
+ ...(cachedInputTokens !== undefined ? { cachedInputTokens, cacheReadInputTokens: cachedInputTokens } : {}),
227
+ ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
228
+ };
229
+ }
230
+
231
+ function eventError(value: unknown): string {
232
+ if (typeof value === "string" && value) return value;
233
+ if (value && typeof value === "object" && !Array.isArray(value)) {
234
+ const message = (value as Record<string, unknown>).message;
235
+ if (typeof message === "string" && message) return message;
236
+ }
237
+ return "Command Code stream error";
238
+ }
239
+
240
+ async function*ndjson(response: Response, budget: TranslatorBudget): AsyncGenerator<Record<string, unknown>> {
241
+ if (!response.body) throw new Error("Command Code response body missing");
242
+ const reader = response.body.getReader();
243
+ const decoder = new TextDecoder();
244
+ const encoder = new TextEncoder();
245
+ let buffer = "";
246
+ let bufferBytes = 0;
247
+ try {
248
+ for (;;) {
249
+ const { value, done } = await reader.read();
250
+ const next = buffer + decoder.decode(value, { stream: !done });
251
+ const nextBytes = encoder.encode(next).byteLength;
252
+ const reservation = budget.reserveTransient(nextBytes, { kind: "live_transient" });
253
+ buffer = next;
254
+ reservation.commitRetained();
255
+ budget.releaseRetained(bufferBytes, { kind: "live_transient" });
256
+ bufferBytes = nextBytes;
257
+ let newline = buffer.indexOf("\n");
258
+ while (newline >= 0) {
259
+ const line = buffer.slice(0, newline).trim(); buffer = buffer.slice(newline + 1);
260
+ if (line) { try { yield JSON.parse(stripEventFrame(line)) as Record<string, unknown>; } catch { /* ignore non-events */ } }
261
+ newline = buffer.indexOf("\n");
262
+ }
263
+ const residualBytes = encoder.encode(buffer).byteLength;
264
+ const residualReservation = budget.reserveTransient(residualBytes, { kind: "live_transient" });
265
+ residualReservation.commitRetained();
266
+ budget.releaseRetained(bufferBytes, { kind: "live_transient" });
267
+ bufferBytes = residualBytes;
268
+ if (done) break;
269
+ }
270
+ const final = buffer.trim();
271
+ if (final) { try { yield JSON.parse(stripEventFrame(final)) as Record<string, unknown>; } catch { /* ignore */ } }
272
+ } finally {
273
+ budget.releaseRetained(bufferBytes, { kind: "live_transient" });
274
+ try { await reader.cancel(); } catch { /* already closed */ }
275
+ reader.releaseLock();
276
+ }
277
+ }
278
+
279
+ /** The endpoint is newline-delimited JSON; defensively strip an SSE `data:` frame if the gateway ever switches shapes. */
280
+ function stripEventFrame(line: string): string {
281
+ return line.startsWith("data:") ? line.slice("data:".length).trim() : line;
282
+ }
283
+
284
+ function isReasoningEffortRejection(status: number, payload: string): boolean {
285
+ return (status === 400 || status === 422) && /reasoning[_ -]?effort|unsupported effort|invalid effort/i.test(payload);
286
+ }
287
+
288
+ function requestWithoutReasoningEffort(request: AdapterRequest): AdapterRequest | undefined {
289
+ try {
290
+ const body = JSON.parse(request.body) as { params?: Record<string, unknown> };
291
+ if (!body.params?.reasoning_effort) return undefined;
292
+ delete body.params.reasoning_effort;
293
+ return { ...request, body: JSON.stringify(body), reasoningLog: undefined };
294
+ } catch {
295
+ return undefined;
296
+ }
297
+ }
298
+
299
+ async function fetchCommandCode(request: AdapterRequest, ctx: AdapterFetchContext | undefined, executor: typeof globalThis.fetch): Promise<Response> {
300
+ const timeout = new AbortController();
301
+ const timer = setTimeout(() => timeout.abort(new DOMException("Timeout elapsed", "TimeoutError")), ctx?.timeoutMs ?? 200_000);
302
+ const callerSignal = ctx?.abortSignal ?? new AbortController().signal;
303
+ try {
304
+ return await executor(request.url, {
305
+ method: request.method,
306
+ headers: request.headers,
307
+ body: request.body,
308
+ redirect: "manual",
309
+ signal: AbortSignal.any([callerSignal, timeout.signal]),
310
+ });
311
+ } finally {
312
+ clearTimeout(timer);
313
+ }
314
+ }
315
+
316
+ function supportedCommandCodeEffort(provider: OcxProviderConfig, modelId: string, requested: string | undefined): string | undefined {
317
+ if (!requested || requested === "none") return undefined;
318
+ // Compatibility ids (deepseek-v4-flash / glm-5.2) must resolve to their canonical
319
+ // Command Code id before the effort lookup, or legacy requests silently lose the
320
+ // reasoning effort because the official table is keyed by the canonical ids.
321
+ const canonicalId = COMMAND_CODE_MODEL_ALIASES[modelId] ?? modelId;
322
+ const supported = commandCodeReasoningEfforts(canonicalId) ?? configuredReasoningEfforts(provider, canonicalId);
323
+ if (!supported) return undefined;
324
+ // Command Code's official profiles describe xhigh and ultra as the CLI labels that map to
325
+ // the wire value `max`; preserve that mapping without advertising a synthetic tier.
326
+ const wire = (requested === "xhigh" || requested === "ultra") && supported.includes("max") ? "max" : requested;
327
+ return supported.includes(wire) ? wire : undefined;
328
+ }
329
+
330
+ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderAdapter {
331
+ const executor = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch;
332
+ return {
333
+ name: "command-code",
334
+ async buildRequest(parsed: OcxParsedRequest): Promise<AdapterRequest> {
335
+ if (!provider.apiKey) throw new Error("Command Code credential missing — run ocx login command-code");
336
+ const cwd = currentWorkingDirectory();
337
+ const tools = visibleTools(parsed);
338
+ const toolNudge = buildNonOpenAIToolCatalogNudgeForTools(tools, parsed.options.toolChoice);
339
+ const choiceInstruction = toolChoiceInstruction(parsed);
340
+ const system = identifyRoutedModel([
341
+ ...(parsed.context.systemPrompt ?? []),
342
+ ...(toolNudge ? [toolNudge] : []),
343
+ ...(choiceInstruction ? [choiceInstruction] : []),
344
+ ].join("\n\n"), parsed.modelId);
345
+ const reasoningEffort = supportedCommandCodeEffort(provider, parsed.modelId, parsed.options.reasoning);
346
+ const body = {
347
+ config: await commandCodeConfig(cwd), memory: "", taste: null, skills: null,
348
+ permissionMode: "standard", mode: "agent",
349
+ params: {
350
+ model: COMMAND_CODE_MODEL_ALIASES[parsed.modelId] ?? parsed.modelId,
351
+ messages: wireMessages(parsed.context.messages),
352
+ tools: wireTools(tools),
353
+ system,
354
+ max_tokens: parsed.options.maxOutputTokens ?? provider.defaultMaxOutputTokens ?? 64_000,
355
+ // The proprietary /alpha/generate endpoint is NDJSON-stream-only; the proxy converts
356
+ // the buffered/streamed events to the client's requested shape (parsed.stream).
357
+ stream: true,
358
+ ...(parsed.options.temperature !== undefined ? { temperature: parsed.options.temperature } : {}),
359
+ ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
360
+ },
361
+ };
362
+ const headers: Record<string, string> = {
363
+ Authorization: `Bearer ${provider.apiKey}`,
364
+ "Content-Type": "application/json",
365
+ "User-Agent": "cli",
366
+ "x-command-code-version": provider.commandCodeVersion ?? "0.52.1",
367
+ "x-cli-environment": "production",
368
+ "x-taste-learning": "false",
369
+ "x-co-flag": "false",
370
+ "x-session-id": randomUUID(),
371
+ };
372
+ if (cwd) headers["x-project-slug"] = projectSlug(cwd);
373
+ return {
374
+ url: `${provider.baseUrl.replace(/\/$/, "")}/alpha/generate`, method: "POST",
375
+ headers,
376
+ body: JSON.stringify(body),
377
+ ...(reasoningEffort ? { reasoningLog: { effectiveEffort: reasoningEffort, wireField: "reasoning_effort" as const, wireValue: reasoningEffort } } : {}),
378
+ };
379
+ },
380
+ async fetchResponse(request: AdapterRequest, ctx?: AdapterFetchContext): Promise<Response> {
381
+ const response = await fetchCommandCode(request, ctx, executor);
382
+ if (response.ok) return response;
383
+ const currentEffort = (() => {
384
+ try { return (JSON.parse(request.body) as { params?: { reasoning_effort?: unknown } }).params?.reasoning_effort; } catch { return undefined; }
385
+ })();
386
+ if (typeof currentEffort !== "string") return response;
387
+ let body = "";
388
+ try {
389
+ const observed = await readBoundedResponseBody(response.clone(), { signal: ctx?.abortSignal, maxBytes: 8 * 1024 });
390
+ if (!observed.displaySafe) return response;
391
+ body = observed.text;
392
+ } catch { return response; }
393
+ if (!isReasoningEffortRejection(response.status, body)) return response;
394
+ const modelId = (() => {
395
+ try { return (JSON.parse(request.body) as { params?: { model?: unknown } }).params?.model; } catch { return undefined; }
396
+ })();
397
+ if (typeof modelId !== "string") return response;
398
+ const refreshed = await refreshCommandCodeReasoningEfforts(modelId, executor);
399
+ if (!refreshed || refreshed.includes(currentEffort)) return response;
400
+ const retry = requestWithoutReasoningEffort(request);
401
+ if (!retry) return response;
402
+ try { void response.body?.cancel(); } catch { /* already closed */ }
403
+ return fetchCommandCode(retry, ctx, executor);
404
+ },
405
+ async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
406
+ let sawFinish = false;
407
+ for await (const event of ndjson(response, budget)) {
408
+ switch (event.type) {
409
+ case "text-delta": if (typeof event.text === "string") yield { type: "text_delta", text: event.text }; break;
410
+ case "reasoning-delta": if (typeof event.text === "string") yield { type: "thinking_delta", thinking: event.text }; break;
411
+ case "tool-call": {
412
+ const id = typeof event.toolCallId === "string" ? event.toolCallId : randomUUID();
413
+ const name = typeof event.toolName === "string" ? event.toolName : "tool";
414
+ const input = event.input ?? event.args ?? {};
415
+ const argumentsText = typeof input === "string" ? input : JSON.stringify(input);
416
+ yield { type: "tool_call_start", id, name };
417
+ budget.openCall(id);
418
+ try {
419
+ const reservation = budget.reserveTransient(new TextEncoder().encode(argumentsText).byteLength, { kind: "tool_args", callId: id });
420
+ reservation.commitRetained();
421
+ yield { type: "tool_call_delta", arguments: argumentsText };
422
+ yield { type: "tool_call_end" };
423
+ } finally {
424
+ budget.closeCall(id);
425
+ }
426
+ break;
427
+ }
428
+ case "finish-step":
429
+ case "finish": {
430
+ // Both events are terminal on the current wire; streams commonly carry both, so
431
+ // only the first one emits the done. finish-step carries `usage`; finish may carry
432
+ // `totalUsage` (current flows) or `usage`.
433
+ if (sawFinish) break;
434
+ sawFinish = true;
435
+ const usageValue = event.totalUsage ?? event.usage;
436
+ const stopReason = typeof event.rawFinishReason === "string" ? event.rawFinishReason : typeof event.finishReason === "string" ? event.finishReason : undefined;
437
+ yield { type: "done", usage: usage(usageValue), stopReason };
438
+ break;
439
+ }
440
+ case "error": yield { type: "error", message: eventError(event.error), status: 502 }; break;
441
+ }
442
+ }
443
+ // A stream that ends without a finish event still needs a terminal done so the
444
+ // server does not wait on an adapter that silently stopped emitting.
445
+ if (!sawFinish) yield { type: "done", usage: undefined, stopReason: undefined };
446
+ },
447
+ async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
448
+ const events: AdapterEvent[] = [];
449
+ for await (const event of this.parseStream(response, budget)) events.push(event);
450
+ return events;
451
+ },
452
+ };
453
+ }
@@ -23,6 +23,7 @@ import { compileGoogleWireBody } from "./google-wire-compiler";
23
23
  import { identifyRoutedModel } from "./identity";
24
24
  import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay";
25
25
  import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models";
26
+ import { googleVertexLocationConfigError } from "../providers/google-vertex-location";
26
27
  import {
27
28
  isTranslatorBudgetExceededError,
28
29
  retainTranslatedEventBatch,
@@ -436,6 +437,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
436
437
  if (!project) throw new Error("Vertex AI requires a project id (provider.project or GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT).");
437
438
  const location = provider.location || process.env.GOOGLE_CLOUD_LOCATION;
438
439
  if (!location) throw new Error("Vertex AI requires a location (provider.location or GOOGLE_CLOUD_LOCATION).");
440
+ const locationError = googleVertexLocationConfigError(location);
441
+ if (locationError) throw new Error(locationError);
439
442
  const host = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`;
440
443
  const url = `https://${host}/v1/projects/${project}/locations/${location}/publishers/google/models/${parsed.modelId}:${method}${streamParam}`;
441
444
  const token = await getVertexAccessToken();
package/src/cli/claude.ts CHANGED
@@ -18,6 +18,7 @@ import { configuredAdminToken } from "../lib/admin-secrets";
18
18
  import { PROXY_MARKER, ownAdmissionTokens, defaultAuthDetectDeps, detectClaudeAuth, type AuthDetectDeps } from "../claude/auth-detect";
19
19
  import { resolveClaudeAuthMode } from "../claude/auth-mode";
20
20
  import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
21
+ import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "./launcher-context";
21
22
 
22
23
  export interface ClaudeLaunchEnv {
23
24
  [key: string]: string | undefined;
@@ -27,13 +28,18 @@ export interface ClaudeLaunchEnv {
27
28
  * Injectable IO for tests. `env` is deliberately NOT injectable: it is bound to the
28
29
  * launch base so detection and the spawned process can never disagree (audit R3-3).
29
30
  */
30
- export type ClaudeEnvDeps = { authDetect?: Omit<Partial<AuthDetectDeps>, "env" | "ownTokens"> };
31
+ export type ClaudeEnvDeps = {
32
+ authDetect?: Omit<Partial<AuthDetectDeps>, "env" | "ownTokens">;
33
+ /** Test seam; production uses the authenticated Node-launcher context. */
34
+ preBunAnthropicSlots?: readonly AnthropicParentEnvSlot[] | null;
35
+ };
31
36
 
32
37
  /**
33
38
  * Pure env assembly (unit-tested): never sets ANTHROPIC_API_KEY (setting both
34
39
  * token vars triggers Claude Code's auth-conflict warning, 003 E1), and never
35
- * overrides variables the user already exported, apart from stale loopback
36
- * ANTHROPIC_BASE_URL values owned by a previous opencodex launch.
40
+ * preserves Anthropic variables proven to exist in the parent Node launcher,
41
+ * apart from stale loopback ANTHROPIC_BASE_URL values owned by a previous
42
+ * opencodex launch. Unproven ambient values fail closed as project dotenv.
37
43
  */
38
44
  export function buildClaudeEnv(
39
45
  config: OcxConfig,
@@ -49,27 +55,36 @@ export function buildClaudeEnv(
49
55
  // leaving the child with no token at all (audit R2-1). It is opencodex state, never
50
56
  // user auth, so dropping it unconditionally is safe.
51
57
  if (env.ANTHROPIC_AUTH_TOKEN === PROXY_MARKER) delete env.ANTHROPIC_AUTH_TOKEN;
52
- // Step 1b — drop Anthropic credentials that the bundled Bun runtime synthesized from a
53
- // project `.env`/`.env.local` (issue #701). Claude Code disables claude.ai connectors the
54
- // moment either token slot is populated, so an ambient project file silently moved a
55
- // subscriber onto API billing while their OAuth login stayed healthy. The npm launcher
56
- // runs under Node, which does NOT auto-load dotenv, so it records the slots that existed
57
- // before Bun started; anything populated now but absent then came from the working
58
- // directory, not from the user. A genuine shell export is still honored, which keeps
59
- // auto-mode API-key auth working. An ABSENT marker means provenance is unknowable
60
- // (a direct `bun src/cli/index.ts` run, a test, or an older launcher), and then we
61
- // change nothing rather than guess an EMPTY marker is different: the launcher ran
62
- // and saw no pre-existing slots.
63
- const preBunSlots = base.OCX_PRE_BUN_ANTHROPIC_ENV;
64
- if (preBunSlots !== undefined) {
65
- const exported = new Set(preBunSlots.split(",").filter(name => name.length > 0));
66
- for (const name of ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"] as const) {
67
- const value = env[name];
68
- if (value !== undefined && value !== "" && !exported.has(name)) delete env[name];
69
- }
58
+ // Step 1b — drop Anthropic credentials AND destinations that Bun may have synthesized
59
+ // from a project `.env`/`.env.local`. The plain-Node launcher records genuine parent
60
+ // exports before Bun starts and pairs that context with an argv proof, so with a
61
+ // trusted context we know exactly which slots the user really exported.
62
+ //
63
+ // Without a trusted context all three slots are treated as project-controlled. An
64
+ // earlier revision of this branch preserved credentials here, reasoning that the
65
+ // destination is pinned below so a dotenv key would only ever reach the local proxy.
66
+ // That reasoning is wrong, and review caught it: `CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST`
67
+ // is only set when we own an auth token (see below, and #253 for why asserting it
68
+ // otherwise logs a subscriber out), so on a subscription launch Claude Code's
69
+ // settings.env merge can still replace `ANTHROPIC_BASE_URL` after we return. A
70
+ // preserved key then travels to that host. The repository documents that residual for
71
+ // subscription mode; it must not be widened into a credential leak.
72
+ //
73
+ // Direct `bun src/cli/index.ts` therefore loses ambient Anthropic values. That is a
74
+ // real cost to a documented entry point, and the escape hatch is the launcher: run
75
+ // through `ocx` (the published bin) and genuine shell exports are preserved by proof.
76
+ const explicitSlots = deps.preBunAnthropicSlots;
77
+ const trustedSlots = explicitSlots === undefined
78
+ ? trustedNodeLauncherContext()?.anthropicEnvSlots ?? []
79
+ : explicitSlots ?? [];
80
+ const exported = new Set<AnthropicParentEnvSlot>(trustedSlots);
81
+ for (const name of ANTHROPIC_PARENT_ENV_SLOTS) {
82
+ const value = env[name];
83
+ if (value !== undefined && value !== "" && !exported.has(name)) delete env[name];
70
84
  }
71
- // Never forward the seam itself to Claude Code.
85
+ // Never forward old or current provenance seams to Claude Code.
72
86
  delete env.OCX_PRE_BUN_ANTHROPIC_ENV;
87
+ delete env.OCX_NODE_LAUNCH_CONTEXT;
73
88
  const setDefault = (name: string, value: string | undefined) => {
74
89
  if (value === undefined || value.length === 0) return;
75
90
  if (env[name] !== undefined && env[name] !== "") return; // user wins
package/src/cli/index.ts CHANGED
@@ -46,7 +46,10 @@ import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
46
46
  import { collectOrcaCodexHomeDiagnostic } from "../codex/home";
47
47
  import { removeOwnedConfigState } from "../lib/config-ownership";
48
48
  import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
49
+ import { initializeNodeLauncherContext } from "./launcher-context";
50
+ import { createLocalAttestationSecret } from "../lib/local-management-attestation";
49
51
 
52
+ initializeNodeLauncherContext();
50
53
  const args = process.argv.slice(2);
51
54
  const command = args[0];
52
55
 
@@ -195,9 +198,10 @@ async function handleStart(options: { block?: boolean } = {}) {
195
198
  // the same port only (never hop — that was the remaining PR #152 gap).
196
199
  let port = await chooseListenPort(requestedPort);
197
200
  let server: ReturnType<typeof startServer>;
201
+ const localAttestationSecret = createLocalAttestationSecret();
198
202
  for (let attempt = 0; ; attempt++) {
199
203
  try {
200
- server = startServer(port);
204
+ server = startServer(port, { localAttestationSecret });
201
205
  // Prewarm the live provider model cache as soon as the port is bound so the
202
206
  // first GUI /v1/models (and syncModelsToCodex below) share one discovery flight
203
207
  // instead of racing duplicate upstream /models fetches.
@@ -225,7 +229,7 @@ async function handleStart(options: { block?: boolean } = {}) {
225
229
  writePid(process.pid);
226
230
 
227
231
  const config = loadConfig();
228
- writeRuntimePort({ pid: process.pid, port, hostname: config.hostname });
232
+ writeRuntimePort({ pid: process.pid, port, hostname: config.hostname, attestationSecret: localAttestationSecret });
229
233
  // No pre-emptive snapshot here. `injectCodexConfig` journals the exact bytes it
230
234
  // is about to transform; snapshotting earlier only captured a baseline that could
231
235
  // already be stale by the time injection ran (#477).
@@ -317,7 +321,16 @@ async function handleStart(options: { block?: boolean } = {}) {
317
321
  installShellHook();
318
322
 
319
323
  await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start
320
- await syncCodexOnStartIfEnabled(port, config);
324
+ const startupSync = await syncCodexOnStartIfEnabled(port, config);
325
+ // #1046: one warning per startup, after BOTH writes. The server's cache
326
+ // invalidation happens first and the catalog sync second, so the mtime is only
327
+ // final here — and neither write site warns on its own, or a boot that hits
328
+ // both would warn twice.
329
+ const { consumeStartupCacheInvalidationWrite } = await import("../server");
330
+ if (consumeStartupCacheInvalidationWrite() || startupSync.catalogWritten || startupSync.cacheSynced) {
331
+ const { warnIfStaleCodexAppServersAfterStartupWrite } = await import("../codex/app-server-processes");
332
+ warnIfStaleCodexAppServersAfterStartupWrite({ log: console });
333
+ }
321
334
  if (!currentExternalCodexModelProvider() && !shouldInjectApiAuthHeader(config) && config.syncResumeHistory !== false) {
322
335
  historyGuardian = startHistoryMigrationGuardian();
323
336
  }