@bitkyc08/opencodex 2.25.0 → 2.26.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 (99) hide show
  1. package/bin/ocx.mjs +59 -7
  2. package/gui/dist/assets/index-RL6b1bTV.js +102 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/base.ts +18 -0
  6. package/src/adapters/client-fingerprint.ts +0 -2
  7. package/src/adapters/cursor/http1-bidi.ts +361 -0
  8. package/src/adapters/cursor/live-models.ts +117 -30
  9. package/src/adapters/cursor/live-transport.ts +137 -56
  10. package/src/adapters/cursor/native-exec-fs.ts +1 -1
  11. package/src/adapters/cursor/native-exec-network.ts +1 -1
  12. package/src/adapters/cursor/native-exec-shell.ts +0 -1
  13. package/src/adapters/cursor/protobuf-request.ts +89 -21
  14. package/src/adapters/cursor/tool-definitions.ts +18 -7
  15. package/src/adapters/cursor/tool-result-normalize.ts +92 -0
  16. package/src/adapters/cursor/transport.ts +7 -0
  17. package/src/adapters/cursor.ts +2 -0
  18. package/src/adapters/google-http.ts +38 -10
  19. package/src/adapters/google.ts +23 -3
  20. package/src/adapters/openai-chat.ts +38 -13
  21. package/src/adapters/openai-responses.ts +129 -9
  22. package/src/adapters/registry.ts +30 -1
  23. package/src/bridge.ts +50 -12
  24. package/src/chat/inbound.ts +1 -0
  25. package/src/claude/agents-inject.ts +3 -2
  26. package/src/cli/dispatch.ts +19 -6
  27. package/src/cli/help.ts +2 -1
  28. package/src/cli/integrations.ts +35 -0
  29. package/src/cli/minimax.ts +8 -2
  30. package/src/cli/registry.ts +13 -2
  31. package/src/clients/config-export.ts +120 -1
  32. package/src/codex/account-store.ts +17 -1
  33. package/src/codex/auth-api.ts +51 -17
  34. package/src/codex/catalog/effort.ts +8 -5
  35. package/src/codex/catalog/provider-fetch.ts +39 -27
  36. package/src/codex/catalog/sync.ts +30 -8
  37. package/src/codex/inject.ts +39 -13
  38. package/src/codex/main-account.ts +29 -1
  39. package/src/codex/plan-from-token.ts +140 -0
  40. package/src/codex/plan.ts +25 -0
  41. package/src/codex/prompt-journal.ts +6 -2
  42. package/src/codex/quota-rejection.ts +21 -7
  43. package/src/codex/refresh.ts +4 -2
  44. package/src/codex/sync.ts +106 -3
  45. package/src/codex/warmup.ts +187 -81
  46. package/src/config.ts +85 -46
  47. package/src/generated/compatibility-version.json +127 -83
  48. package/src/grok/inject.ts +1 -1
  49. package/src/images/loop.ts +1 -0
  50. package/src/integrations/registry.ts +7 -0
  51. package/src/lab/automation/config-persistence.ts +2 -2
  52. package/src/lab/automation/persistence.ts +2 -2
  53. package/src/lab/ledger/purge.ts +2 -2
  54. package/src/lab/subject/behavior-fingerprint.ts +2 -2
  55. package/src/lib/config-ownership.ts +5 -2
  56. package/src/lib/destination-policy.ts +18 -1
  57. package/src/lib/provider-outbound.ts +7 -0
  58. package/src/lib/redact.ts +11 -0
  59. package/src/lib/tool-argument-integers.ts +50 -6
  60. package/src/lib/upstream-http-version.ts +57 -0
  61. package/src/lib/upstream-retry.ts +2 -1
  62. package/src/lib/windows-atomic-replace.ts +155 -0
  63. package/src/lib/windows-service-wrappers.ts +72 -0
  64. package/src/oauth/google-antigravity.ts +2 -2
  65. package/src/oauth/index.ts +60 -8
  66. package/src/providers/antigravity-models.ts +58 -4
  67. package/src/providers/command-code-efforts.ts +36 -9
  68. package/src/providers/context-cap.ts +9 -0
  69. package/src/providers/derive.ts +4 -0
  70. package/src/providers/fastwire.ts +453 -0
  71. package/src/providers/registry.ts +21 -1
  72. package/src/providers/service-tier.ts +173 -89
  73. package/src/responses/parser.ts +25 -16
  74. package/src/responses/reasoning-replay-cache.ts +30 -0
  75. package/src/responses/thought-signature-replay.ts +74 -4
  76. package/src/router.ts +6 -0
  77. package/src/routing/compatibility/behavior.ts +27 -16
  78. package/src/server/index.ts +10 -1
  79. package/src/server/management/oauth-account-routes.ts +10 -2
  80. package/src/server/management/shared.ts +1 -1
  81. package/src/server/management/system-routes.ts +15 -0
  82. package/src/server/request-log.ts +57 -3
  83. package/src/server/responses/agent-task-recovery.ts +6 -1
  84. package/src/server/responses/core.ts +209 -40
  85. package/src/server/responses/fetch-helpers.ts +15 -36
  86. package/src/server/responses/responses-field-backfill.ts +173 -0
  87. package/src/server/responses-reasoning-summary-rewrite.ts +171 -0
  88. package/src/service.ts +38 -36
  89. package/src/storage/cleanup.ts +2 -2
  90. package/src/tray/windows.ts +3 -2
  91. package/src/types.ts +101 -14
  92. package/src/update/job.ts +9 -18
  93. package/src/update/transactional-install.d.mts +22 -0
  94. package/src/update/transactional-install.mjs +259 -0
  95. package/src/usage/cost.ts +34 -5
  96. package/src/usage/log.ts +74 -4
  97. package/src/vision/anthropic-describe.ts +10 -6
  98. package/src/web-search/anthropic-executor.ts +8 -6
  99. package/gui/dist/assets/index-DxJ7kXj9.js +0 -102
