@bitkyc08/opencodex 2.26.0 → 2.28.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 (83) hide show
  1. package/gui/dist/assets/{index-RL6b1bTV.js → index-D2sP-biU.js} +14 -14
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic.ts +60 -1
  5. package/src/adapters/base.ts +16 -2
  6. package/src/adapters/command-code.ts +4 -3
  7. package/src/adapters/cursor/cursor-errors.ts +15 -0
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google-antigravity-replay.ts +16 -8
  10. package/src/adapters/google.ts +22 -5
  11. package/src/adapters/openai-chat.ts +189 -60
  12. package/src/adapters/openai-responses.ts +37 -0
  13. package/src/adapters/tool-catalog-nudge.ts +1 -1
  14. package/src/bridge.ts +11 -5
  15. package/src/cli/doctor.ts +76 -0
  16. package/src/cli/help.ts +2 -0
  17. package/src/cli/index.ts +19 -6
  18. package/src/cli/models.ts +13 -6
  19. package/src/codex/account-usability.ts +3 -0
  20. package/src/codex/app-server-processes.ts +269 -37
  21. package/src/codex/auth-api.ts +22 -5
  22. package/src/codex/auth-context.ts +108 -3
  23. package/src/codex/catalog/aggregation.ts +3 -0
  24. package/src/codex/catalog/metadata.ts +17 -3
  25. package/src/codex/catalog/native-models.ts +22 -14
  26. package/src/codex/catalog/parsing.ts +20 -3
  27. package/src/codex/catalog/provider-fetch.ts +8 -0
  28. package/src/codex/catalog/sync.ts +63 -15
  29. package/src/codex/convergence.ts +61 -13
  30. package/src/codex/log-guard/path-safety.ts +52 -3
  31. package/src/codex/model-entitlements.ts +353 -0
  32. package/src/codex/native-profile-startup.ts +100 -2
  33. package/src/codex/quota.ts +28 -3
  34. package/src/codex/routing.ts +14 -8
  35. package/src/codex/user-identity.ts +21 -1
  36. package/src/config/provider-name.ts +24 -0
  37. package/src/config.ts +11 -24
  38. package/src/generated/compatibility-version.json +110 -70
  39. package/src/images/loop.ts +11 -4
  40. package/src/lib/destination-policy.ts +47 -0
  41. package/src/lib/shadow-call.ts +15 -0
  42. package/src/lib/state-store-registrations.ts +8 -2
  43. package/src/oauth/index.ts +33 -5
  44. package/src/oauth/store.ts +11 -5
  45. package/src/providers/antigravity-models.ts +70 -5
  46. package/src/providers/derive.ts +12 -2
  47. package/src/providers/fastwire.ts +39 -8
  48. package/src/providers/quota.ts +9 -2
  49. package/src/providers/registry.ts +120 -6
  50. package/src/providers/service-tier.ts +50 -15
  51. package/src/responses/parser.ts +59 -11
  52. package/src/responses/state.ts +162 -5
  53. package/src/responses/tool-search-compat.ts +301 -0
  54. package/src/router.ts +17 -3
  55. package/src/routing/capability.ts +26 -9
  56. package/src/routing/compatibility/behavior.ts +44 -6
  57. package/src/routing/profile.ts +1 -1
  58. package/src/server/chat-native.ts +11 -2
  59. package/src/server/index.ts +59 -8
  60. package/src/server/management/agent-settings-routes.ts +16 -2
  61. package/src/server/management/shared.ts +3 -1
  62. package/src/server/request-log.ts +31 -0
  63. package/src/server/responses/collaboration.ts +34 -9
  64. package/src/server/responses/compact.ts +54 -7
  65. package/src/server/responses/core.ts +259 -43
  66. package/src/server/responses/input-admission.ts +7 -2
  67. package/src/server/responses/responses-field-backfill.ts +88 -6
  68. package/src/server/responses/terminal-guard.ts +10 -0
  69. package/src/server/responses-tool-search-repair.ts +217 -0
  70. package/src/server/system-env.ts +74 -5
  71. package/src/service-manager-probe.ts +99 -0
  72. package/src/service.ts +86 -6
  73. package/src/tray/windows.ts +25 -5
  74. package/src/types/accounts.ts +37 -0
  75. package/src/types/config.ts +818 -0
  76. package/src/types/provider.ts +521 -0
  77. package/src/types/request.ts +358 -0
  78. package/src/types/tools.ts +131 -0
  79. package/src/types/wire.ts +80 -0
  80. package/src/types.ts +103 -1883
  81. package/src/usage/cost.ts +37 -1
  82. package/src/usage/log.ts +4 -0
  83. package/src/web-search/loop.ts +11 -4
