@bitkyc08/opencodex 2.17.1-preview.20260814 → 2.19.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 (50) hide show
  1. package/gui/dist/assets/{index-DUCH59lJ.css → index-CQ7bIKee.css} +1 -1
  2. package/gui/dist/assets/{index-ta3-_hgj.js → index-D_JUZLEC.js} +16 -16
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/src/adapters/client-fingerprint.ts +14 -10
  6. package/src/adapters/cursor/live-transport.ts +17 -5
  7. package/src/adapters/cursor/protobuf-events.ts +662 -20
  8. package/src/adapters/cursor/tool-definitions.ts +12 -6
  9. package/src/adapters/google-antigravity-wire.ts +4 -3
  10. package/src/adapters/google.ts +22 -3
  11. package/src/bridge.ts +12 -2
  12. package/src/chat/inbound.ts +24 -1
  13. package/src/cli/index.ts +11 -0
  14. package/src/codex/app-server-processes.ts +3 -3
  15. package/src/codex/shim.ts +100 -5
  16. package/src/codex/user-identity.ts +36 -6
  17. package/src/config.ts +0 -2
  18. package/src/generated/compatibility-version.json +52 -44
  19. package/src/lib/errors.ts +27 -0
  20. package/src/lib/token-estimate.ts +19 -2
  21. package/src/lib/windows-elevation.ts +37 -0
  22. package/src/lib/windows-secret-acl.ts +7 -0
  23. package/src/lib/windows-text.ts +106 -0
  24. package/src/lib/windows-user-principal.ts +0 -2
  25. package/src/oauth/index.ts +1 -1
  26. package/src/oauth/store.ts +32 -18
  27. package/src/providers/antigravity-models.ts +25 -5
  28. package/src/providers/free-directory.ts +1 -1
  29. package/src/providers/registry.ts +6 -3
  30. package/src/responses/spill-store.ts +20 -1
  31. package/src/responses/state.ts +159 -3
  32. package/src/server/chat-completions.ts +4 -2
  33. package/src/server/effort-policy.ts +18 -0
  34. package/src/server/index.ts +5 -1
  35. package/src/server/management/logs-usage-routes.ts +7 -22
  36. package/src/server/request-log.ts +48 -3
  37. package/src/server/responses/core.ts +59 -15
  38. package/src/server/responses/encrypted-payload.ts +58 -38
  39. package/src/server/responses/fetch-helpers.ts +12 -4
  40. package/src/server/responses/input-admission.ts +169 -0
  41. package/src/server/responses/policy-fallback.ts +13 -2
  42. package/src/server/responses/ws-upstream.ts +115 -6
  43. package/src/service-manager-probe.ts +23 -37
  44. package/src/service.ts +233 -25
  45. package/src/tray/windows.ts +0 -2
  46. package/src/types.ts +10 -2
  47. package/src/update/job.ts +2 -2
  48. package/src/usage/summary.ts +21 -4
  49. package/src/vision/index.ts +21 -4
  50. package/src/web-search/index.ts +2 -1
@@ -200,6 +200,12 @@ export function cursorRequestHasShellAlias(tools: readonly Pick<OcxTool, "namesp
200
200
  return tools?.some(isBareCodexExecCommandTool) ?? false;
201
201
  }
202
202
 