@@ -324,7 +324,7 @@ export function buildGrokManagedBlock(
324
324
  `[model.${alias}]`,
325
325
  `model = ${tomlString(model.id)}`,
326
326
  `base_url = ${tomlString(baseUrl)}`,
327
- 'api_backend = "chat_completions"',
327
+ 'api_backend = "responses"',
328
328
  'api_key = "opencodex-loopback"',
329
329
  `name = ${tomlString(model.name ?? `OCX ${model.id}`)}`,
330
330
  // Best-effort attribution tag for the usage dashboard. Upstream Grok sends
@@ -508,6 +508,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
508
508
  timeoutMs: connectTimeoutMs,
509
509
  returnRawErrors: true,
510
510
  stream: true,
511
+ executor: fetchImpl,
511
512
  });
512
513
  } else {
513
514
  response = await fetchWithResetRetry(
@@ -27,6 +27,8 @@ import {
27
27
  opencodeGlobalConfigPath,
28
28
  openclawConfigPath,
29
29
  openclawHomeDir,
30
+ zcodeConfigPath,
31
+ zcodeHomeDir,
30
32
  type ExportClientId,
31
33
  } from "../clients/config-export";
32
34
 
@@ -119,6 +121,11 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
119
121
  configPath: (env = process.env, home = homedir()) => mcodeConfigPath(env, home),
120
122
  detectDir: (env = process.env, home = homedir()) => mcodeHomeDir(env, home),
121
123
  },
124
+ zcode: {
125
+ id: "zcode",
126
+ configPath: (env = process.env, home = homedir()) => zcodeConfigPath(env, home),
127
+ detectDir: (env = process.env, home = homedir()) => zcodeHomeDir(env, home),
128
+ },
122
129
  };
123
130
 
124
131
  export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] =
@@ -6,11 +6,11 @@ import {
6
6
  linkSync,
7
7
  openSync,
8
8
  readFileSync,
9
- renameSync,
10
9
  unlinkSync,
11
10
  writeFileSync,
12
11
  } from "node:fs";
13
12
  import { dirname, join } from "node:path";
13
+ import { renameAtomicFile } from "../../lib/windows-atomic-replace";
14
14
  import {
15
15
  ensureLabDirs,
16
16
  labAutomationPolicyPath,
@@ -217,7 +217,7 @@ function writeConfigUnlocked(
217
217
  if (configCommitFaultForTests === "before_publish") {
218
218
  throw new LabAutomationError("synthetic automation config commit failure", "invalid_state");
219
219
  }
220
- renameSync(tmp, path);
220
+ renameAtomicFile(tmp, path, undefined, "lab-automation");
221
221
  return normalized;
222
222
  } finally {
223
223
  if (fd !== null) closeSync(fd);
@@ -6,11 +6,11 @@ import {
6
6
  linkSync,
7
7
  openSync,
8
8
  readFileSync,
9
- renameSync,
10
9
  unlinkSync,
11
10
  writeFileSync,
12
11
  } from "node:fs";
13
12
  import { dirname, join } from "node:path";
13
+ import { renameAtomicFile } from "../../lib/windows-atomic-replace";
14
14
  import {
15
15
  ensureLabDirs,
16
16
  labAutomationPolicyPath,
@@ -125,7 +125,7 @@ function atomicWriteJson(path: string, payload: unknown): void {
125
125
  const tmp = join(dir, `.${basename(path)}.${process.pid}.${Date.now()}.tmp`);
126
126
  const text = JSON.stringify(payload);
127
127
  writeFileSync(tmp, text, { encoding: "utf8", mode: 0o600 });
128
- renameSync(tmp, path);
128
+ renameAtomicFile(tmp, path, undefined, "lab-automation");
129
129
  }
130
130
 
131
131
  function basename(path: string): string {
@@ -5,6 +5,7 @@ import {
5
5
  type TrustedArtifactDir,
6
6
  } from "../artifacts/secure-fs";
7
7
  import { ArtifactFsError } from "../artifacts/secure-fs";
8
+ import { renameAtomicFile } from "../../lib/windows-atomic-replace";
8
9
  import {
9
10
  LAB_EVENT_SCHEMA_VERSION,
10
11
  LAB_PRODUCER,
@@ -29,7 +30,6 @@ import {
29
30
  fsyncSync,
30
31
  openSync,
31
32
  readdirSync,
32
- renameSync,
33
33
  rmSync,
34
34
  unlinkSync,
35
35
  writeSync,
@@ -79,7 +79,7 @@ function atomicRewriteLedger(ledgerPath: string, events: LabEvent[]): void {
79
79
  } finally {
80
80
  closeSync(fd);
81
81
  }
82
- renameSync(tmpPath, ledgerPath);
82
+ renameAtomicFile(tmpPath, ledgerPath, undefined, "lab-ledger");
83
83
  renamed = true;
84
84
  } catch (err) {
85
85
  if (!renamed) {
@@ -5,7 +5,7 @@ import type { LabBehaviorSource, LabBehaviorValues } from "../live/types";
5
5
  const CLOSED_KEYS = new Set([
6
6
  "wire.adapter", "wire.upstreamProtocol", "wire.responsesPath", "wire.commandCodeVersion", "wire.modelSuffixMode",
7
7
  "auth.mode", "auth.transport",
8
- "responses.stateful", "responses.upstreamStreaming", "responses.serviceTier", "responses.snapshotRepair", "responses.itemIdRepair",
8
+ "responses.stateful", "responses.upstreamStreaming", "responses.serviceTier", "responses.fastWireKind", "responses.fastWireValue", "responses.snapshotRepair", "responses.itemIdRepair",
9
9
  "limits.contextWindow", "limits.maxInputTokens", "limits.maxOutputTokens",
10
10
  "modalities.input",
11
11
  "sampling.omitTemperature", "sampling.omitTopP", "sampling.omitPenalties",
@@ -72,6 +72,6 @@ export function normalizeBehaviorValues(values: LabBehaviorValues): LabBehaviorV
72
72
 
73
73
  /** Hash the authoritative production resolver output; Lab performs validation/canonicalization only. */
74
74
  export function buildBehaviorFingerprintV1(values: LabBehaviorValues): string {
75
- const payload = { schemaVersion: 1, resolverVersion: 1, values: normalizeBehaviorValues(values) };
75
+ const payload = { schemaVersion: 1, resolverVersion: 2, values: normalizeBehaviorValues(values) };
76
76
  return createHash("sha256").update(jcsStringify(payload)).digest("hex");
77
77
  }
@@ -5,7 +5,6 @@ import {
5
5
  readFileSync,
6
6
  readdirSync,
7
7
  realpathSync,
8
- renameSync,
9
8
  rmdirSync,
10
9
  unlinkSync,
11
10
  writeFileSync,
@@ -13,6 +12,7 @@ import {
13
12
  import { randomUUID } from "node:crypto";
14
13
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
15
14
  import type { GenerationContext } from "./state-store-sweeper";
15
+ import { renameAtomicFile } from "./windows-atomic-replace";
16
16
 
17
17
  export const CONFIG_OWNER_FILE = ".opencodex-owner.json";
18
18
  export const CONFIG_UNINSTALL_MANIFEST = ".opencodex-uninstall.json";
@@ -232,7 +232,10 @@ function writeManifest(configDir: string, manifest: ConfigUninstallManifest): vo
232
232
  const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
233
233
  writeFileSync(temp, `${JSON.stringify(manifest, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
234
234
  try {
235
- renameSync(temp, path);
235
+ // Same Windows sharing-violation tolerance the config writer has: a
236
+ // scanner holding the manifest must not turn uninstall bookkeeping into a
237
+ // hard failure.
238
+ renameAtomicFile(temp, path, undefined, "config-ownership");
236
239
  } catch (error) {
237
240
  try { unlinkSync(temp); } catch { /* best effort */ }
238
241
  throw error;
@@ -241,10 +241,18 @@ export function assessUrlDestination(url: string): UrlDestinationAssessment | nu
241
241
  * Returns the validated addresses so direct callers can pin the connect peer and
242
242
  * avoid a second, rebindable resolution. DNS failures remain fail-closed here;
243
243
  * the provider proxy wrapper alone may recognize that typed failure and degrade.
244
+ *
245
+ * `allowBenchmarkAddresses` is an explicit outbound-only opt-in for Clash/Surge/
246
+ * Mihomo fake-IP DNS (IANA benchmark space 198.18.0.0/15, credit #1748): a hostname
247
+ * answer in that range is accepted without marking the destination private, so the
248
+ * caller can keep the hostname on its configured HTTP(S) proxy path. It applies to
249
+ * resolved answers only — a literal 198.18.x URL still rejects — and mixed answers
250
+ * that include any other non-public address still fail. Callers that do not pass it
251
+ * (image and Lab fetch) keep rejecting benchmark space.
244
252
  */
245
253
  export async function resolvePublicAddresses(
246
254
  url: string,
247
- options?: string | { context?: string; allowPrivateNetwork?: boolean },
255
+ options?: string | { context?: string; allowPrivateNetwork?: boolean; allowBenchmarkAddresses?: boolean },
248
256
  ): Promise<{
249
257
  hostname: string;
250
258
  addresses: { address: string; family: number }[];
@@ -254,6 +262,7 @@ export async function resolvePublicAddresses(
254
262
  ? `${options.trim() || "image"} URL`
255
263
  : options?.context?.trim() || "image URL";
256
264
  const privateNetworkAllowed = typeof options === "object" && options?.allowPrivateNetwork === true;
265
+ const benchmarkAllowed = typeof options === "object" && options?.allowBenchmarkAddresses === true;
257
266
  let hostname: string;
258
267
  try {
259
268
  hostname = normalizeHostname(new URL(url.trim()).hostname);
@@ -294,6 +303,14 @@ export async function resolvePublicAddresses(
294
303
  const ipKind = isIP(address) || (family === 4 || family === 6 ? family : 0);
295
304
  const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null;
296
305
  if (!assessment || assessment.kind !== "public") {
306
+ // Hostname → 198.18.0.0/15 under the explicit opt-in is Clash/Surge/Mihomo
307
+ // fake-IP DNS, not a LAN provider. Accept it without allowPrivateNetwork and
308
+ // do not mark the destination private, so the caller's HTTP(S)_PROXY path
309
+ // still applies (credit #1748).
310
+ if (benchmarkAllowed && assessment?.kind === "private" && assessment.detail === "benchmark address") {
311
+ validatedAddresses.push({ address, family: ipKind === 4 || ipKind === 6 ? ipKind : (family || 4) });
312
+ continue;
313
+ }
297
314
  const allowedPrivateAddress = privateNetworkAllowed
298
315
  && assessment
299
316
  && (assessment.kind === "loopback" || assessment.kind === "private");
@@ -148,6 +148,13 @@ async function providerOutboundRequest(
148
148
  resolved = await resolveAddresses(url, {
149
149
  context: "provider URL",
150
150
  allowPrivateNetwork: allowPrivate,
151
+ // Clash/Surge/Mihomo fake-IP DNS (198.18.0.0/15) answers are admitted only
152
+ // when this exact host will use the configured outbound proxy: the hostname
153
+ // then rides the proxy as an ordinary CONNECT instead of failing as a private
154
+ // destination or being pin-connected to the fake-IP (credit #1748). A NO_PROXY
155
+ // match is a direct route, so it keeps the benchmark answer rejected. Image/Lab
156
+ // fetch never passes this flag.
157
+ allowBenchmarkAddresses: proxyConfigured && !noProxyMatches(parsed),
151
158
  });
152
159
  } catch (error) {
153
160
  const dnsResolutionFailed = error instanceof DestinationDnsResolutionError
package/src/lib/redact.ts CHANGED
@@ -443,6 +443,17 @@ export function redactSecretString(value: string): string {
443
443
  return redacted;
444
444
  }
445
445
 
446
+ /** Shared bounded representation for caller-controlled scalar metadata stored in logs. */
447
+ export function sanitizeLogMetadataString(value: unknown, maxLength = 64): string | undefined {
448
+ if (typeof value !== "string" || !Number.isInteger(maxLength) || maxLength < 1) return undefined;
449
+ // Remove every control/line-separator code point that common terminals and log viewers
450
+ // can render as a record boundary before the value reaches a single-line log field.
451
+ const filtered = value.trim().replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "");
452
+ if (!filtered) return undefined;
453
+ const redacted = redactSecretString(filtered).trim();
454
+ return redacted ? redacted.slice(0, maxLength) : undefined;
455
+ }
456
+
446
457
  export function redactSecrets(value: unknown): unknown {
447
458
  if (typeof value === "string") return redactSecretString(value);
448
459
  if (Array.isArray(value)) return value.map(item => redactSecrets(item));
@@ -1,4 +1,7 @@
1
- // Schema-aware repair for integer tool arguments that arrive as floats (issue #1611).
1
+ // Schema-aware repair for tool arguments whose serialized representation disagrees
2
+ // with the declared schema in a way that has exactly one faithful reading.
3
+ //
4
+ // Case 1 — integer fields arriving as integral floats (issue #1611).
2
5
  //
3
6
  // Grok serializes integer tool-call arguments through a float representation, so
4
7
  // `yield_time_ms: 120000` leaves the provider as `120000.0`. Codex declares those
@@ -15,7 +18,23 @@
15
18
  // `120000.0` has exactly one integer reading, so it is repaired;
16
19
  // - `1.5` in an integer field is a genuine disagreement with the schema and is left
17
20
  // alone so it still fails, rather than being truncated into a plausible lie.
18
- // Anything without a declared `integer` type is never touched.
21
+ //
22
+ // Case 2 — string fields arriving as bare integers (issue #1938).
23
+ //
24
+ // Cursor-served models emit `{"cell_id": 4}` where the schema declares
25
+ // `{"type": "string"}`. Codex rejects the call before the tool runs (invalid type:
26
+ // integer `4`, expected a string), and the model never self-corrects, so every such
27
+ // call is a hard failure loop. A safely-integral JSON number in a string-declared
28
+ // field has exactly one faithful string reading (`4` -> `"4"`), so it is repaired
29
+ // under the same intent boundary:
30
+ // - only when the field declares `string` and no numeric type (a
31
+ // `["integer","string"]` union accepts the number as-is and is left alone);
32
+ // - a non-integral number (`4.5`) in a string field is a genuine disagreement and
33
+ // is left alone so it still fails;
34
+ // - beyond 2^53-1 the parsed value may differ from the serialized text, so the
35
+ // original bytes stay.
36
+ //
37
+ // Anything without a declared `integer`/`string` type is never touched.
19
38
 
20
39
  /** JSON Schema subset we need; provider tool schemas are untrusted input. */
21
40
  type SchemaNode = Record<string, unknown>;
@@ -33,6 +52,21 @@ function declaresInteger(schema: SchemaNode): boolean {
33
52
  return Array.isArray(type) && type.includes("integer");
34
53
  }
35
54
 
55
+ /** True when the node declares `string`, including `["string","null"]` unions. */
56
+ function declaresString(schema: SchemaNode): boolean {
57
+ const type = schema.type;
58
+ if (type === "string") return true;
59
+ return Array.isArray(type) && type.includes("string");
60
+ }
61
+
62
+ /** True when the node accepts a JSON number (`integer` or `number`), so a numeric
63
+ * value is already schema-valid and must not be rewritten into a string. */
64
+ function declaresNumeric(schema: SchemaNode): boolean {
65
+ const type = schema.type;
66
+ if (type === "integer" || type === "number") return true;
67
+ return Array.isArray(type) && (type.includes("integer") || type.includes("number"));
68
+ }
69
+
36
70
  /**
37
71
  * Resolve a local `$ref` (`#/$defs/Foo`, `#/definitions/Foo`).
38
72
  *
@@ -91,8 +125,17 @@ function coerceValue(value: unknown, schema: SchemaNode | undefined, root: Schem
91
125
 
92
126
  if (typeof value === "number") {
93
127
  if (!resolved) return { value, changed: false };
94
- const integerDeclared = declaresInteger(resolved)
95
- || compositionBranches(resolved).some(declaresInteger);
128
+ const branches = compositionBranches(resolved);
129
+ const integerDeclared = declaresInteger(resolved) || branches.some(declaresInteger);
130
+ if (!integerDeclared && safelyIntegral(value)) {
131
+ // Issue #1938: a bare integer in a string-only field has exactly one faithful
132
+ // string reading. A field that also accepts a numeric type keeps the number.
133
+ const stringDeclared = declaresString(resolved) || branches.some(declaresString);
134
+ const numericDeclared = declaresNumeric(resolved) || branches.some(declaresNumeric);
135
+ if (stringDeclared && !numericDeclared) {
136
+ return { value: String(value), changed: true };
137
+ }
138
+ }
96
139
  // Not an integer field, already an integer, non-integral, or unrepresentable:
97
140
  // in every one of those cases the received value is the right thing to keep.
98
141
  if (!integerDeclared || !safelyIntegral(value)) return { value, changed: false };
@@ -141,8 +184,9 @@ export function coerceIntegerToolArguments(
141
184
  parameters: Record<string, unknown> | undefined,
142
185
  ): string {
143
186
  if (!parameters || !args) return args;
144
- // Cheap reject: a payload with no fractional-looking number cannot need repair.
145
- if (!/\d\.\d/.test(args)) return args;
187
+ // Cheap reject: a payload with no digit cannot need either repair (integral-float
188
+ // -> integer, or bare-integer -> string).
189
+ if (!/\d/.test(args)) return args;
146
190
  let parsed: unknown;
147
191
  try {
148
192
  parsed = JSON.parse(args);
@@ -0,0 +1,57 @@
1
+ import type { OcxProviderConfig, UpstreamHttpVersion } from "../types";
2
+
3
+ /** True when a provider explicitly selected the HTTP/1.1 compatibility path. */
4
+ export function isPinnedHttp1(version: UpstreamHttpVersion | undefined): boolean {
5
+ return version === "http1.1" || version === "h1";
6
+ }
7
+
8
+ /**
9
+ * Bun's fetch accepts a non-standard `protocol` init to pin the HTTP version
10
+ * (BunFetchRequestInit.protocol). The DOM lib types do not include it, so the
11
+ * value is represented through a RequestInit cast at this shared boundary.
12
+ */
13
+ const UPSTREAM_HTTP_VERSION_PROTOCOL: Record<Exclude<UpstreamHttpVersion, "auto">, string> = {
14
+ "http1.1": "http1.1",
15
+ h1: "h1",
16
+ http2: "http2",
17
+ h2: "h2",
18
+ };
19
+
20
+ /** Raised before I/O when a fixed Bun HTTP-version pin cannot apply to the target URL. */
21
+ export class UpstreamHttpVersionTargetError extends Error {
22
+ readonly code = "upstream_http_version_target";
23
+
24
+ constructor(readonly observedProtocol: string) {
25
+ super(`upstream HTTP version pin requires an HTTPS target (received ${observedProtocol})`);
26
+ this.name = "UpstreamHttpVersionTargetError";
27
+ }
28
+ }
29
+
30
+ /** Attach Bun's `protocol` pin for one explicit version value. */
31
+ export function withUpstreamHttpVersionValue(
32
+ input: Parameters<typeof globalThis.fetch>[0],
33
+ init: RequestInit | undefined,
34
+ version: UpstreamHttpVersion | undefined,
35
+ ): RequestInit | undefined {
36
+ if (!version || version === "auto") return init;
37
+ // Bun's protocol pin requires an https: target. An explicit fixed-version request must fail
38
+ // locally when the pin cannot be honored instead of silently negotiating another transport.
39
+ const target = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
40
+ let targetUrl: URL;
41
+ try {
42
+ targetUrl = new URL(target);
43
+ } catch {
44
+ throw new UpstreamHttpVersionTargetError("unparseable");
45
+ }
46
+ if (targetUrl.protocol !== "https:") throw new UpstreamHttpVersionTargetError(targetUrl.protocol);
47
+ return { ...(init ?? {}), protocol: UPSTREAM_HTTP_VERSION_PROTOCOL[version] } as RequestInit;
48
+ }
49
+
50
+ /** Attach Bun's `protocol` pin when the provider opted into a fixed HTTP version. */
51
+ export function withUpstreamHttpVersion(
52
+ input: Parameters<typeof globalThis.fetch>[0],
53
+ init: RequestInit | undefined,
54
+ provider: Pick<OcxProviderConfig, "upstreamHttpVersion">,
55
+ ): RequestInit | undefined {
56
+ return withUpstreamHttpVersionValue(input, init, provider.upstreamHttpVersion);
57
+ }
@@ -215,6 +215,7 @@ export async function fetchWithAttemptDeadline(
215
215
  timeoutMs: number,
216
216
  abortSignal?: AbortSignal,
217
217
  preferIdentityEncoding = false,
218
+ executor: typeof globalThis.fetch = globalThis.fetch,
218
219
  ): Promise<Response> {
219
220
  const attemptTimeout = clearableDeadline(timeoutMs, abortSignal);
220
221
  const headers = new Headers(init.headers);
@@ -222,7 +223,7 @@ export async function fetchWithAttemptDeadline(
222
223
  headers.set("accept-encoding", "identity");
223
224
  }
224
225
  try {
225
- return await fetch(url, {
226
+ return await executor(url, {
226
227
  ...init,
227
228
  headers,
228
229
  signal: attemptTimeout.signal,
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Windows-tolerant atomic replace.
3
+ *
4
+ * POSIX `rename()` replaces the destination entry unconditionally. Windows can
5
+ * refuse the same call with EBUSY, EPERM or EACCES while another process holds
6
+ * the target open — a real-time scanner that just indexed the file, a sync
7
+ * client, a backup agent. The hold is usually momentary, so a bounded retry
8
+ * turns an operational failure back into a successful publish.
9
+ *
10
+ * The envelope is deliberately small: two retries, 25ms then 50ms, about 75ms
11
+ * total. It is sized for a scanner blinking, not for a file someone actually
12
+ * has open. Widening it without evidence would trade a rare failure for a
13
+ * routine stall.
14
+ *
15
+ * This lives in its own module rather than in config.ts because
16
+ * config-ownership.ts is one of its callers and config.ts already imports
17
+ * config-ownership.ts — exporting it from there would close an import cycle.
18
+ */
19
+
20
+ import { renameSync } from "node:fs";
21
+
22
+ /**
23
+ * Which durable publisher retried. A closed union on purpose: the value is a
24
+ * label in a diagnostic surface, and a path-derived string could carry a
25
+ * username. The type is the enforcement — a path cannot be passed here without
26
+ * failing typecheck. (`privacy:scan` reads file text and cannot see a runtime
27
+ * value, so it is a backstop for the response body, not the guard.)
28
+ */
29
+ export type ReplacePublisher =
30
+ | "config"
31
+ | "prompt-journal"
32
+ | "config-ownership"
33
+ | "claude-agents"
34
+ | "lab-automation"
35
+ | "lab-ledger"
36
+ | "storage-cleanup"
37
+ | "tray";
38
+
39
+ /** The Windows error codes this module treats as a momentary hold. */
40
+ export type ReplaceRetryCode = "EBUSY" | "EPERM" | "EACCES";
41
+
42
+ export interface ReplaceRetryCounts {
43
+ /** Replaces that hit this code and were retried. */
44
+ retried: number;
45
+ /** Replaces that exhausted every retry and threw. */
46
+ exhausted: number;
47
+ }
48
+
49
+ /**
50
+ * Keyed by publisher AND code. The code is the diagnostic half: EBUSY from a
51
+ * scanner, EACCES from a permissions problem and EPERM from a lock are three
52
+ * different stories, and collapsing them would leave the counters unable to
53
+ * answer the question they exist for.
54
+ */
55
+ const counters = new Map<string, ReplaceRetryCounts>();
56
+
57
+ function counterKey(publisher: ReplacePublisher, code: ReplaceRetryCode): string {
58
+ return `${publisher}:${code}`;
59
+ }
60
+
61
+ function bump(
62
+ publisher: ReplacePublisher,
63
+ code: ReplaceRetryCode,
64
+ field: keyof ReplaceRetryCounts,
65
+ ): void {
66
+ const key = counterKey(publisher, code);
67
+ const current = counters.get(key) ?? { retried: 0, exhausted: 0 };
68
+ current[field] += 1;
69
+ counters.set(key, current);
70
+ }
71
+
72
+ /**
73
+ * Process-lifetime snapshot, keyed `publisher:CODE`. These reset on restart,
74
+ * which is fine: the question they answer is "does this ever fire at all", not
75
+ * "how often per hour".
76
+ */
77
+ export function readWindowsReplaceRetryCounters(): Record<string, ReplaceRetryCounts> {
78
+ const out: Record<string, ReplaceRetryCounts> = {};
79
+ for (const [key, counts] of counters) out[key] = { ...counts };
80
+ return out;
81
+ }
82
+
83
+ /** Test-only: the counters are module state and cases must not leak into each other. */
84
+ export function resetWindowsReplaceRetryCountersForTests(): void {
85
+ counters.clear();
86
+ }
87
+ export interface AtomicRenameIO {
88
+ platform: NodeJS.Platform;
89
+ rename: (source: string, destination: string) => void;
90
+ sleep: (milliseconds: number) => void;
91
+ }
92
+
93
+ const MAX_RETRIES = 2;
94
+
95
+ /**
96
+ * Windows sharing violations only, returning the code so the caller can record
97
+ * which one. Any other error is the caller's to see, immediately.
98
+ */
99
+ function transientWindowsReplaceCode(
100
+ platform: NodeJS.Platform,
101
+ error: unknown,
102
+ ): ReplaceRetryCode | null {
103
+ if (platform !== "win32") return null;
104
+ const code = (error as NodeJS.ErrnoException).code;
105
+ return code === "EBUSY" || code === "EPERM" || code === "EACCES" ? code : null;
106
+ }
107
+
108
+ export function renameAtomicFile(
109
+ source: string,
110
+ destination: string,
111
+ io: AtomicRenameIO = {
112
+ platform: process.platform,
113
+ rename: renameSync,
114
+ sleep: Bun.sleepSync,
115
+ },
116
+ publisher: ReplacePublisher = "config",
117
+ ): void {
118
+ for (let attempt = 0; ; attempt += 1) {
119
+ try {
120
+ io.rename(source, destination);
121
+ return;
122
+ } catch (error) {
123
+ const code = transientWindowsReplaceCode(io.platform, error);
124
+ if (!code) throw error;
125
+ if (attempt >= MAX_RETRIES) {
126
+ bump(publisher, code, "exhausted");
127
+ throw error;
128
+ }
129
+ bump(publisher, code, "retried");
130
+ io.sleep(25 * (attempt + 1));
131
+ }
132
+ }
133
+ }
134
+
135
+ export async function renameAtomicFileAsync(
136
+ source: string,
137
+ destination: string,
138
+ publisher: ReplacePublisher = "config",
139
+ ): Promise<void> {
140
+ for (let attempt = 0; ; attempt += 1) {
141
+ try {
142
+ renameSync(source, destination);
143
+ return;
144
+ } catch (error) {
145
+ const code = transientWindowsReplaceCode(process.platform, error);
146
+ if (!code) throw error;
147
+ if (attempt >= MAX_RETRIES) {
148
+ bump(publisher, code, "exhausted");
149
+ throw error;
150
+ }
151
+ bump(publisher, code, "retried");
152
+ await Bun.sleep(25 * (attempt + 1));
153
+ }
154
+ }
155
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Shared termination of surviving Windows scheduler launcher/wrapper processes.
3
+ *
4
+ * `schtasks /end` ends the task instance but often leaves wscript/cmd running the
5
+ * `:loop` batch, which brings the proxy back during a stop, a restart, or
6
+ * post-update reclaim. Both the service teardown path and the update job need
7
+ * that guarantee, and they used to carry a copy each.
8
+ *
9
+ * The copies drifted in both directions, which is why this module exists rather
10
+ * than a second careful implementation: the updater received the #1589 argv
11
+ * cleanup that service.ts missed, and service.ts received the canonical-path
12
+ * scoping the updater missed. One of those gaps could force-terminate a wrapper
13
+ * belonging to a DIFFERENT OpenCodex home.
14
+ *
15
+ * Matching is scoped to the CANONICAL paths of one installation, never a bare
16
+ * filename, and the path must appear as a COMPLETE command-line token
17
+ * (wscript.exe spawns the .vbs as an argument; cmd.exe /c runs the .cmd). A
18
+ * substring match is excluded: an unrelated process whose command line merely
19
+ * contains the filename, and a wrapper under another home whose path ends with
20
+ * the same name, must both survive.
21
+ */
22
+ import { spawnSync } from "node:child_process";
23
+
24
+ import { resolveTrustedWindowsPowerShellExe } from "./windows-elevation";
25
+
26
+ /** Quote for PowerShell: single-quote the value and double any embedded quote. */
27
+ function quoteForPowerShell(value: string): string {
28
+ return `'${value.replace(/'/g, "''")}'`;
29
+ }
30
+
31
+ /**
32
+ * The PowerShell that finds and stops the wrappers for exactly these paths.
33
+ * Exported for tests: the matching rule is the whole point of this module, and
34
+ * the spawn itself is best-effort and unobservable.
35
+ */
36
+ export function windowsWrapperKillScript(paths: readonly string[]): string {
37
+ return [
38
+ `$pats = @(${paths.map(quoteForPowerShell).join(", ")});`,
39
+ "Get-CimInstance Win32_Process | Where-Object {",
40
+ " if ($_.ProcessId -eq $PID) { return $false };",
41
+ " $c = $_.CommandLine; if (-not $c) { return $false };",
42
+ " foreach ($p in $pats) {",
43
+ " $i = $c.IndexOf($p, [System.StringComparison]::OrdinalIgnoreCase);",
44
+ " if ($i -lt 0) { continue };",
45
+ " $before = if ($i -gt 0) { $c.Substring($i - 1, 1) } else { ' ' };",
46
+ " $end = $i + $p.Length;",
47
+ " $after = if ($end -lt $c.Length) { $c.Substring($end, 1) } else { ' ' };",
48
+ " if ($before -match '[\\s\"'']' -and $after -match '[\\s\"'']') { return $true };",
49
+ " };",
50
+ " $false",
51
+ "} | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }",
52
+ ].join(" ");
53
+ }
54
+
55
+ /**
56
+ * Best-effort: never throws, never reports. A wrapper that survives is handled
57
+ * by the caller's own stop verification.
58
+ */
59
+ export function killWindowsSchedulerWrappers(paths: {
60
+ scriptPath: string;
61
+ launcherPath: string;
62
+ }): void {
63
+ if (process.platform !== "win32") return;
64
+ try {
65
+ spawnSync(resolveTrustedWindowsPowerShellExe(), [
66
+ // No `-WindowStyle Hidden` here: Bun 1.3.14 can fail that direct CLI pair
67
+ // before the command runs (#1589). `windowsHide` below is sufficient.
68
+ "-NoProfile", "-NoLogo", "-NonInteractive",
69
+ "-Command", windowsWrapperKillScript([paths.scriptPath, paths.launcherPath]),
70
+ ], { stdio: "ignore", timeout: 5000, windowsHide: true });
71
+ } catch { /* best-effort */ }
72
+ }
@@ -12,7 +12,7 @@
12
12
  import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server";
13
13
  import { generatePKCE } from "./pkce";
14
14
  import type { OAuthController, OAuthCredentials } from "./types";
15
- import { antigravityUserAgent, ANTIGRAVITY_GOOG_API_CLIENT_UA, ANTIGRAVITY_IDE_VERSION } from "../adapters/client-fingerprint";
15
+ import { antigravityUserAgent, ANTIGRAVITY_IDE_VERSION } from "../adapters/client-fingerprint";
16
16
 
17
17
  const CLIENT_ID = process.env.GOOGLE_ANTIGRAVITY_CLIENT_ID
18
18
  || "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
@@ -110,7 +110,7 @@ async function onboardProject(accessToken: string, signal?: AbortSignal): Promis
110
110
  if (signal?.aborted) throw signal.reason ?? new Error("Antigravity onboarding aborted");
111
111
  const response = await fetch(`${DAILY_API}/${API_VERSION}:onboardUser`, {
112
112
  method: "POST",
113
- headers: { Authorization: `Bearer ${accessToken}`, Accept: "*/*", "Content-Type": "application/json", "User-Agent": antigravityUserAgent(), "x-goog-api-client": ANTIGRAVITY_GOOG_API_CLIENT_UA },
113
+ headers: { Authorization: `Bearer ${accessToken}`, Accept: "*/*", "Content-Type": "application/json", "User-Agent": antigravityUserAgent() },
114
114
  // `ide_version` is a version, not a User-Agent. `antigravityUserAgent()` returns the whole
115
115
  // header — `antigravity/ide/2.5.5 (aidev_client; os_type=...; arch=...)` — so onboarding was
116
116
  // sending a parenthesized UA string in a field the real client fills with `2.5.5`. It is a