@@ -0,0 +1,217 @@
1
+ import {
2
+ isTranslatorBudgetExceededError,
3
+ type TranslatorBudget,
4
+ } from "../lib/translator-budget";
5
+ import {
6
+ restoreRoutedToolSearchCalls,
7
+ } from "../responses/tool-search-compat";
8
+ import {
9
+ replaceSseDataPayload,
10
+ sseDataPayload,
11
+ type SseBlockRewrite,
12
+ } from "./sse-payload-rewrite";
13
+
14
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
15
+ return !!value && typeof value === "object" && !Array.isArray(value);
16
+ }
17
+
18
+ type PendingArgumentBlock = {
19
+ block: string;
20
+ itemId?: string;
21
+ outputIndex?: number;
22
+ retainedBytes: number;
23
+ };
24
+
25
+ const MAX_PENDING_ARGUMENT_FRAMES = 256;
26
+ const MAX_PENDING_ARGUMENT_BYTES = 1024 * 1024;
27
+
28
+ /**
29
+ * Public Responses gateways stream a lowered search as a normal function lifecycle. Codex expects
30
+ * only `tool_search_call` items, so classify each item before dropping its function-argument
31
+ * frames. Unknown early argument frames stay bounded until their item arrives.
32
+ */
33
+ export function createRoutedToolSearchRestoreBlockRewrite(
34
+ names: ReadonlySet<string>,
35
+ budget?: TranslatorBudget,
36
+ ): SseBlockRewrite {
37
+ const routedItemIds = new Set<string>();
38
+ const ordinaryItemIds = new Set<string>();
39
+ let pendingArguments: PendingArgumentBlock[] = [];
40
+ let pendingArgumentBytes = 0;
41
+ let passthrough = false;
42
+ let disposed = false;
43
+
44
+ const releaseAll = (): void => {
45
+ if (disposed) return;
46
+ disposed = true;
47
+ if (pendingArgumentBytes > 0) {
48
+ budget?.releaseRetained(pendingArgumentBytes, { kind: "retained_collectors" });
49
+ }
50
+ pendingArguments = [];
51
+ pendingArgumentBytes = 0;
52
+ routedItemIds.clear();
53
+ ordinaryItemIds.clear();
54
+ };
55
+
56
+ const retainPending = (
57
+ block: string,
58
+ itemId: string | undefined,
59
+ outputIndex: number | undefined,
60
+ ): readonly string[] | null => {
61
+ const retainedBytes = Buffer.byteLength(block, "utf8");
62
+ const overflow = pendingArguments.length >= MAX_PENDING_ARGUMENT_FRAMES
63
+ || pendingArgumentBytes + retainedBytes > MAX_PENDING_ARGUMENT_BYTES;
64
+ if (overflow) {
65
+ const flushed = [...pendingArguments.map(pending => pending.block), block];
66
+ if (pendingArgumentBytes > 0) {
67
+ budget?.releaseRetained(pendingArgumentBytes, { kind: "retained_collectors" });
68
+ }
69
+ pendingArguments = [];
70
+ pendingArgumentBytes = 0;
71
+ passthrough = true;
72
+ // Deliberately KEEP routedItemIds. Overflow means we stop buffering UNKNOWN frames, not
73
+ // that we forget what we already classified: an item restored to `tool_search_call`
74
+ // upstream of here would otherwise start emitting `function_call_arguments.*` again and
75
+ // the client would see a mixed private/public lifecycle for one call.
76
+ ordinaryItemIds.clear();
77
+ return flushed;
78
+ }
79
+ if (retainedBytes > 0) {
80
+ try {
81
+ budget?.chargeRetained(retainedBytes, { kind: "retained_collectors" });
82
+ } catch (error) {
83
+ if (!isTranslatorBudgetExceededError(error)) throw error;
84
+ const flushed = [...pendingArguments.map(pending => pending.block), block];
85
+ if (pendingArgumentBytes > 0) {
86
+ budget?.releaseRetained(pendingArgumentBytes, { kind: "retained_collectors" });
87
+ }
88
+ pendingArguments = [];
89
+ pendingArgumentBytes = 0;
90
+ passthrough = true;
91
+ // Same reasoning as the frame/byte overflow above: an already-restored routed item
92
+ // must keep its frames suppressed even once buffering stops.
93
+ ordinaryItemIds.clear();
94
+ return flushed;
95
+ }
96
+ }
97
+ pendingArguments.push({ block, itemId, outputIndex, retainedBytes });
98
+ pendingArgumentBytes += retainedBytes;
99
+ return null;
100
+ };
101
+
102
+ const takePending = (
103
+ itemId: string | undefined,
104
+ outputIndex: number | undefined,
105
+ ): string[] => {
106
+ const matched: PendingArgumentBlock[] = [];
107
+ const remaining: PendingArgumentBlock[] = [];
108
+ for (const pending of pendingArguments) {
109
+ const matches = pending.itemId !== undefined
110
+ ? itemId !== undefined && pending.itemId === itemId
111
+ : outputIndex !== undefined && pending.outputIndex === outputIndex;
112
+ (matches ? matched : remaining).push(pending);
113
+ }
114
+ pendingArguments = remaining;
115
+ const retainedBytes = matched.reduce((total, pending) => total + pending.retainedBytes, 0);
116
+ if (retainedBytes > 0) {
117
+ budget?.releaseRetained(retainedBytes, { kind: "retained_collectors" });
118
+ pendingArgumentBytes = Math.max(0, pendingArgumentBytes - retainedBytes);
119
+ }
120
+ return matched.map(pending => {
121
+ if (pending.itemId !== undefined || itemId === undefined) return pending.block;
122
+ const payload = sseDataPayload(pending.block);
123
+ if (payload === null) return pending.block;
124
+ try {
125
+ const parsed: unknown = JSON.parse(payload);
126
+ return isPlainObject(parsed)
127
+ ? replaceSseDataPayload(pending.block, JSON.stringify({ ...parsed, item_id: itemId }))
128
+ : pending.block;
129
+ } catch {
130
+ return pending.block;
131
+ }
132
+ });
133
+ };
134
+
135
+ const rewrite: SseBlockRewrite = (block: string): readonly string[] => {
136
+ if (disposed) return [block];
137
+ const payload = sseDataPayload(block);
138
+ if (payload === null || payload === "[DONE]") return [block];
139
+ let parsed: unknown;
140
+ try {
141
+ parsed = JSON.parse(payload);
142
+ } catch {
143
+ return [block];
144
+ }
145
+ if (!isPlainObject(parsed)) return [block];
146
+
147
+ const type = typeof parsed.type === "string" ? parsed.type : "";
148
+ // After overflow we stop BUFFERING unknown frames, but an item already restored to
149
+ // `tool_search_call` must keep its public argument frames suppressed — otherwise the client
150
+ // receives a private item followed by `function_call_arguments.*` for the same id, which is
151
+ // exactly the mixed lifecycle this rewrite exists to prevent. Everything else passes through.
152
+ if (passthrough) {
153
+ const passthroughItemId = typeof parsed.item_id === "string" ? parsed.item_id : undefined;
154
+ const isArgumentEvent = type === "response.function_call_arguments.delta"
155
+ || type === "response.function_call_arguments.done";
156
+ if (isArgumentEvent && passthroughItemId && routedItemIds.has(passthroughItemId)) return [];
157
+ return [block];
158
+ }
159
+ const outputIndex = typeof parsed.output_index === "number"
160
+ && Number.isInteger(parsed.output_index)
161
+ && parsed.output_index >= 0
162
+ ? parsed.output_index
163
+ : undefined;
164
+ if (
165
+ (type === "response.output_item.added" || type === "response.output_item.done")
166
+ && isPlainObject(parsed.item)
167
+ && parsed.item.type === "function_call"
168
+ && typeof parsed.item.name === "string"
169
+ ) {
170
+ const itemId = typeof parsed.item.id === "string" ? parsed.item.id : undefined;
171
+ const routed = names.has(parsed.item.name);
172
+ if (itemId) {
173
+ if (routed) {
174
+ routedItemIds.add(itemId);
175
+ ordinaryItemIds.delete(itemId);
176
+ } else {
177
+ ordinaryItemIds.add(itemId);
178
+ routedItemIds.delete(itemId);
179
+ }
180
+ }
181
+ const pending = takePending(itemId, outputIndex);
182
+ const restored = routed ? restoreRoutedToolSearchCalls(parsed, names) : { value: parsed, changed: false };
183
+ const restoredBlock = restored.changed
184
+ ? replaceSseDataPayload(block, JSON.stringify(restored.value))
185
+ : block;
186
+ // Classification is retained past `output_item.done` for BOTH kinds, until the terminal
187
+ // event releases everything.
188
+ //
189
+ // `done` ends the item, not the id's relevance. Forgetting a ROUTED id let a trailing
190
+ // `function_call_arguments.*` — which some upstreams emit after done — fall through to
191
+ // the unknown-id branch and reach the client as a public frame for an item the client
192
+ // was told is a private `tool_search_call`: the mixed lifecycle this rewrite exists to
193
+ // prevent. Forgetting an ORDINARY id is not leak-shaped but is not free either, because
194
+ // the same fall-through buffers its trailing frames as unknown and delays them until an
195
+ // item that will never arrive. Neither id is dropped early.
196
+ return routed ? [restoredBlock] : [...pending, restoredBlock];
197
+ }
198
+
199
+ const itemId = typeof parsed.item_id === "string" ? parsed.item_id : undefined;
200
+ const argumentEvent = type === "response.function_call_arguments.delta"
201
+ || type === "response.function_call_arguments.done";
202
+ if (argumentEvent && (!itemId || (!routedItemIds.has(itemId) && !ordinaryItemIds.has(itemId)))) {
203
+ return retainPending(block, itemId, outputIndex) ?? [];
204
+ }
205
+ if (argumentEvent && itemId && routedItemIds.has(itemId)) return [];
206
+
207
+ const terminal = type === "response.completed" || type === "response.failed" || type === "response.incomplete";
208
+ if (!terminal) return [block];
209
+ const restored = restoreRoutedToolSearchCalls(parsed, names);
210
+ releaseAll();
211
+ return restored.changed
212
+ ? [replaceSseDataPayload(block, JSON.stringify(restored.value))]
213
+ : [block];
214
+ };
215
+ rewrite.dispose = releaseAll;
216
+ return rewrite;
217
+ }
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
3
- import { join } from "node:path";
2
+ import { accessSync, constants, readFileSync, writeFileSync, unlinkSync, mkdirSync, statSync } from "node:fs";
3
+ import { delimiter, join } from "node:path";
4
4
  import { getConfigDir } from "../config";
5
5
  import { resolveAutoContext, type AutoContextMode } from "../claude/context-windows";
6
6
  import { PROXY_MARKER, defaultAuthDetectDeps, detectClaudeAuth, ownAdmissionTokens } from "../claude/auth-detect";
@@ -118,15 +118,84 @@ export function uninstallShellHook(): { removed: boolean; reason?: string } {
118
118
  try {
119
119
  const content = readFileSync(zshrcPath, "utf8");
120
120
  if (!content.includes(SHELL_HOOK_MARKER)) return { removed: false, reason: "not installed" };
121
- // Remove the hook block (marker line + source line + surrounding newlines)
122
- const cleaned = content.replace(/\n?# opencodex claude-env hook\n\[.*claude-env\.sh.*\n?/g, "\n");
121
+ // Match CR?LF, not LF alone. A .zshrc with CRLF line endings ordinary on a home
122
+ // directory an editor or another OS has touched — did not match, so the file was
123
+ // rewritten unchanged and the caller was told the hook was removed. Reporting success
124
+ // while the hook still sources on every new shell is the worse of the two failures.
125
+ const cleaned = content.replace(/\r?\n?# opencodex claude-env hook\r?\n\[.*claude-env\.sh.*(?:\r?\n)?/g, "\n");
126
+ // Verify instead of assuming: if the marker survives, the block is shaped in a way this
127
+ // pattern does not own, and the honest answer is failure rather than a silent no-op.
128
+ if (cleaned.includes(SHELL_HOOK_MARKER)) {
129
+ return { removed: false, reason: "hook block present but not in the expected shape; remove it manually" };
130
+ }
123
131
  writeFileSync(zshrcPath, cleaned, { encoding: "utf8", mode: 0o644 });
124
132
  return { removed: true };
125
- } catch {
133
+ } catch (error) {
134
+ if (error && typeof error === "object" && (error as { code?: unknown }).code === "ENOENT") {
135
+ return { removed: false, reason: "not installed" };
136
+ }
126
137
  return { removed: false, reason: "read/write failed" };
127
138
  }
128
139
  }
129
140
 
141
+ /** Whether a real `claude` executable is discoverable from this process's PATH. */
142
+ export function claudeCodeCliInstalled(pathValue = process.env.PATH): boolean {
143
+ if (!pathValue) return false;
144
+ for (const directory of pathValue.split(delimiter)) {
145
+ // An empty PATH segment means the current directory. Do not let the proxy treat a
146
+ // workspace-local file as a durable user installation.
147
+ if (!directory) continue;
148
+ const candidate = join(directory, "claude");
149
+ try {
150
+ if (!statSync(candidate).isFile()) continue;
151
+ accessSync(candidate, constants.X_OK);
152
+ return true;
153
+ } catch {
154
+ // Keep scanning PATH after missing, non-file, and non-executable entries.
155
+ }
156
+ }
157
+ return false;
158
+ }
159
+
160
+ /**
161
+ * Keep the shell hook aligned with the integration that can actually consume it.
162
+ * Claude Desktop uses its own profile and does not source `.zshrc`; this hook exists
163
+ * only for plain Claude Code CLI launches.
164
+ *
165
+ * Reconciliation is PATH-sensitive by construction: "Claude Code is installed" is answered
166
+ * from the PATH of whichever process calls this. A launchd/service context with a stripped
167
+ * PATH can therefore fail to see a `claude` the user's interactive shell finds, and this will
168
+ * remove the hook. That is the intended failure direction — removing an OpenCodex-owned block
169
+ * is reversible on the next foreground `ocx start`, whereas leaving a hook pointing at an
170
+ * uninstalled CLI is the stale state this reconciliation exists to clear. Only the block
171
+ * carrying our own marker is ever touched; user lines are preserved.
172
+ */
173
+ export function reconcileShellHook(systemEnvInjected: boolean): {
174
+ changed: boolean;
175
+ state: "installed" | "absent" | "failed";
176
+ reason?: string;
177
+ } {
178
+ if (process.platform !== "darwin") return { changed: false, state: "absent", reason: "not macOS" };
179
+ if (systemEnvInjected && claudeCodeCliInstalled()) {
180
+ const result = installShellHook();
181
+ if (result.installed) return { changed: true, state: "installed" };
182
+ if (result.reason === "already installed") {
183
+ return { changed: false, state: "installed", reason: result.reason };
184
+ }
185
+ return { changed: false, state: "failed", reason: result.reason ?? "install failed" };
186
+ }
187
+
188
+ const result = uninstallShellHook();
189
+ if (!result.removed && result.reason !== "not installed") {
190
+ return { changed: false, state: "failed", reason: result.reason ?? "remove failed" };
191
+ }
192
+ return {
193
+ changed: result.removed,
194
+ state: "absent",
195
+ reason: systemEnvInjected ? "Claude Code not installed" : "system environment inactive",
196
+ };
197
+ }
198
+
130
199
  const SYSTEM_ENV_NAMES = [
131
200
  "ANTHROPIC_BASE_URL",
132
201
  "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY",
@@ -179,6 +179,86 @@ function unitEnvValue(body: string, key: string): string | null {
179
179
  return null;
180
180
  }
181
181
 
182
+ /**
183
+ * Did `systemctl --user` fail because the session bus could not be reached at all?
184
+ *
185
+ * These are the shapes reported on #2114 and #1939. The distinction that matters is
186
+ * "the question never left the machine" versus "systemd answered and said no" — only
187
+ * the former licenses reading the disk instead.
188
+ *
189
+ * **Locale caveat, stated rather than hidden:** systemd localizes these strings, so a
190
+ * non-English host will not match and keeps the old `unknown`. That is the safe
191
+ * direction — it fences rather than admits — but it does mean the fix does not reach
192
+ * every affected user. Forcing `LC_ALL=C` on the probe would remove the caveat and is
193
+ * the obvious follow-up; it is not done here because it changes every systemctl call
194
+ * this module makes, not just this branch.
195
+ */
196
+ function busUnreachable(stderr: string): boolean {
197
+ const err = stderr.trim();
198
+ return err.includes("Failed to connect to bus")
199
+ || err.includes("Failed to connect to user scope bus")
200
+ || err.includes("Failed to get D-Bus connection")
201
+ || err.includes("DBUS_SESSION_BUS_ADDRESS")
202
+ || err.includes("System has not been booted with systemd");
203
+ }
204
+
205
+ /**
206
+ * Ownership from the unit file alone, for when the bus cannot answer (#2114).
207
+ *
208
+ * A unit file is proof of installation that does not require a running bus, and the homes
209
+ * it names are what ownership is actually decided on. What the disk cannot tell us is
210
+ * whether systemd has the unit LOADED, so this reports `registration: "absent"` — the
211
+ * honest reading of "no running manager has it" — rather than inventing a live state.
212
+ *
213
+ * A foreign home therefore still blocks, which is the whole reason this consults the disk
214
+ * instead of widening the exit code.
215
+ */
216
+ function systemdUserUnitSearchPaths(home: string): string[] {
217
+ // systemd's user search path is not one directory. Checking only the canonical one and
218
+ // calling the rest absent is a fail-open: with the bus down a foreign unit in any other
219
+ // search dir is invisible, and "no answer" would be read as "no owner".
220
+ const xdgConfig = process.env.XDG_CONFIG_HOME?.trim();
221
+ const xdgData = process.env.XDG_DATA_HOME?.trim();
222
+ const dirs = [
223
+ xdgConfig ? join(xdgConfig, "systemd", "user") : join(home, ".config", "systemd", "user"),
224
+ join(home, ".config", "systemd", "user"),
225
+ xdgData ? join(xdgData, "systemd", "user") : join(home, ".local", "share", "systemd", "user"),
226
+ join(home, ".local", "share", "systemd", "user"),
227
+ ];
228
+ return [...new Set(dirs)].map(dir => join(dir, `${TASK}.service`));
229
+ }
230
+
231
+ function inspectSystemdOffline(home: string): ServiceManagerInstallation {
232
+ const candidates = systemdUserUnitSearchPaths(home);
233
+ const found = candidates.filter(path => artifactPresence(path) === "present");
234
+ if (candidates.some(path => artifactPresence(path) === "unreadable")) {
235
+ return unknown("the session bus is unreachable and a systemd unit could not be read");
236
+ }
237
+ if (found.length === 0) return { kind: "absent" };
238
+ if (found.length > 1) {
239
+ return unknown("the session bus is unreachable and more than one systemd unit file claims this proxy");
240
+ }
241
+ const definitionPath = found[0]!;
242
+ let body: string;
243
+ try {
244
+ body = readFileSync(definitionPath, "utf-8");
245
+ } catch (error) {
246
+ return unknown(`the session bus is unreachable and the systemd unit could not be read: ${String(error)}`);
247
+ }
248
+ return {
249
+ kind: "present",
250
+ claims: [{
251
+ backend: "systemd",
252
+ definitionPath,
253
+ homes: {
254
+ codexHome: unitEnvValue(body, "CODEX_HOME"),
255
+ opencodexHome: unitEnvValue(body, "OPENCODEX_HOME"),
256
+ },
257
+ registration: "absent",
258
+ }],
259
+ };
260
+ }
261
+
182
262
  function inspectLaunchd(deps: Required<Pick<ProbeDeps, "run" | "uid" | "home">>): ServiceManagerInstallation {
183
263
  const definitionPath = join(deps.home, "Library", "LaunchAgents", `${LABEL}.plist`);
184
264
 
@@ -269,6 +349,15 @@ function inspectSystemd(deps: Required<Pick<ProbeDeps, "run" | "home">>): Servic
269
349
  if (shown.status !== 0) {
270
350
  // A missing unit still exits ZERO and says not-found; a non-zero status means
271
351
  // the question never reached the bus.
352
+ //
353
+ // That is evidence about the BUS, not evidence that a foreign service owns this home
354
+ // (#2114). Calling it `unknown` fences native-main for the whole process, so a laptop
355
+ // with no session bus answers every native request with a 503 until `ocx restart`.
356
+ //
357
+ // Widening on the exit code alone would fail open, because with the bus down systemctl
358
+ // cannot see a foreign unit either. So ask the disk, which needs no bus, and fall back
359
+ // to `unknown` for every other non-zero exit.
360
+ if (busUnreachable(shown.stderr)) return inspectSystemdOffline(deps.home);
272
361
  return unknown(`systemctl show exited ${String(shown.status)}: ${shown.stderr.trim()}`);
273
362
  }
274
363
 
@@ -729,6 +818,16 @@ function walkWinswChain(
729
818
  const registration = probeWinswRegistration(deps);
730
819
 
731
820
  if (xml === "absent" && exe === "absent" && registration === "absent") return { kind: "absent" };
821
+ // A query we could not ask is a question about a service that cannot exist: WinSW is an
822
+ // optional backend, and with neither its XML nor its exe on disk there is nothing for a
823
+ // registration to belong to. Fencing here on an `sc.exe` timeout is one of the two
824
+ // triggers behind #2108, where a scheduler-only install answers 503 until `ocx restart`.
825
+ //
826
+ // The disk outranks the unaskable query only when BOTH assets are gone. Either one
827
+ // present means a real install may be there and the old `unknown` still holds.
828
+ if (registration === "unknown" && xml === "absent" && exe === "absent") {
829
+ return { kind: "absent" };
830
+ }
732
831
  if (registration === "unknown") {
733
832
  return unknown("the native WinSW service registration could not be verified");
734
833
  }
package/src/service.ts CHANGED
@@ -19,6 +19,7 @@ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from
19
19
  import type { BunRuntimeSource } from "./lib/bun-runtime";
20
20
  import { isProcessAlive, stopProxy } from "./lib/process-control";
21
21
  import { serviceApiTokenFilePath } from "./lib/service-secrets";
22
+ import { PROXY_ENV_KEYS } from "./lib/proxy-env";
22
23
  import { randomUUID } from "node:crypto";
23
24
  import {
24
25
  ELEVATION_REQUEST_TIMEOUT_MS,
@@ -389,7 +390,7 @@ function writeServiceApiTokenFile(): string | null {
389
390
  return path;
390
391
  }
391
392
 
392
- export function buildPlist(): string {
393
+ export function buildPlist(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string {
393
394
  const { bun, bunRuntimeSource, cli } = cliEntry();
394
395
  const log = logPath();
395
396
  const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
@@ -404,6 +405,8 @@ export function buildPlist(): string {
404
405
  codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : null,
405
406
  codexSqliteHome ? ` <key>CODEX_SQLITE_HOME</key><string>${plistString(codexSqliteHome)}</string>` : null,
406
407
  opencodexHome ? ` <key>OPENCODEX_HOME</key><string>${plistString(opencodexHome)}</string>` : null,
408
+ ...proxyEnv.map(({ name, value }) =>
409
+ ` <key>${name}</key><string>${plistString(value)}</string>`),
407
410
  ].filter((line): line is string => Boolean(line)).join("\n");
408
411
  const command = buildServiceShellCommand(bun, cli);
409
412
  return `<?xml version="1.0" encoding="UTF-8"?>
@@ -640,6 +643,31 @@ function systemdEnvironmentAssignment(name: string, value: string | undefined):
640
643
  return `Environment=${systemdQuote(`${name}=${value}`)}`;
641
644
  }
642
645
 
646
+ /**
647
+ * Outbound proxy settings the installing shell had, resolved for baking into a service
648
+ * definition.
649
+ *
650
+ * A service manager does not inherit the environment of the shell that installed it, and
651
+ * `ExecStart=/bin/sh -lc` is dash on Ubuntu/WSL — login dash reads `.profile`, not
652
+ * `.bashrc`, which is where proxy exports usually live. So a user who needs a proxy to
653
+ * reach the upstream got a service that dialed direct: the socket was reset, the retry
654
+ * budget drained, and the request surfaced as `502 Provider unreachable` (#2107). The
655
+ * same install driven through `ocx codex-shim` worked, because that path spawns with
656
+ * `{ ...process.env }`.
657
+ *
658
+ * Lower-case variants are honored because curl-style tooling sets them and the runtime's
659
+ * own `applyProxyEnv` already treats both cases as equivalent. Only the canonical
660
+ * upper-case name is baked, so a definition never carries two spellings of one setting.
661
+ */
662
+ export function resolvedProxyEnv(env: NodeJS.ProcessEnv = process.env): { name: string; value: string }[] {
663
+ const resolved: { name: string; value: string }[] = [];
664
+ for (const key of PROXY_ENV_KEYS) {
665
+ const value = env[key]?.trim() || env[key.toLowerCase()]?.trim();
666
+ if (value) resolved.push({ name: key, value });
667
+ }
668
+ return resolved;
669
+ }
670
+
643
671
  function systemdOutputTarget(value: string): string {
644
672
  // StandardOutput/StandardError use output specifiers such as append:/path.
645
673
  // Quoting the full specifier makes systemd reject it as an invalid output target.
@@ -1513,7 +1541,11 @@ function taskXmlRunLevelAcceptable(principal: string): boolean {
1513
1541
  return value === "leastprivilege" || value === "highestavailable";
1514
1542
  }
1515
1543
 
1516
- export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServiceListenPort()): string {
1544
+ export function buildWindowsServiceScript(
1545
+ entry = cliEntry(),
1546
+ port = resolveServiceListenPort(),
1547
+ proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(),
1548
+ ): string {
1517
1549
  // Provenance rides along with the entry: a second durableBunRuntime() call here could
1518
1550
  // resolve differently from the binary the caller actually baked.
1519
1551
  const { bun, bunRuntimeSource, cli } = entry;
@@ -1531,6 +1563,7 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ
1531
1563
  windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"),
1532
1564
  windowsBatchSet("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute("windows"), "path"),
1533
1565
  windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"),
1566
+ ...proxyEnv.map(({ name, value }) => windowsBatchSet(name, value)),
1534
1567
  windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath(), "path"),
1535
1568
  windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath(), "path"),
1536
1569
  windowsBatchSet("OCX_BUN", bun, "path"),
@@ -1852,7 +1885,7 @@ function installLaunchd(): void {
1852
1885
  // Capture this BEFORE writing: the write below makes the plist exist unconditionally,
1853
1886
  // so a post-write existsSync would call every fresh install an "installed" service.
1854
1887
  const wasInstalled = existsSync(p);
1855
- writeFileSync(p, buildPlist(), "utf8");
1888
+ writeServiceDefinitionFile(p, buildPlist(), "utf8");
1856
1889
  // Best-effort: an absent job is fine here, and a failed unload is caught by the
1857
1890
  // load verification below with a better message than a raw unload error.
1858
1891
  runLaunchctl(["unload", p]);
@@ -1915,6 +1948,52 @@ function uninstallLaunchd(): void {
1915
1948
  if (existsSync(p)) unlinkSync(p);
1916
1949
  }
1917
1950
 
1951
+ /**
1952
+ * Write a service definition with owner-only permissions.
1953
+ *
1954
+ * These files carry the outbound proxy environment (#2107), and a proxy URL routinely
1955
+ * carries `user:password`. `writeFileSync` without a mode lands at 0644 under the default
1956
+ * umask, so the credential would be world-readable on a shared host. Every other
1957
+ * secret-bearing write in this file already uses 0600 — the service API token and the
1958
+ * install state — and a service definition holding a proxy credential belongs in the same
1959
+ * class.
1960
+ *
1961
+ * The explicit `chmodSync` is not redundant: `mode` only applies when the file is
1962
+ * created, so an install over a definition left at 0644 by an earlier version would keep
1963
+ * the loose mode.
1964
+ *
1965
+ * On Windows the POSIX bits are advisory, so the ACL is the real boundary — and whether it
1966
+ * may soft-fail depends on what the definition actually contains. A definition carrying a
1967
+ * proxy credential is a secret publication and fails closed like the API token and the
1968
+ * install state do; one carrying only paths and a port is not worth refusing an install
1969
+ * over, since before #2107 these files had no hardening at all and a failure here would
1970
+ * regress a user who has no credential to protect.
1971
+ */
1972
+ export function writeServiceDefinitionFile(path: string, content: string, encoding: "utf8" | "utf16le"): void {
1973
+ writeFileSync(path, content, { encoding, mode: 0o600 });
1974
+ try { chmodSync(path, 0o600); } catch { /* superseded by the Windows ACL below */ }
1975
+ if (process.platform === "win32") {
1976
+ hardenSecretPath(path, { required: definitionCarriesCredential(content) });
1977
+ }
1978
+ }
1979
+
1980
+ /**
1981
+ * Does this service definition embed a credential-bearing proxy URL?
1982
+ *
1983
+ * Only the userinfo form leaks something: `http://user:pass@host` in any of the four proxy
1984
+ * variables. A bare `http://127.0.0.1:7890` is not a secret, and treating it as one would
1985
+ * make an icacls stall fail an install that had nothing to protect.
1986
+ *
1987
+ * The scan is over any URL in the rendered definition rather than over a `KEY=value` shape,
1988
+ * because the three formats render differently — systemd writes `Environment="K=V"`, the
1989
+ * plist writes `<key>K</key><string>V</string>`, and the Windows wrapper writes
1990
+ * `set "K=V"`. Keying on the assignment syntax silently missed the plist.
1991
+ */
1992
+ export function definitionCarriesCredential(content: string): boolean {
1993
+ // A userinfo authority: scheme, then anything that is not a delimiter, then '@'.
1994
+ return /[a-z][a-z0-9+.-]*:\/\/[^\s"'<>/@]+@/i.test(content);
1995
+ }
1996
+
1918
1997
  // ── Windows (Task Scheduler) ──
1919
1998
  /**
1920
1999
  * In-place service-asset write that tolerates the transient EBUSY/EPERM/EACCES Windows
@@ -1923,7 +2002,7 @@ function uninstallLaunchd(): void {
1923
2002
  function writeServiceAssetWithRetry(path: string, content: string, encoding: "utf8" | "utf16le"): void {
1924
2003
  for (let attempt = 0; ; attempt++) {
1925
2004
  try {
1926
- writeFileSync(path, content, encoding);
2005
+ writeServiceDefinitionFile(path, content, encoding);
1927
2006
  return;
1928
2007
  } catch (err) {
1929
2008
  const code = (err as NodeJS.ErrnoException).code;
@@ -2415,7 +2494,7 @@ function unitPath(): string {
2415
2494
  return join(unitDir(), `${TASK}.service`);
2416
2495
  }
2417
2496
 
2418
- export function buildUnit(): string {
2497
+ export function buildUnit(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string {
2419
2498
  const { bun, bunRuntimeSource, cli } = cliEntry();
2420
2499
  const log = logPath();
2421
2500
  const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
@@ -2430,6 +2509,7 @@ export function buildUnit(): string {
2430
2509
  codexHome,
2431
2510
  codexSqliteHome,
2432
2511
  opencodexHome,
2512
+ ...proxyEnv.map(({ name, value }) => systemdEnvironmentAssignment(name, value)),
2433
2513
  ].filter((line): line is string => Boolean(line)).join("\n");
2434
2514
  return `[Unit]
2435
2515
  Description=OpenCodex Proxy Server
@@ -2490,7 +2570,7 @@ function installSystemd(): void {
2490
2570
  recordOwnedConfigPath(getConfigDir(), serviceStatePath());
2491
2571
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
2492
2572
  writeServiceApiTokenFile();
2493
- writeFileSync(unitPath(), buildUnit(), "utf8");
2573
+ writeServiceDefinitionFile(unitPath(), buildUnit(), "utf8");
2494
2574
  sh("systemctl --user daemon-reload");
2495
2575
  sh(`systemctl --user enable ${TASK}`);
2496
2576
  sh(`systemctl --user restart ${TASK}`);
@@ -9,6 +9,7 @@ import type { BunRuntimeSource } from "../lib/bun-runtime";
9
9
  import { forgetEphemeralSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
10
10
  import { recordOwnedConfigPath } from "../lib/config-ownership";
11
11
  import { renameAtomicFile } from "../lib/windows-atomic-replace";
12
+ import { decodeWindowsTextBytes } from "../lib/windows-text";
12
13
 
13
14
  const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
14
15
  const RUN_PARENT_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion";
@@ -117,12 +118,31 @@ function registryExe(): string {
117
118
  return existsSync(candidate) ? candidate : "reg.exe";
118
119
  }
119
120
 
121
+ /**
122
+ * Decode `reg.exe` output the way the rest of the product decodes Windows console
123
+ * output.
124
+ *
125
+ * `reg.exe` writes the console ANSI code page when its output is redirected, not
126
+ * UTF-8. Reading it as utf8 corrupts every non-ASCII byte, so a profile path such
127
+ * as `C:\Users\M<o-umlaut>tz` came back with replacement characters, the
128
+ * comparison against the value we wrote could never match, `registrationOwned`
129
+ * went false, and the CLI reported the tray registration as
130
+ * "foreign, stale, or points to missing package files" over an entry that was
131
+ * correct and owned (#1933).
132
+ *
133
+ * `decodeWindowsTextBytes` already solves this for `schtasks` (#1573). The tray
134
+ * reader was the site that class fix missed.
135
+ */
136
+ function decodeRegistryOutput(stdout: Buffer | string): string {
137
+ const bytes = typeof stdout === "string" ? Buffer.from(stdout, "binary") : stdout;
138
+ return decodeWindowsTextBytes(bytes).trim();
139
+ }
140
+
120
141
  function runRegistry(args: string[]): string {
121
- return execFileSync(registryExe(), args, {
122
- encoding: "utf8",
142
+ return decodeRegistryOutput(execFileSync(registryExe(), args, {
123
143
  stdio: ["ignore", "pipe", "pipe"],
124
144
  windowsHide: true,
125
- }).trim();
145
+ }));
126
146
  }
127
147
 
128
148
  function safePath(value: string): string {
@@ -335,13 +355,13 @@ function readOwnedRunValue(runValue = windowsTrayRunValue(getConfigDir())): stri
335
355
  function runRegistryAsync(args: string[]): Promise<string> {
336
356
  return new Promise((resolvePromise, rejectPromise) => {
337
357
  execFile(registryExe(), args, {
338
- encoding: "utf8",
358
+ encoding: "buffer",
339
359
  timeout: 2_000,
340
360
  windowsHide: true,
341
361
  maxBuffer: 64 * 1024,
342
362
  }, (error, stdout) => {
343
363
  if (error) rejectPromise(error);
344
- else resolvePromise(stdout.trim());
364
+ else resolvePromise(decodeRegistryOutput(stdout));
345
365
  });
346
366
  });
347
367
  }