203
+ function cursorRequestHasExecutionPath(
204
+ tools: readonly Pick<OcxTool, "namespace" | "name">[] | undefined,
205
+ ): boolean {
206
+ return tools?.some(isCursorExecutionPathTool) ?? false;
207
+ }
208
+
203
209
  export function cursorRequestAdvertisesApplyPatch(
204
210
  tools: readonly Pick<OcxTool, "namespace" | "name" | "freeform">[] | undefined,
205
211
  toolChoice?: OcxRequestOptions["toolChoice"],
@@ -249,14 +255,14 @@ export function cursorStructuredEditTools(
249
255
  name: CURSOR_EDIT_FILE_TOOL,
250
256
  cursorStructuredEdit: true,
251
257
  description:
252
- "Replace one block of exact text in a file. OpenCodex converts the replacement into a Codex apply_patch change, which the Codex client applies with its normal approval and sandbox policy. old_string must match the current file content exactly at exactly one location (apply_patch rejects ambiguous hunks). Matching is line-based, so an edit cannot add or remove only the file's final newline, and old_string/new_string that are identical after line normalization are rejected as a no-op.",
258
+ "Replace one block of text in a file. OpenCodex converts the replacement into a Codex apply_patch change. Copy old_string and new_string with their exact leading whitespace — Codex may locate a line after trimming indent, but it writes new_string verbatim, so stripped indent silently corrupts the file. An empty old_string with a non-empty new_string creates a new file (Add File). If the same text appears more than once, the first match is updated. Matching is line-based, so an edit cannot add or remove only the file's final newline, and old_string/new_string that are identical after line normalization are rejected as a no-op.",
253
259
  parameters: { ...CURSOR_EDIT_FILE_INPUT_SCHEMA },
254
260
  },
255
261
  {
256
262
  name: CURSOR_MULTI_EDIT_TOOL,
257
263
  cursorStructuredEdit: true,
258
264
  description:
259
- "Apply several exact-text replacements to one file. OpenCodex converts the edits into a single Codex apply_patch change, which the Codex client applies with its normal approval and sandbox policy. Each old_string must match the current file content exactly at exactly one location (apply_patch rejects ambiguous hunks). Edits are independent: every old_string is matched against the ORIGINAL file content, so a later edit must not rely on text introduced by an earlier one. Matching is line-based, so an edit cannot add or remove only the file's final newline, and old_string/new_string that are identical after line normalization are rejected as a no-op.",
265
+ "Apply several text replacements to one file. OpenCodex converts them into one Codex apply_patch change. Copy each old_string/new_string with exact leading whitespace. If a later edit's old_string is the text after an earlier replacement, OpenCodex folds those edits into one original-file hunk. Independent edits stay separate hunks. An empty old_string with a non-empty new_string creates a new file (Add File); do not mix that with an independent Update on the same path. If the same text appears more than once, the first match is updated. Matching is line-based, so an edit cannot add or remove only the file's final newline, and identical old/new after line normalization are rejected as a no-op.",
260
266
  parameters: { ...CURSOR_MULTI_EDIT_INPUT_SCHEMA },
261
267
  },
262
268
  ];
@@ -421,7 +427,7 @@ export function shouldUseNativeExecOnlyForGenericToolUse(
421
427
  text: string,
422
428
  ): boolean {
423
429
  const trimmed = text.trim();
424
- if (trimmed.length === 0 || !cursorRequestHasShellAlias(tools) || !isGenericToolUseCountDemoPrompt(trimmed)) return false;
430
+ if (trimmed.length === 0 || !cursorRequestHasExecutionPath(tools) || !isGenericToolUseCountDemoPrompt(trimmed)) return false;
425
431
  return !/\b(?:mcp|resource|resources|tool_search|plugin|plugins|app connector|github)\b/i.test(trimmed)
426
432
  && !/(?:리소스|플러그인|깃허브|github)/i.test(trimmed);
427
433
  }
@@ -432,7 +438,7 @@ export function cursorToolsForActivePrompt<T extends Pick<OcxTool, "namespace" |
432
438
  toolChoice?: OcxRequestOptions["toolChoice"],
