@bitkyc08/opencodex 2.78.0 → 2.80.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 (91) hide show
  1. package/gui/dist/assets/App-CSJvmjNs.css +1 -0
  2. package/gui/dist/assets/App-MbDVfUeC.js +54 -0
  3. package/gui/dist/assets/{Tray-DiJpSub0.js → Tray-CzkxK82A.js} +1 -1
  4. package/gui/dist/assets/{index-DmQB8D_R.css → index-Brk6F3fn.css} +1 -1
  5. package/gui/dist/assets/index-UJt6npXk.js +94 -0
  6. package/gui/dist/assets/{quota-summary-B_a96nXj.js → quota-summary-DU3sD6Z-.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +3 -2
  9. package/src/adapters/cursor/live-transport.ts +5 -3
  10. package/src/adapters/cursor/native-exec-fs.ts +12 -9
  11. package/src/adapters/cursor/native-exec.ts +13 -5
  12. package/src/adapters/cursor/protobuf-request.ts +13 -7
  13. package/src/adapters/cursor/request-builder.ts +23 -10
  14. package/src/adapters/cursor/tool-definitions.ts +9 -6
  15. package/src/adapters/cursor/tool-guidance.ts +29 -8
  16. package/src/adapters/cursor/tool-naming.ts +40 -7
  17. package/src/adapters/cursor/tool-wording.ts +46 -0
  18. package/src/adapters/cursor.ts +3 -0
  19. package/src/adapters/devin.ts +37 -2
  20. package/src/adapters/openai-chat/messages.ts +3 -1
  21. package/src/claude/desktop-picker.ts +11 -9
  22. package/src/claude/inbound-content-options.ts +29 -2
  23. package/src/claude/inbound.ts +9 -0
  24. package/src/claude/intercept/local-ca-files.ts +265 -0
  25. package/src/claude/intercept/local-ca-windows.ts +73 -0
  26. package/src/claude/intercept/local-ca.ts +12 -24
  27. package/src/claude/intercept/picker-ca-persistence.ts +106 -0
  28. package/src/claude/intercept/picker-ca-startup.ts +16 -0
  29. package/src/claude/intercept/picker-ca-store.ts +70 -0
  30. package/src/claude/intercept/picker-ca.ts +43 -9
  31. package/src/claude/intercept/picker-runtime.ts +14 -4
  32. package/src/claude/intercept/runtime.ts +32 -23
  33. package/src/cli/ensure-readiness.ts +48 -0
  34. package/src/cli/gui-pair-client.ts +15 -7
  35. package/src/cli/gui.ts +4 -1
  36. package/src/cli/hub.ts +1 -0
  37. package/src/cli/index.ts +10 -11
  38. package/src/cli/status.ts +2 -2
  39. package/src/cli/update-restart-child.ts +4 -1
  40. package/src/codex/account-credit-use.ts +8 -2
  41. package/src/codex/auth-api/account-pause-group.ts +113 -0
  42. package/src/codex/auth-api/routes.ts +18 -12
  43. package/src/codex/auth-context.ts +45 -3
  44. package/src/codex/autostart-health.ts +13 -3
  45. package/src/codex/journal.ts +36 -5
  46. package/src/codex/pool-credit-policy.ts +67 -0
  47. package/src/codex/shim-overlay.ts +9 -1
  48. package/src/combos/jev.ts +3 -1
  49. package/src/generated/compatibility-version.json +135 -63
  50. package/src/lib/gui-pair-capability.ts +1 -1
  51. package/src/lib/gui-pair-intent.ts +170 -0
  52. package/src/lib/windows-owner-acl.ts +143 -0
  53. package/src/providers/openai-sidecar-credit.ts +26 -0
  54. package/src/providers/openai-sidecar.ts +35 -0
  55. package/src/server/claude-messages.ts +1 -0
  56. package/src/server/gui-pair-delivery.ts +23 -0
  57. package/src/server/index/serve-options.ts +5 -8
  58. package/src/server/index.ts +12 -14
  59. package/src/server/live-native-calls.ts +94 -0
  60. package/src/server/live.ts +28 -5
  61. package/src/server/local-management-read-client.ts +15 -11
  62. package/src/server/management/oauth-account-routes.ts +15 -0
  63. package/src/server/management/route-registry.ts +1 -0
  64. package/src/server/management/shared.ts +9 -3
  65. package/src/server/messages-native-scope.ts +60 -0
  66. package/src/server/messages-native.ts +9 -0
  67. package/src/server/responses/adapter-dispatch.ts +31 -2
  68. package/src/server/responses/combo-requested-effort.ts +14 -0
  69. package/src/server/responses/compact.ts +17 -2
  70. package/src/server/responses/core-auth.ts +18 -1
  71. package/src/server/responses/core-codex-account.ts +18 -8
  72. package/src/server/responses/core-combo.ts +5 -7
  73. package/src/server/responses/non-replayable-error.ts +48 -0
  74. package/src/server/responses/passthrough-delivery.ts +5 -1
  75. package/src/server/responses/passthrough-dispatch.ts +21 -19
  76. package/src/server/responses/request-prepare.ts +11 -1
  77. package/src/server/responses/sidecar-execution.ts +1 -0
  78. package/src/server/search.ts +4 -0
  79. package/src/server/startup-health-cache.ts +2 -6
  80. package/src/service/desktop-startup.ts +100 -3
  81. package/src/vision/describe.ts +17 -10
  82. package/src/vision/index.ts +1 -0
  83. package/src/web-search/executor.ts +17 -10
  84. package/src/web-search/format-result.ts +304 -20
  85. package/src/web-search/header-deadline.ts +67 -0
  86. package/src/web-search/loop.ts +7 -6
  87. package/src/web-search/passthrough-bridge.ts +1 -0
  88. package/src/web-search/run-turn-loop.ts +1 -1
  89. package/gui/dist/assets/App-BtfG5id-.js +0 -54
  90. package/gui/dist/assets/App-lCIslZ1L.css +0 -1
  91. package/gui/dist/assets/index-Btc3Ogw4.js +0 -94
@@ -18,7 +18,8 @@ import {
18
18
  relayWithAbort,
19
19
  } from "../relay";
20
20
  import { isUsageDebugEnabled } from "../../usage/debug";
21
- import { isReplayRefusalResponse } from "../../lib/upstream-retry";
21
+ import { isNonReplayableResponse, isReplayRefusalResponse } from "../../lib/upstream-retry";
22
+ import { sanitizeNonReplayableUpstreamError } from "./non-replayable-error";
22
23
  import { teeWithBoundedInspection } from "../inspection-tee";
23
24
  import {
24
25
  codexForwardTerminalOutcomeRecorder,
@@ -543,6 +544,9 @@ export async function deliverPassthroughResponse(
543
544
  // through sanitizePassthroughHeaders) so a redirect to a dead host can never
544
545
  // masquerade as a pre-connection failure after the credential was seen.
545
546
  // The numeric outcome above already classified it neutral — no streak.
547
+ if (isNonReplayableResponse(upstreamResponse) && !isReplayRefusalResponse(upstreamResponse)) {
548
+ return await sanitizeNonReplayableUpstreamError(upstreamResponse, upstream.signal);
549
+ }
546
550
  if (upstreamResponse.status >= 300 && upstreamResponse.status < 400) {
547
551
  return new Response(upstreamResponse.body, {
548
552
  status: upstreamResponse.status,
@@ -37,7 +37,7 @@ import {
37
37
  codexProbeLeaseId,
38
38
  codexProbeQuotaScope,
39
39
  codexTransientProbeGrant,
40
- createCodexReserveDispatchGuard,
40
+ createCodexAuthDispatchGuard,
41
41
  } from "../../codex/auth-context";
42
42
  import {
43
43
  NamespaceToolCollisionError,
@@ -87,7 +87,6 @@ import {
87
87
  fetchWithHeaderTimeout,
88
88
  providerFetch,
89
89
  safeHostLabel,
90
- storedPoolReplayDispatchNotifier,
91
90
  } from "./fetch-helpers";
92
91
  import { classifyPoolRecoveryDispatch } from "../../routing/probe-lease";
93
92
  import { clientCancelledResponse } from "./core-errors";
@@ -816,7 +815,7 @@ export async function preparePassthroughExchange(
816
815
  throw new Error("Credential selection changed before pre-output stream recovery");
817
816
  }
818
817
  if (isCanonicalOpenAiForwardProvider(route.provider)) {
819
- createCodexReserveDispatchGuard(
818
+ createCodexAuthDispatchGuard(
820
819
  admissionState.authCtx,
821
820
  options.codexAuthPolicy ?? config,
822
821
  route.modelId,
@@ -984,7 +983,7 @@ export async function preparePassthroughExchange(
984
983
  modelId: route.modelId,
985
984
  onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
986
985
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
987
- ? createCodexReserveDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
986
+ ? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
988
987
  }),
989
988
  route.provider.authMode === "forward")
990
989
  // Every real attempt response — including an intermediate 5xx the
@@ -1089,7 +1088,7 @@ export async function preparePassthroughExchange(
1089
1088
  modelId: route.modelId,
1090
1089
  onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
1091
1090
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
1092
- ? createCodexReserveDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
1091
+ ? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
1093
1092
  }),
1094
1093
  route.provider.authMode === "forward")
1095
1094
  .then(adoptObservedResponse);
@@ -1180,13 +1179,13 @@ export async function preparePassthroughExchange(
1180
1179
  // The replay-dispatched signal is what bounds the rest of this logical request, so it
1181
1180
  // has to describe a send that actually happened. fetchWithHeaderTimeout awaits pacing
1182
1181
  // admission BEFORE calling the executor, so signalling at the call site would spend the
1183
- // budget even when a rejected pacing wait means nothing reaches the network. Wrapping
1184
- // the executor moves the signal to the last moment before the send, where a throw from
1185
- // here on is a genuine transport attempt. The notifier is built once for the whole leg:
1186
- // it fires on the first dispatch, and a replacement is another send of the same replay
1187
- // rather than a second one to announce.
1188
- const oauthReplayExecutor = storedPoolReplayDispatchNotifier(
1189
- providerFetch(route.provider, options.codexWsRuntimeIdentity, {
1182
+ // budget even when a rejected pacing wait means nothing reaches the network. Signal
1183
+ // only after the physical-dispatch policy check succeeds; a local refusal is no send.
1184
+ // A replacement is another send of the same replay, rather than a second announcement.
1185
+ const replayGuard = isCanonicalOpenAiForwardProvider(route.provider)
1186
+ ? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined;
1187
+ let replayNotified = false;
1188
+ const oauthReplayExecutor = providerFetch(route.provider, options.codexWsRuntimeIdentity, {
1190
1189
  httpOnly: canonicalBufferedJson,
1191
1190
  nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt
1192
1191
  && responseEffects.plaintextV2AgentMessageToolNames.size === 0
@@ -1195,11 +1194,14 @@ export async function preparePassthroughExchange(
1195
1194
  providerName: route.providerName,
1196
1195
  modelId: route.modelId,
1197
1196
  onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
1198
- beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
1199
- ? createCodexReserveDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
1200
- }),
1201
- codex401ReplayKind === "stored" ? options.onStoredPool401ReplayDispatched : undefined,
1202
- );
1197
+ beforeDispatch: headers => {
1198
+ replayGuard?.(headers);
1199
+ if (codex401ReplayKind === "stored" && !replayNotified) {
1200
+ replayNotified = true;
1201
+ options.onStoredPool401ReplayDispatched?.();
1202
+ }
1203
+ },
1204
+ });
1203
1205
  // Routed through the shared helper so an ambiguous reset on THIS leg answers with the
1204
1206
  // same refusal every other leg gives. A bare fetch here rejected instead, and the
1205
1207
  // caller's transport-failure path turns a rejection into a client-retryable 502 --
@@ -1346,7 +1348,7 @@ export async function preparePassthroughExchange(
1346
1348
  modelId: route.modelId,
1347
1349
  onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
1348
1350
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
1349
- ? createCodexReserveDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
1351
+ ? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
1350
1352
  }),
1351
1353
  route.provider.authMode === "forward")
1352
1354
  .then(adoptObservedResponse);
@@ -1481,7 +1483,7 @@ export async function preparePassthroughExchange(
1481
1483
  modelId: route.modelId,
1482
1484
  onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
1483
1485
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
1484
- ? createCodexReserveDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
1486
+ ? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
1485
1487
  }),
1486
1488
  route.provider.authMode === "forward")
1487
1489
  .then(adoptObservedResponse);
@@ -46,6 +46,7 @@ import {
46
46
  codexPoolAffinityKey,
47
47
  previewCodexPoolLineage,
48
48
  applyCodexAuthContextToProvider,
49
+ releaseCodexAuthContextProbeLease,
49
50
  hasCallerCodexBearer,
50
51
  requestOwnedMainPinState,
51
52
  requestOwnedMainCredentialIsLive,
@@ -58,6 +59,7 @@ import {
58
59
  previousResponseProviderState,
59
60
  } from "../../responses/state";
60
61
  import { formatErrorResponse } from "../../bridge";
62
+ import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error";
61
63
  import type { OcxParsedRequest } from "../../types";
62
64
  import { buildToolBridgeMaps } from "./collaboration";
63
65
  import { parseRequest } from "../../responses/parser";
@@ -1352,7 +1354,15 @@ export async function prepareResponsesRequest(
1352
1354
  substituteMainCredential = finalAuth.substituteMainCredential;
1353
1355
  }
1354
1356
 
1355
- route.provider = applyCodexAuthContextToProvider(route.provider, admissionState.authCtx, route.codexAccountMode);
1357
+ try {
1358
+ route.provider = applyCodexAuthContextToProvider(route.provider, admissionState.authCtx, route.codexAccountMode);
1359
+ } catch (error) {
1360
+ releaseCodexAuthContextProbeLease(admissionState.authCtx);
1361
+ if (options.abortSignal?.aborted || req.signal.aborted) return clientCancelledResponse();
1362
+ const mapped = mapCodexAuthContextErrorToResponse(error, { now: Date.now(), accountSelector: route.codexAccountNamespace });
1363
+ if (mapped) return mapped;
1364
+ throw error;
1365
+ }
1356
1366
  applyCodexAccountGatedWireNormalization(parsed, route, logCtx);
1357
1367
  try {
1358
1368
  recordPolicyPreparedDestination(policyScope, route.providerName, parsed._wireModelOverride ?? parsed.modelId);
@@ -510,6 +510,7 @@ export async function executeResponsesSidecars(
510
510
  transportState.bindKeyUsageFromBridge(usage);
511
511
  },
512
512
  recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome,
513
+ beforeSidecarDispatch: wsPlan.forwardSidecar?.beforeDispatch,
513
514
  connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
514
515
  routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
515
516
  stallTimeoutSec: wsPlan.stallTimeoutSec,
@@ -11,6 +11,7 @@
11
11
  * paid backend than the one the operator named.
12
12
  */
13
13
  import { formatErrorResponse } from "../bridge";
14
+ import { openAiSidecarCreditRefusal } from "../providers/openai-sidecar-credit";
14
15
  import {
15
16
  CodexAccountCooldownError,
16
17
  codexMainProfileDrainingResponse,
@@ -202,6 +203,7 @@ export async function handleSearch(
202
203
  const sidecarExit = sidecarEnter("search");
203
204
  let upstreamResponse: Response | undefined;
204
205
  try {
206
+ upstream.beforeDispatch?.();
205
207
  upstreamResponse = await fetch(url, {
206
208
  method: "POST",
207
209
  headers,
@@ -233,6 +235,8 @@ export async function handleSearch(
233
235
  if (req.signal.aborted) {
234
236
  return formatErrorResponse(499, "client_closed_request", "search request canceled by client");
235
237
  }
238
+ const policyRefusal = openAiSidecarCreditRefusal(err);
239
+ if (policyRefusal) return cooldownErrorResponse(policyRefusal, Date.now(), accountNamespace);
236
240
  if (linkedSignal.signal.aborted || (err instanceof Error && err.name === "TimeoutError")) {
237
241
  upstream.recordOutcome?.("timeout");
238
242
  return formatErrorResponse(504, "upstream_error", "search upstream timed out");
@@ -1,7 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { join } from "node:path";
3
3
  import { codexAutoStartEnabled } from "../config";
4
- import { deriveStartupHealth, type StartupHealth } from "../codex/autostart-health";
4
+ import { deriveStartupHealth, startupHealthProbeBudgetMs, type StartupHealth } from "../codex/autostart-health";
5
5
  import { getCodexRoutingKind } from "../codex/inject";
6
6
  import { diagnoseCodexShim } from "../codex/shim";
7
7
  import { durableBunPath } from "../lib/bun-runtime";
@@ -11,7 +11,7 @@ import type { OcxConfig } from "../types";
11
11
  import { truncateRetainedUtf8 } from "../lib/admission";
12
12
 
13
13
  const CACHE_TTL_MS = 30_000;
14
- const PROBE_TIMEOUT_MS = probeTimeoutMs();
14
+ const PROBE_TIMEOUT_MS = startupHealthProbeBudgetMs();
15
15
  const INITIAL_PROBE_WAIT_MS = PROBE_TIMEOUT_MS + 500;
16
16
  const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024;
17
17
 
@@ -31,10 +31,6 @@ const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024;
31
31
  * bound in both cases: a wedged probe is still abandoned, and the caller still
32
32
  * receives the previous reading rather than waiting on it.
33
33
  */
34
- function probeTimeoutMs(): number {
35
- return process.platform === "win32" ? 15_000 : 5_000;
36
- }
37
-
38
34
  /** The probe bound, so a test's own budget cannot fall below what it must wait for. */
39
35
  export function startupHealthProbeTimeoutMs(): number {
40
36
  return PROBE_TIMEOUT_MS;
@@ -1,7 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { accessSync, constants, readFileSync, realpathSync } from "node:fs";
2
+ import { accessSync, constants, readFileSync, readlinkSync, realpathSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { dirname, join } from "node:path";
4
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { readPid } from "../config/process-state";
6
6
  import { resolveServiceOwnership, sameServiceOwnershipSubject, type ServiceOwnershipResolution } from "./state";
7
7
 
@@ -29,11 +29,28 @@ interface DesktopStartupDeps {
29
29
  ownership?: () => ServiceOwnershipResolution;
30
30
  readPid?: () => number | null;
31
31
  run?: typeof run;
32
+ env?: NodeJS.ProcessEnv;
33
+ proc?: ProcReader;
32
34
  }
33
35
 
36
+ /** Linux process identity from procfs: executable target and parent pid. */
37
+ interface ProcReader {
38
+ exe(pid: number): string;
39
+ parent(pid: number): number;
40
+ }
41
+ const procfs: ProcReader = {
42
+ exe: pid => readlinkSync(`/proc/${pid}/exe`),
43
+ // comm may contain spaces or parentheses, so fields are counted after the last ')'.
44
+ parent: pid => {
45
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
46
+ return Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
47
+ },
48
+ };
49
+
34
50
  /** Cheap ownership-only snapshot: no launchd/process probes on the server request path. */
35
51
  export function desktopStartupOwnership(deps: DesktopStartupDeps = {}): DesktopStartupDiagnostic | undefined {
36
- if ((deps.platform ?? process.platform) !== "darwin") return undefined;
52
+ const platform = deps.platform ?? process.platform;
53
+ if (platform !== "darwin" && platform !== "linux") return undefined;
37
54
  const owner = (deps.ownership ?? resolveServiceOwnership)();
38
55
  return owner.kind === "owned" && owner.ownership.owner === "desktop"
39
56
  ? deriveDesktopStartup({ owned: true, loginEnabled: false, running: false }) : undefined;
@@ -91,3 +108,83 @@ export function diagnoseMacDesktopStartup(deps: DesktopStartupDeps = {}): Deskto
91
108
  return deriveDesktopStartup({ ...facts, running: false });
92
109
  }
93
110
  }
111
+
112
+ /** HOME-based autostart entry written by the desktop app's login item (`tauri-plugin-autostart`). */
113
+ function linuxLoginApp(entry: string): string | null {
114
+ const fields = new Map<string, string>();
115
+ let section = "";
116
+ for (const raw of entry.split(/\r?\n/)) {
117
+ const line = raw.trim();
118
+ if (!line || line.startsWith("#")) continue;
119
+ if (line.startsWith("[")) { section = line; continue; }
120
+ const at = line.indexOf("=");
121
+ if (section === "[Desktop Entry]" && at > 0 && !fields.has(line.slice(0, at).trim())) {
122
+ fields.set(line.slice(0, at).trim(), line.slice(at + 1).trim());
123
+ }
124
+ }
125
+ if (fields.get("Type") !== "Application" || fields.get("Hidden") === "true"
126
+ || fields.get("X-GNOME-Autostart-enabled") === "false"
127
+ || ["OnlyShowIn", "NotShowIn", "TryExec"].some(key => fields.has(key))) return null;
128
+ // Credit only the backend's simple Exec form; desktop quoting/escaping/field codes are unsupported.
129
+ const app = /^([^\s"'\\%`><~|&;$*?#()]+) --autostart$/.exec(fields.get("Exec") ?? "")?.[1];
130
+ return app && isAbsolute(app) && basename(app) === "opencodex-desktop" ? app : null;
131
+ }
132
+
133
+ /** Read-only Linux desktop ownership, HOME login entry and live parent/child checks via procfs. */
134
+ export function diagnoseLinuxDesktopStartup(deps: DesktopStartupDeps = {}): DesktopStartupDiagnostic | undefined {
135
+ if ((deps.platform ?? process.platform) !== "linux") return undefined;
136
+ const ownership = deps.ownership ?? resolveServiceOwnership;
137
+ const owner = ownership();
138
+ if (owner.kind !== "owned" || owner.ownership.owner !== "desktop") return undefined;
139
+ const facts = { owned: true, loginEnabled: false, running: false };
140
+ const proc = deps.proc ?? procfs;
141
+ const pidReader = deps.readPid ?? readPid;
142
+ try {
143
+ const env = deps.env ?? process.env;
144
+ const home = deps.home ?? homedir();
145
+ const configured = env.XDG_CONFIG_HOME;
146
+ const config = configured && isAbsolute(configured) ? configured : join(home, ".config");
147
+ // auto-launch 0.5.0 writes autostart under HOME; Tauri app_config_dir uses XDG for install-id.
148
+ // A relocated XDG_CONFIG_HOME puts that entry outside the session's autostart search path.
149
+ if (resolve(config) !== resolve(home, ".config")) return deriveDesktopStartup(facts);
150
+ const capture = () => {
151
+ const id = readFileSync(join(config, "com.opencodex.desktop", "install-id"), "utf8");
152
+ if (id.trim() !== owner.ownership.installId) return null;
153
+ const entry = readFileSync(join(home, ".config", "autostart", "OpenCodex.desktop"), "utf8");
154
+ const login = linuxLoginApp(entry);
155
+ if (!login) return null;
156
+ const app = realpathSync(login);
157
+ if (basename(app) !== "opencodex-desktop") return null;
158
+ const proxy = realpathSync(join(dirname(app), "ocx"));
159
+ if (dirname(proxy) !== dirname(app) || basename(proxy) !== "ocx") return null;
160
+ accessSync(app, constants.X_OK);
161
+ accessSync(proxy, constants.X_OK);
162
+ const pid = pidReader();
163
+ const parent = pid !== null ? proc.parent(pid) : null;
164
+ const child = pid !== null ? realpathSync(proc.exe(pid)) : null;
165
+ const parentApp = parent !== null && parent > 1 ? realpathSync(proc.exe(parent)) : null;
166
+ const running = pid !== null && child === proxy && parentApp === app && pidReader() === pid;
167
+ const currentOwner = ownership();
168
+ if (currentOwner.kind === "unknown" || !sameServiceOwnershipSubject(owner, currentOwner)) return null;
169
+ return { id, entry, login, app, proxy, pid, parent, child, parentApp, running };
170
+ };
171
+ const first = capture();
172
+ if (!first) return deriveDesktopStartup(facts);
173
+ const final = capture();
174
+ // Re-read the entire evidence chain, including files, targets and the exact parent PID.
175
+ // A changed or unreadable snapshot cannot retain protection from the first read.
176
+ if (final && JSON.stringify(first) === JSON.stringify(final)) {
177
+ facts.loginEnabled = true;
178
+ facts.running = final.running;
179
+ }
180
+ return deriveDesktopStartup(facts);
181
+ } catch {
182
+ // Unreadable login or procfs evidence never grants protection or releases the claim.
183
+ return deriveDesktopStartup({ ...facts, running: false });
184
+ }
185
+ }
186
+
187
+ /** Platform dispatch for the full (probing) desktop startup diagnostic. */
188
+ export function diagnoseDesktopStartup(deps: DesktopStartupDeps = {}): DesktopStartupDiagnostic | undefined {
189
+ return (deps.platform ?? process.platform) === "linux" ? diagnoseLinuxDesktopStartup(deps) : diagnoseMacDesktopStartup(deps);
190
+ }
@@ -8,6 +8,7 @@ import { applyUpstreamRecoveryInit, fetchWithResetRetry } from "../lib/upstream-
8
8
  import { parseSidecarSSE } from "../web-search/parse";
9
9
  import type { SidecarOutcomeRecorder } from "../web-search/executor";
10
10
  import { NATIVE_RESERVE_MODEL } from "../codex/catalog/native-models";
11
+ import { openAiSidecarCreditRefusal } from "../providers/openai-sidecar-credit";
11
12
 
12
13
  export interface VisionSettings {
13
14
  model: string;
@@ -60,6 +61,7 @@ export async function describeImage(
60
61
  settings: VisionSettings,
61
62
  abortSignal?: AbortSignal,
62
63
  recordOutcome?: SidecarOutcomeRecorder,
64
+ beforeDispatch?: () => void,
63
65
  ): Promise<DescribeOutcome> {
64
66
  if (settings.reserveCompatibility && settings.model === NATIVE_RESERVE_MODEL) {
65
67
  return { text: "", error: "Luna Reserve compatibility is only available as a conversation model, not a vision helper. Choose another vision helper model." };
@@ -98,16 +100,19 @@ export async function describeImage(
98
100
  const res = await fetchWithResetRetry(
99
101
  // The replay needs `keepalive: false` to abandon the half-closed pooled socket; Bun has
100
102
  // ignored a bare `Connection: close` (oven-sh/bun#20492).
101
- recovery => fetch(`${forwardProvider.baseUrl}/responses`, applyUpstreamRecoveryInit({
102
- method: "POST",
103
- headers,
104
- body: JSON.stringify(body),
105
- signal: linkedSignal.signal,
106
- // Credential-bearing: do not follow a cross-origin 3xx. Bun strips `Authorization`
107
- // across origins but forwards nonstandard headers such as `chatgpt-account-id`,
108
- // `session_id`, and `x-codex-turn-metadata` to the redirect target.
109
- redirect: "manual",
110
- }, recovery)),
103
+ recovery => {
104
+ beforeDispatch?.();
105
+ return fetch(`${forwardProvider.baseUrl}/responses`, applyUpstreamRecoveryInit({
106
+ method: "POST",
107
+ headers,
108
+ body: JSON.stringify(body),
109
+ signal: linkedSignal.signal,
110
+ // Credential-bearing: do not follow a cross-origin 3xx. Bun strips `Authorization`
111
+ // across origins but forwards nonstandard headers such as `chatgpt-account-id`,
112
+ // `session_id`, and `x-codex-turn-metadata` to the redirect target.
113
+ redirect: "manual",
114
+ }, recovery));
115
+ },
111
116
  { replaySafe: true, abortSignal: linkedSignal.signal, label: "vision-sidecar" },
112
117
  );
113
118
  const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
@@ -129,6 +134,8 @@ export async function describeImage(
129
134
  detachBodyGuard();
130
135
  }
131
136
  } catch (e) {
137
+ const policyRefusal = openAiSidecarCreditRefusal(e);
138
+ if (policyRefusal) return { text: "", error: policyRefusal.message };
132
139
  const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error";
133
140
  const callerAborted = abortSignal?.aborted === true
134
141
  && linkedSignal.signal.aborted
@@ -269,6 +269,7 @@ async function executeDescription(
269
269
  plan.settings,
270
270
  abortSignal,
271
271
  recordSidecarOutcome,
272
+ plan.forwardSidecar.beforeDispatch,
272
273
  );
273
274
  }
274
275
 
@@ -15,6 +15,7 @@ import { withUpstreamHttpVersion } from "../lib/upstream-http-version";
15
15
  import { parseSidecarSSE, type WebSearchResult } from "./parse";
16
16
  import type { CodexUpstreamOutcome } from "../codex/routing";
17
17
  import { NATIVE_RESERVE_MODEL } from "../codex/catalog/native-models";
18
+ import { openAiSidecarCreditRefusal } from "../providers/openai-sidecar-credit";
18
19
 
19
20
  export interface SidecarSettings {
20
21
  model: string;
@@ -78,6 +79,7 @@ export async function runWebSearch(
78
79
  settings: SidecarSettings,
79
80
  abortSignal?: AbortSignal,
80
81
  recordOutcome?: SidecarOutcomeRecorder,
82
+ beforeDispatch?: () => void,
81
83
  ): Promise<SidecarOutcome> {
82
84
  if (settings.reserveCompatibility && settings.model === NATIVE_RESERVE_MODEL) {
83
85
  return { text: "", sources: [], error: "Luna Reserve compatibility is only available as a conversation model, not a search helper. Choose another search helper model." };
@@ -115,16 +117,19 @@ export async function runWebSearch(
115
117
  // defined init, and withUpstreamHttpVersion spreads the result, so `protocol` and the
116
118
  // recovery fields (`connection: close` + Bun's transport-level `keepalive: false`) survive
117
119
  // together. The reverse order needs a `?? init` fallback to type-check at all.
118
- recovery => fetch(url, withUpstreamHttpVersion(url, applyUpstreamRecoveryInit({
119
- method: "POST",
120
- headers,
121
- body: JSON.stringify(body),
122
- signal: linkedSignal.signal,
123
- // Credential-bearing: do not follow a cross-origin 3xx. Bun strips `Authorization`
124
- // across origins but forwards nonstandard headers such as `chatgpt-account-id`,
125
- // `session_id`, and `x-codex-turn-metadata` to the redirect target.
126
- redirect: "manual",
127
- }, recovery), forwardProvider)),
120
+ recovery => {
121
+ beforeDispatch?.();
122
+ return fetch(url, withUpstreamHttpVersion(url, applyUpstreamRecoveryInit({
123
+ method: "POST",
124
+ headers,
125
+ body: JSON.stringify(body),
126
+ signal: linkedSignal.signal,
127
+ // Credential-bearing: do not follow a cross-origin 3xx. Bun strips `Authorization`
128
+ // across origins but forwards nonstandard headers such as `chatgpt-account-id`,
129
+ // `session_id`, and `x-codex-turn-metadata` to the redirect target.
130
+ redirect: "manual",
131
+ }, recovery), forwardProvider));
132
+ },
128
133
  {
129
134
  replaySafe: true,
130
135
  abortSignal: linkedSignal.signal,
@@ -180,6 +185,8 @@ export async function runWebSearch(
180
185
  detachBodyGuard();
181
186
  }
182
187
  } catch (e) {
188
+ const policyRefusal = openAiSidecarCreditRefusal(e);
189
+ if (policyRefusal) return { text: "", sources: [], error: policyRefusal.message };
183
190
  const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error";
184
191
  const callerAborted = abortSignal?.aborted === true
185
192
  && linkedSignal.signal.aborted