433
439
  ): readonly T[] | undefined {
434
440
  if (!shouldUseNativeExecOnlyForGenericToolUse(tools, activeText)) return tools;
435
- const execTools = tools?.filter(isBareCodexExecCommandTool);
441
+ const execTools = tools?.filter(isCursorExecutionPathTool);
436
442
  const catalog = tools ?? [];
437
443
  if (execTools?.length && !execTools.some(tool => cursorToolAllowedByChoice(tool, toolChoice, catalog))) return tools;
438
444
  return execTools && execTools.length > 0 ? execTools : tools;
@@ -558,7 +564,7 @@ export function buildCursorToolGuidanceSystemNote(
558
564
  // Host-shell-neutral: the Codex client executes bridge commands, and may differ from
559
565
  // the OpenCodex proxy OS (LAN/SSH remote-proxy). Always cover PowerShell 5.1 pitfalls.
560
566
  const hostShellNote = hasBareExec
561
- ? "Match shell syntax to the Codex client host that runs the bridge (not only the proxy OS). Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs (`<<EOF`); `&&`/`||` are unsupported parser errors — prefer the bridge working-directory argument for directory changes, and use `if ($?) { ... }` for success-gated follow-up steps; do not treat `;` as a substitute for `&&`. POSIX: use portable commands. After a shell failure, make at most one corrected bridge attempt, then report the error and stop — do not repeat equivalent failing commands."
567
+ ? "Match shell syntax to the Codex client host that runs the bridge (not only the proxy OS). Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs (`<<EOF`); `&&`/`||` are unsupported parser errors — prefer the bridge working-directory argument for directory changes, and use `if ($?) { ... }` for success-gated follow-up steps; do not treat `;` as a substitute for `&&`. POSIX: use portable commands (`cat`/`ls`/`rg`); never emit Get-Content or Get-ChildItem unless the host shell is PowerShell. After a shell failure, make at most one corrected bridge attempt, then report the error and stop — do not repeat equivalent failing commands."
562
568
  : undefined;
563
569
  const notes = [
564
570
  `Cursor tool calls: available tool names are exactly ${listedNames}.`,
@@ -582,7 +588,7 @@ export function buildCursorToolGuidanceSystemNote(
582
588
  : undefined,
583
589
  hasApplyPatch
584
590
  ? structuredEditNames.length > 0
585
- ? `For file edits, prefer the structured edit tools ${quotedNames(structuredEditNames)} — they take exact-match replacements that OpenCodex converts into Codex \`apply_patch\` changes for approval. Use \`apply_patch\` directly only when you can emit its exact freeform syntax (\`*** Begin Patch\` envelope with \`@@\` hunks and \`-\`/\`+\` line prefixes); never emit patch-like plain text as tool arguments.`
591
+ ? `For file edits, prefer the structured edit tools ${quotedNames(structuredEditNames)} — they take replacements that OpenCodex converts into Codex \`apply_patch\` changes. Include exact leading whitespace in old_string/new_string. Use \`apply_patch\` directly only with a \`*** Begin Patch\` envelope and bare \`@@\` hunks (never git-style \`@@ -n,m +n,m @@\`); never emit patch-like plain text as tool arguments.`
586
592
  : "For file edits, use the `apply_patch` tool, not built-in file write/delete tools."
587
593
  : undefined,
588
594
  hasBareExec
@@ -3,10 +3,11 @@ import type { OcxContentPart, OcxParsedRequest } from "../types";
3
3
  import { antigravityUserAgent } from "./client-fingerprint";
4
4
 
5
5
  /**
6
- * Antigravity request User-Agent. Mirrors the real Antigravity CLI UA
7
- * (`antigravity/cli/{ver} (aidev_client; os_type=darwin; arch=arm64)`) so the request fingerprint
6
+ * Antigravity request User-Agent. Mirrors the real Antigravity IDE UA
7
+ * (`antigravity/ide/{ver} (aidev_client; os_type=windows; arch=amd64)`) so the request fingerprint
8
8
  * matches the OAuth credential — the prior literal `"antigravity"` was a giveaway no real client
9
- * sends. A `GOOGLE_ANTIGRAVITY_USER_AGENT` override still wins.
9
+ * sends. The IDE client family is also required to unlock newer agent models (the backend 404s
10
+ * CLI-shaped UAs for `gemini-3.7-*`). A `GOOGLE_ANTIGRAVITY_USER_AGENT` override still wins.
10
11
  */
11
12
  export const ANTIGRAVITY_REQUEST_UA = process.env.GOOGLE_ANTIGRAVITY_USER_AGENT || antigravityUserAgent();
12
13
 
@@ -45,6 +45,25 @@ const GOOGLE_BREVITY_INSTRUCTION = [
45
45
  "- This applies only to intermediate progress text. Your final answer after the work is done is exempt: write it in full and at whatever length the task requires.",
46
46
  ].join("\n");
47
47
 
48
+ /**
49
+ * Google renamed the current Gemini Flash generations on the Generative Language API,
50
+ * appending a `-tiered` suffix (`gemini-3.7-flash` -> `gemini-3.7-flash-tiered`). The
51
+ * old `gemini-3.7-flash` path 404s, so a saved config or registry entry naming the base
52
+ * id must be resolved here before it reaches the URL. The user-facing id is deliberately
53
+ * left alone: the picker, the catalog, the usage log and the price overlays all stay
54
+ * keyed on the base id, and only the wire path learns the new spelling.
55
+ */
56
+ const GEMINI_DIRECT_WIRE_RENAMES: Record<string, string> = {
57
+ "gemini-3.7-flash": "gemini-3.7-flash-tiered",
58
+ "gemini-3.6-flash": "gemini-3.6-flash-tiered",
59
+ };
60
+
61
+ function resolveDirectGeminiWireModelId(modelId: string): string {
62
+ return Object.hasOwn(GEMINI_DIRECT_WIRE_RENAMES, modelId)
63
+ ? GEMINI_DIRECT_WIRE_RENAMES[modelId]!
64
+ : modelId;
65
+ }
66
+
48
67
  /** Vertex API key: provider.apiKey if it looks real (not a sentinel), else GOOGLE_CLOUD_API_KEY env. */
49
68
  function resolveVertexApiKey(optKey?: string): string | undefined {
50
69
  const realKey = optKey && !optKey.startsWith("<") && optKey !== "N/A" ? optKey : undefined;
@@ -353,7 +372,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
353
372
  parsed.modelId,
354
373
  mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning),
355
374
  ).wireModelId
356
- : parsed.modelId;
375
+ : resolveDirectGeminiWireModelId(parsed.modelId);
357
376
  const { systemInstruction, contents } = messagesToGeminiFormat(parsed, routedModelId);
358
377
  const tools = toolsToGeminiFormat(parsed);
359
378
 
@@ -450,7 +469,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
450
469
  const envelope = {
451
470
  model: wireModelId,
452
471
  // The envelope's `userAgent` field is a protocol constant ("antigravity"), distinct from
453
- // the HTTP `User-Agent` header (the real CLI UA). CLIProxyAPI `geminiToAntigravity` hardcodes
472
+ // the HTTP `User-Agent` header (the real IDE UA). CLIProxyAPI `geminiToAntigravity` hardcodes
454
473
  // the body field; only the header carries the versioned client string.
455
474
  userAgent: "antigravity",
456
475
  requestType: "agent",
@@ -501,7 +520,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
501
520
  }
502
521
 
503
522
  // ai-studio (default): Generative Language API + x-goog-api-key.
504
- const url = `${provider.baseUrl}/v1beta/models/${parsed.modelId}:${method}${streamParam}`;
523
+ const url = `${provider.baseUrl}/v1beta/models/${routedModelId}:${method}${streamParam}`;
505
524
  const apiKey = provider.apiKey?.trim();
506
525
  if (!apiKey) throw new Error("google (AI Studio) requires a non-empty API key");
507
526
  headers["x-goog-api-key"] = apiKey;
package/src/bridge.ts CHANGED
@@ -92,10 +92,11 @@ function responseError(status: number, type: string, message: string): OcxErrorP
92
92
  * non-stream adapters degrade a bad payload to `{}`.
93
93
  */
94
94
  function toolCallArgumentsUsable(args: string): boolean {
95
+ if (args.length === 0) return true;
95
96
  const trimmed = args.trim();
96
- if (!trimmed) return true;
97
+ if (!trimmed) return false;
97
98
  try {
98
- JSON.parse(trimmed);
99
+ JSON.parse(args);
99
100
  return true;
100
101
  } catch {
101
102
  return false;
@@ -1593,6 +1594,15 @@ function buildResponseJSONWithBudget(
1593
1594
  };
1594
1595
 
1595
1596
  for (const e of events) {
1597
+ if (errorEvent) {
1598
+ // Match streaming: once the turn fails, later parallel calls must not become executable
1599
+ // completed output. Still release every retained event in order and preserve terminal usage.
1600
+ if (e.type === "error" || e.type === "incomplete" || e.type === "done") {
1601
+ usage = e.usage ?? usage;
1602
+ }
1603
+ if (budget) releaseTranslatedEvent(e, budget);
1604
+ continue;
1605
+ }
1596
1606
  switch (e.type) {
1597
1607
  case "assistant_boundary":
1598
1608
  flushText("commentary");
@@ -14,6 +14,7 @@ function isRec(v: unknown): v is Rec {
14
14
  }
15
15
 
16
16
  const OUTPUT_CONFIG_EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);
17
+ const OUTPUT_CONFIG_SUMMARIES = new Set(["auto", "concise", "detailed", "none"]);
17
18
 
18
19
  function contentToText(content: unknown): string {
19
20
  if (typeof content === "string") return content;
@@ -205,6 +206,22 @@ function resolveReasoningEffort(raw: Rec): string | undefined {
205
206
  return undefined;
206
207
  }
207
208
 
209
+ /**
210
+ * Chat Completions clients (Grok Build, Copilot, OpenAI-compatible SDKs) expect
211
+ * `delta.reasoning_content` whenever the model thinks. The internal Responses
212
+ * parser hides thinking unless `reasoning.summary` is set and is not `"none"`.
213
+ * Map the common Chat Completions knobs onto that field. When the client only
214
+ * sent an effort, default the summary to `"auto"` so traces are not swallowed.
215
+ */
216
+ function resolveReasoningSummary(raw: Rec): string | undefined {
217
+ if (isRec(raw.reasoning) && typeof raw.reasoning.summary === "string" && OUTPUT_CONFIG_SUMMARIES.has(raw.reasoning.summary)) {
218
+ return raw.reasoning.summary;
219
+ }
220
+ if (raw.include_reasoning === false) return "none";
221
+ if (raw.include_reasoning === true) return "auto";
222
+ return undefined;
223
+ }
224
+
208
225
  /**
209
226
  * Translate an OpenAI Chat Completions request body into a /v1/responses request body.
210
227
  * Throws ChatCompletionsRequestError (-> 400) on malformed input.
@@ -286,7 +303,13 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {
286
303
  if (raw.metadata !== undefined) body.metadata = raw.metadata;
287
304
 
288
305
  const effort = resolveReasoningEffort(raw);
289
- if (effort) body.reasoning = { effort };
306
+ const summary = resolveReasoningSummary(raw);
307
+ if (effort || summary !== undefined) {
308
+ body.reasoning = {
309
+ ...(effort ? { effort } : {}),
310
+ summary: summary ?? "auto",
311
+ };
312
+ }
290
313
 
291
314
  const text = responseFormatToText(raw.response_format);
292
315
  if (text) body.text = text;
package/src/cli/index.ts CHANGED
@@ -216,6 +216,17 @@ async function handleStart(options: { block?: boolean } = {}) {
216
216
  const requestedPort = parsePortOption();
217
217
  const owner = await findProxyOwnerBeforeJournalRecovery();
218
218
  if (owner.live) {
219
+ // Service-wrapper context (opencodex-service.cmd `:loop`): a healthy proxy from
220
+ // ANY source means the requested port is already served. Exit 0 so the wrapper's
221
+ // `if %ERRORLEVEL% NEQ 0` retry loop terminates instead of respawning every 5s
222
+ // against a listener it can never claim (observed as an endless
223
+ // "Proxy already running" service.log loop).
224
+ // Only the exact "1" sentinel takes this path — the same check syncCleanup
225
+ // uses — so an env value like "0" or "false" cannot bypass the conflict error.
226
+ if (process.env.OCX_SERVICE === "1") {
227
+ console.log(`Proxy already running (PID ${owner.live.pid ?? owner.pidSnapshot ?? "unknown"}, port ${owner.live.port}); service wrapper staying out of the way.`);
228
+ process.exit(0);
229
+ }
219
230
  console.error(`⚠️ Proxy already running (PID ${owner.live.pid ?? owner.pidSnapshot ?? "unknown"}, port ${owner.live.port}). Use 'ocx stop' first.`);
220
231
  process.exit(1);
221
232
  }
@@ -350,7 +350,7 @@ export function listWindowsSnapshots(): ProcessSnapshot[] {
350
350
  // executable resolves from the trusted System32 directory (never PATH), and
351
351
  // windowsHide keeps the enumeration console-less on desktop sessions (#1278).
352
352
  const output = execFileSync(resolveTrustedWindowsPowerShellExe(), [
353
- "-NoProfile", "-NoLogo", "-NonInteractive", "-WindowStyle", "Hidden",
353
+ "-NoProfile", "-NoLogo", "-NonInteractive",
354
354
  "-Command",
355
355
  psCommand,
356
356
  ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 8_000, windowsHide: true });
@@ -461,7 +461,7 @@ function readDarwinProcStartMs(pid: number): number | null {
461
461
  function readWindowsProcStartMs(pid: number): number | null {
462
462
  try {
463
463
  const out = execFileSync(resolveTrustedWindowsPowerShellExe(), [
464
- "-NoProfile", "-NoLogo", "-NonInteractive", "-WindowStyle", "Hidden",
464
+ "-NoProfile", "-NoLogo", "-NonInteractive",
465
465
  "-Command",
466
466
  `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CreationDate.ToUniversalTime().ToString("o")`,
467
467
  ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 8_000, windowsHide: true }).trim();
@@ -517,7 +517,7 @@ export function readProcessStartMsBatch(
517
517
  try {
518
518
  const filter = pids.map(pid => `ProcessId=${pid}`).join(" OR ");
519
519
  const stdout = execFileSync(resolveTrustedWindowsPowerShellExe(), [
520
- "-NoProfile", "-NoLogo", "-NonInteractive", "-WindowStyle", "Hidden",
520
+ "-NoProfile", "-NoLogo", "-NonInteractive",
521
521
  "-Command",
522
522
  `Get-CimInstance Win32_Process -Filter "${filter}" | ForEach-Object { "$($_.ProcessId)\t$($_.CreationDate.ToUniversalTime().ToString("o"))" }`,
523
523
  ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, windowsHide: true });
package/src/codex/shim.ts CHANGED
@@ -8,16 +8,19 @@ import {
8
8
  existsSync,
9
9
  fstatSync,
10
10
  lstatSync,
11
+ linkSync,
11
12
  mkdirSync,
12
13
  mkdtempSync,
13
14
  openSync,
14
15
  readFileSync,
15
16
  readdirSync,
17
+ readlinkSync,
16
18
  readSync,
17
19
  renameSync,
18
20
  rmSync,
19
21
  rmdirSync,
20
22
  statSync,
23
+ symlinkSync,
21
24
  type Stats,
22
25
  unlinkSync,
23
26
  writeFileSync,
@@ -407,6 +410,63 @@ function sameStableShimPathProbe(left: StableShimPathProbe, right: StableShimPat
407
410
  return left.prefix === right.prefix && sameFingerprint(left.fingerprint, right.fingerprint);
408
411
  }
409
412
 
413
+ /**
414
+ * Identity of whatever sits at `path`, read from metadata alone.
415
+ *
416
+ * `stableShimPathProbe` answers a different question: it reads content to decide
417
+ * whether a launcher looks like a healthy shim, and it deliberately returns null
418
+ * for a zero-byte file. That makes it the wrong instrument for rollback
419
+ * bookkeeping. A user can legitimately own an empty `codex` launcher, and a fresh
420
+ * install moves it aside before writing our wrapper; if the move is recorded
421
+ * without a fingerprint, rollback cannot prove the backup is still the file it
422
+ * set aside and refuses to restore it — the launcher stays lost (#1625).
423
+ *
424
+ * Content is irrelevant to that proof, so this reads dev/ino/mode/size/times and
425
+ * re-reads them to reject a path that changed under us, following a symlink to
426
+ * fingerprint its target as well.
427
+ */
428
+ function shimPathFingerprint(path: string): ShimPathFingerprint | null {
429
+ const before = statFingerprint(path, false);
430
+ if (!before) return null;
431
+ if (before.kind !== "symlink") {
432
+ const after = statFingerprint(path, false);
433
+ return after && sameFingerprint(before, after) ? before : null;
434
+ }
435
+ const targetBefore = statFingerprint(path, true);
436
+ if (!targetBefore) return null;
437
+ const targetAfter = statFingerprint(path, true);
438
+ const after = statFingerprint(path, false);
439
+ if (!targetAfter || !after
440
+ || !sameFingerprint(targetBefore, targetAfter)
441
+ || !sameFingerprint(before, after)) return null;
442
+ return { ...before, target: targetBefore };
443
+ }
444
+
445
+ /**
446
+ * Move `from` onto `to` without ever replacing an existing entry.
447
+ *
448
+ * `renameSync` silently clobbers the destination on POSIX, which is wrong for a
449
+ * rollback restore: `sourceOccupied` is sampled before the fingerprint check, so
450
+ * a concurrent installer can publish its own launcher at the original path in
451
+ * between, and the restore would delete it. `link` fails EEXIST instead, which
452
+ * is the no-replace primitive we need and needs no native helper.
453
+ *
454
+ * `link` follows a symlink to its target rather than preserving the link, so a
455
+ * symlink launcher is republished with `symlink`, which is also no-replace: it
456
+ * fails EEXIST on an occupied destination. Checking existence and then renaming
457
+ * would reintroduce exactly the race this function exists to close.
458
+ */
459
+ function restoreWithoutReplacing(from: string, to: string): void {
460
+ const source = lstatSync(from);
461
+ if (source.isSymbolicLink()) {
462
+ symlinkSync(readlinkSync(from), to);
463
+ unlinkSync(from);
464
+ return;
465
+ }
466
+ linkSync(from, to);
467
+ unlinkSync(from);
468
+ }
469
+
410
470
  function isHealthyShimProbe(probe: StableShimPathProbe, platform: NodeJS.Platform): boolean {
411
471
  if (probe.prefix.length < 180 || !probe.prefix.includes(SHIM_MARKER) || !probe.prefix.includes("ensure")) return false;
412
472
  const mode = probe.fingerprint.target?.mode ?? probe.fingerprint.mode;
@@ -644,6 +704,7 @@ let codexShimProbeHookForTests: (() => void) | null = null;
644
704
  let codexShimProbeShellForTests: string | null = null;
645
705
  let codexShimGuardedWriteHookForTests: (() => void) | null = null;
646
706
  let codexShimFreshWriteHookForTests: (() => void) | null = null;
707
+ let codexShimRollbackRestoreHookForTests: ((target: ShimFileState) => void) | null = null;
647
708
  let codexShimProbeObservationMs = CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS;
648
709
 
649
710
  /** Narrow deterministic seam for transaction rollback tests. */
@@ -671,6 +732,20 @@ export function setCodexShimFreshWriteHookForTests(hook: (() => void) | null): v
671
732
  codexShimFreshWriteHookForTests = hook;
672
733
  }
673
734
 
735
+ /**
736
+ * @internal Test-only seam for the rollback restore race.
737
+ *
738
+ * The window this closes opens after `sourceOccupied` is sampled and closes when
739
+ * the backup is republished, so no earlier hook can reach it: publishing from
740
+ * the fresh-write hook makes `sourceOccupied` true and skips the restore
741
+ * entirely.
742
+ */
743
+ export function setCodexShimRollbackRestoreHookForTests(
744
+ hook: ((target: ShimFileState) => void) | null,
745
+ ): void {
746
+ codexShimRollbackRestoreHookForTests = hook;
747
+ }
748
+
674
749
  function readProbeMetadata(path: string, maxBytes: number): string | null {
675
750
  try {
676
751
  if (!existsSync(path)) return "";
@@ -830,9 +905,9 @@ function rollbackFreshShimInstall(journal: readonly FreshShimInstallJournalEntry
830
905
  }
831
906
  try {
832
907
  if (entry.originalMovedToBackup && existsSync(target.backupPath)) {
833
- const movedOriginal = stableShimPathProbe(target.backupPath);
908
+ const movedOriginal = shimPathFingerprint(target.backupPath);
834
909
  if (!movedOriginal || !entry.movedOriginalFingerprint
835
- || !sameFingerprint(movedOriginal.fingerprint, entry.movedOriginalFingerprint)) {
910
+ || !sameFingerprint(movedOriginal, entry.movedOriginalFingerprint)) {
836
911
  throw new Error("Codex shim fresh-install backup changed during rollback");
837
912
  }
838
913
  if (sourceOccupied) {
@@ -842,7 +917,12 @@ function rollbackFreshShimInstall(journal: readonly FreshShimInstallJournalEntry
842
917
  // lose the command entirely. Keep it in that case — a stray
843
918
  // `codex.opencodex-real` is recoverable, a deleted launcher is not.
844
919
  if (ownsWrapperNow) unlinkSync(target.backupPath);
845
- } else renameSync(target.backupPath, target.originalPath);
920
+ } else {
921
+ // No-replace: sourceOccupied was sampled earlier, so a concurrent
922
+ // installer may have published a launcher at the original path since.
923
+ codexShimRollbackRestoreHookForTests?.(target);
924
+ restoreWithoutReplacing(target.backupPath, target.originalPath);
925
+ }
846
926
  }
847
927
  } catch (error) {
848
928
  errors.push(error instanceof Error ? error : new Error(String(error)));
@@ -1832,10 +1912,25 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i
1832
1912
  if (existsSync(target.originalPath)) {
1833
1913
  renameSync(target.originalPath, target.backupPath);
1834
1914
  entry.originalMovedToBackup = true;
1915
+ // Metadata-only, and before the content probe: an empty or otherwise
1916
+ // unprobeable launcher must still be restorable during rollback.
1917
+ //
1918
+ // Only Unix reaches the rollback path (Windows rethrows freshApplyError
1919
+ // without rolling back), so only Unix may treat a missing fingerprint as
1920
+ // fatal. Throwing here on Windows would abort AFTER the original moved,
1921
+ // stranding the launcher at its backup path with nothing to restore it.
1922
+ const movedOriginalFingerprint = shimPathFingerprint(target.backupPath);
1923
+ if (movedOriginalFingerprint) entry.movedOriginalFingerprint = movedOriginalFingerprint;
1835
1924
  if (process.platform !== "win32") {
1925
+ if (!movedOriginalFingerprint) {
1926
+ throw new Error("Codex shim fresh install could not fingerprint the staged launcher");
1927
+ }
1836
1928
  const movedOriginal = stableShimPathProbe(target.backupPath);
1837
- if (!movedOriginal) throw new Error("Codex shim fresh install could not fingerprint the staged launcher");
1838
- entry.movedOriginalFingerprint = movedOriginal.fingerprint;
1929
+ // A content probe still runs where it can, purely as a consistency
1930
+ // check: disagreement means the file moved under us mid-install.
1931
+ if (movedOriginal && !sameFingerprint(movedOriginal.fingerprint, movedOriginalFingerprint)) {
1932
+ throw new Error("Codex shim fresh install staged launcher changed while being fingerprinted");
1933
+ }
1839
1934
  }
1840
1935
  }
1841
1936
  if (!target.preserveOnly) {
@@ -53,15 +53,22 @@ function refuse(message: string, cause?: unknown): never {
53
53
  }
54
54
 
55
55
  function windowsIdentityPowerShellCommand(expression: string): string[] {
56
+ // PowerShell 5.1 can encode redirected native/host output with the active
57
+ // Windows code page. Base64 contains ASCII only, while the payload is
58
+ // explicitly UTF-16LE, so Korean and Western profile paths arrive unchanged.
59
+ const deterministicOutput = [
60
+ "$ErrorActionPreference = 'Stop'",
61
+ `$ocxValue = [string](${expression})`,
62
+ "$ocxBytes = [System.Text.Encoding]::Unicode.GetBytes($ocxValue)",
63
+ "[Console]::Out.Write([Convert]::ToBase64String($ocxBytes))",
64
+ ].join("; ");
56
65
  return [
57
66
  resolveTrustedWindowsPowerShellExe(),
58
67
  "-NoLogo",
59
68
  "-NoProfile",
60
69
  "-NonInteractive",
61
- "-WindowStyle",
62
- "Hidden",
63
70
  "-Command",
64
- expression,
71
+ deterministicOutput,
65
72
  ];
66
73
  }
67
74
 
@@ -93,6 +100,28 @@ export function windowsIdentityPowerShellSpawnOptionsForTests(): ReturnType<
93
100
  return windowsIdentityPowerShellSpawnOptions();
94
101
  }
95
102
 
103
+ function decodeWindowsIdentityPowerShellOutput(output: Uint8Array): string {
104
+ let encoded: string;
105
+ try {
106
+ encoded = new TextDecoder("utf-8", { fatal: true }).decode(output).trim();
107
+ } catch (cause) {
108
+ refuse("Windows effective-account lookup returned a malformed value.", cause);
109
+ }
110
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) {
111
+ refuse("Windows effective-account lookup returned a malformed value.");
112
+ }
113
+ const bytes = Buffer.from(encoded, "base64");
114
+ if (bytes.length % 2 !== 0 || bytes.toString("base64") !== encoded) {
115
+ refuse("Windows effective-account lookup returned a malformed value.");
116
+ }
117
+ return bytes.toString("utf16le").trim();
118
+ }
119
+
120
+ /** Test-only decode seam for the deterministic PowerShell output contract. */
121
+ export function decodeWindowsIdentityPowerShellOutputForTests(output: Uint8Array): string {
122
+ return decodeWindowsIdentityPowerShellOutput(output);
123
+ }
124
+
96
125
  function powershellValue(expression: string): string {
97
126
  let command: string[];
98
127
  try {
@@ -105,15 +134,16 @@ function powershellValue(expression: string): string {
105
134
  // `windowsHide` is the popup fix (#1278): the desktop proxy parent runs
106
135
  // without a console, so a console-subsystem child spawned without
107
136
  // CREATE_NO_WINDOW gets a fresh visible console window at startup and on
108
- // every config write. `-WindowStyle Hidden` alone does not stop the
109
- // allocation; the flag behind `windowsHide` does.
137
+ // every config write. Do not add PowerShell's `-WindowStyle Hidden` here:
138
+ // Bun 1.3.14 can fail that direct CLI combination before the SID command
139
+ // executes (#1589); the process-level `windowsHide` flag is sufficient.
110
140
  result = Bun.spawnSync(command, windowsIdentityPowerShellSpawnOptions());
111
141
  } catch (cause) {
112
142
  refuse("Windows effective-account lookup could not start.", cause);
113
143
  }
114
144
  if (result.exitedDueToTimeout) refuse("Windows effective-account lookup timed out.");
115
145
  if (result.exitCode !== 0) refuse("Windows effective-account lookup failed.");
116
- const value = new TextDecoder().decode(result.stdout).trim();
146
+ const value = decodeWindowsIdentityPowerShellOutput(result.stdout ?? Buffer.alloc(0));
117
147
  if (!value) refuse("Windows effective-account lookup returned an empty value.");
118
148
  return value;
119
149
  }
package/src/config.ts CHANGED
@@ -3578,8 +3578,6 @@ function readProcessCommandLine(pid: number): string | undefined {
3578
3578
  "-NoProfile",
3579
3579
  "-NoLogo",
3580
3580
  "-NonInteractive",
3581
- "-WindowStyle",
3582
- "Hidden",
3583
3581
  "-Command",
3584
3582
  `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`,
3585
3583
  ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });