@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.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 (173) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +47 -22
  3. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  4. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/discovery.ts +4 -1
  10. package/src/adapters/cursor/effort-map.ts +3 -0
  11. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  12. package/src/adapters/cursor/protobuf-events.ts +24 -2
  13. package/src/adapters/cursor/protobuf-request.ts +1 -2
  14. package/src/adapters/cursor/tool-definitions.ts +68 -29
  15. package/src/adapters/google-wire-compiler.ts +4 -0
  16. package/src/adapters/google.ts +128 -2
  17. package/src/adapters/identity.ts +12 -2
  18. package/src/adapters/kiro.ts +64 -7
  19. package/src/adapters/mimo-free.ts +2 -0
  20. package/src/adapters/openai-responses.ts +246 -59
  21. package/src/claude/agents-inject.ts +5 -0
  22. package/src/claude/alias.ts +94 -14
  23. package/src/claude/inbound.ts +26 -9
  24. package/src/claude/outbound.ts +6 -3
  25. package/src/cli/account-auth.ts +1 -1
  26. package/src/cli/agent-driven.ts +37 -0
  27. package/src/cli/catalog-prewarm.ts +24 -0
  28. package/src/cli/claude.ts +35 -10
  29. package/src/cli/doctor.ts +71 -19
  30. package/src/cli/help.ts +42 -6
  31. package/src/cli/index.ts +93 -19
  32. package/src/cli/interactive-confirm.ts +133 -0
  33. package/src/cli/opencode.ts +701 -0
  34. package/src/cli/provider-runtime.ts +3 -0
  35. package/src/cli/provider.ts +31 -10
  36. package/src/cli/star-prompt.ts +79 -18
  37. package/src/cli/status.ts +47 -13
  38. package/src/cli/v2.ts +10 -1
  39. package/src/codex/account-id.ts +34 -0
  40. package/src/codex/account-lifecycle.ts +4 -1
  41. package/src/codex/account-namespace-match.ts +63 -0
  42. package/src/codex/account-namespaces.ts +149 -0
  43. package/src/codex/account-pause.ts +20 -0
  44. package/src/codex/account-store.ts +2 -0
  45. package/src/codex/account-usability.ts +6 -1
  46. package/src/codex/app-server-processes.ts +511 -0
  47. package/src/codex/auth-api.ts +293 -34
  48. package/src/codex/auth-collision.ts +2 -1
  49. package/src/codex/auth-context.ts +60 -17
  50. package/src/codex/catalog/bundled.ts +9 -2
  51. package/src/codex/catalog/parsing.ts +42 -2
  52. package/src/codex/catalog/provider-fetch.ts +264 -70
  53. package/src/codex/catalog/sync.ts +45 -8
  54. package/src/codex/catalog.ts +2 -2
  55. package/src/codex/features.ts +524 -5
  56. package/src/codex/history-provider.ts +145 -1
  57. package/src/codex/inject.ts +114 -14
  58. package/src/codex/main-account.ts +2 -8
  59. package/src/codex/pool-rotation.ts +186 -0
  60. package/src/codex/quota.ts +92 -2
  61. package/src/codex/routing.ts +695 -106
  62. package/src/codex/runtime.ts +10 -1
  63. package/src/codex/shim.ts +4 -1
  64. package/src/codex/subagent-defaults.ts +550 -0
  65. package/src/codex/subagent-model-fallback.ts +2 -0
  66. package/src/codex/sync.ts +3 -0
  67. package/src/config.ts +574 -25
  68. package/src/generated/jawcode-model-metadata.ts +12 -12
  69. package/src/github/star-state.ts +191 -0
  70. package/src/images/artifacts.ts +516 -0
  71. package/src/images/fulfill-video.ts +163 -0
  72. package/src/images/fulfill.ts +111 -0
  73. package/src/images/index.ts +4 -0
  74. package/src/images/loop.ts +789 -0
  75. package/src/images/plan.ts +133 -0
  76. package/src/images/synthetic-tool.ts +133 -0
  77. package/src/images/types.ts +41 -0
  78. package/src/images/xai-client.ts +141 -0
  79. package/src/images/xai-video-client.ts +163 -0
  80. package/src/lib/admin-secrets.ts +25 -0
  81. package/src/lib/bun-binary-validator.d.mts +3 -0
  82. package/src/lib/bun-binary-validator.mjs +18 -0
  83. package/src/lib/bun-runtime.ts +6 -20
  84. package/src/lib/config-ownership.ts +327 -0
  85. package/src/lib/crash-guard.ts +2 -0
  86. package/src/lib/destination-policy.ts +132 -7
  87. package/src/lib/pinned-http.ts +151 -0
  88. package/src/lib/process-control.ts +2 -2
  89. package/src/lib/provider-outbound.ts +167 -0
  90. package/src/lib/provider-url.ts +14 -0
  91. package/src/lib/proxy-env.ts +18 -0
  92. package/src/lib/shadow-call.ts +30 -0
  93. package/src/lib/test-home-guard.ts +90 -0
  94. package/src/lib/win-exec.ts +12 -2
  95. package/src/lib/windows-elevation.ts +81 -3
  96. package/src/lib/windows-secret-acl.ts +189 -12
  97. package/src/lib/winsw.ts +2 -0
  98. package/src/oauth/anthropic-routing.ts +570 -0
  99. package/src/oauth/health.ts +6 -0
  100. package/src/oauth/index.ts +310 -75
  101. package/src/oauth/key-providers.ts +38 -8
  102. package/src/oauth/kimi.ts +2 -0
  103. package/src/oauth/kiro-credentials.ts +373 -12
  104. package/src/oauth/kiro.ts +424 -43
  105. package/src/oauth/login-cli.ts +33 -6
  106. package/src/oauth/store.ts +56 -4
  107. package/src/oauth/types.ts +11 -0
  108. package/src/providers/alibaba-region-migration.ts +16 -3
  109. package/src/providers/antigravity-models.ts +3 -0
  110. package/src/providers/api-keys.ts +13 -6
  111. package/src/providers/derive.ts +8 -2
  112. package/src/providers/key-failover.ts +24 -4
  113. package/src/providers/model-discovery.ts +356 -0
  114. package/src/providers/quota.ts +233 -29
  115. package/src/providers/registry.ts +125 -3
  116. package/src/responses/parser.ts +11 -0
  117. package/src/responses/state.ts +22 -8
  118. package/src/responses/tool-groups.ts +19 -0
  119. package/src/router.ts +19 -7
  120. package/src/server/auth-cors.ts +114 -24
  121. package/src/server/claude-messages.ts +8 -1
  122. package/src/server/gui-static.ts +30 -6
  123. package/src/server/images.ts +303 -9
  124. package/src/server/index.ts +77 -9
  125. package/src/server/lifecycle.ts +25 -1
  126. package/src/server/live.ts +75 -25
  127. package/src/server/management/agent-settings-routes.ts +106 -8
  128. package/src/server/management/combo-routes.ts +7 -0
  129. package/src/server/management/config-routes.ts +22 -7
  130. package/src/server/management/context.ts +11 -1
  131. package/src/server/management/logs-usage-routes.ts +167 -3
  132. package/src/server/management/model-routes.ts +46 -13
  133. package/src/server/management/oauth-account-routes.ts +163 -17
  134. package/src/server/management/provider-routes.ts +73 -10
  135. package/src/server/management/shared.ts +2 -2
  136. package/src/server/management/sidebar-routes.ts +39 -0
  137. package/src/server/management/system-restart.ts +172 -0
  138. package/src/server/management/system-routes.ts +33 -10
  139. package/src/server/management-api.ts +5 -3
  140. package/src/server/management-auth.ts +216 -0
  141. package/src/server/proxy-liveness.ts +14 -3
  142. package/src/server/responses/compact.ts +21 -13
  143. package/src/server/responses/core.ts +614 -172
  144. package/src/server/responses/upstream-error.ts +48 -0
  145. package/src/server/responses-image-gen-repair.ts +118 -0
  146. package/src/server/responses-item-id-repair.ts +10 -85
  147. package/src/server/sse-payload-rewrite.ts +116 -0
  148. package/src/server/startup-action-control.ts +30 -14
  149. package/src/server/system-env.ts +28 -10
  150. package/src/service.ts +284 -19
  151. package/src/storage/cleanup-job.ts +57 -0
  152. package/src/storage/cleanup.ts +1504 -28
  153. package/src/storage/policy-job.ts +387 -0
  154. package/src/storage/policy-scheduler.ts +40 -0
  155. package/src/storage/policy-worker.ts +53 -0
  156. package/src/storage/policy.ts +522 -0
  157. package/src/storage/restore-job.ts +253 -0
  158. package/src/storage/restore-worker.ts +52 -0
  159. package/src/storage/storage-mutation-coordinator.ts +109 -0
  160. package/src/storage/worker-lifecycle.ts +81 -0
  161. package/src/tray/windows.ts +34 -4
  162. package/src/types.ts +107 -1
  163. package/src/update/badge.ts +72 -0
  164. package/src/update/index.ts +36 -18
  165. package/src/update/job.ts +111 -16
  166. package/src/update/npm-invocation.d.mts +23 -0
  167. package/src/update/npm-invocation.mjs +94 -0
  168. package/src/usage/debug.ts +2 -0
  169. package/src/usage/expected-prices.ts +6 -5
  170. package/src/usage/log.ts +12 -0
  171. package/src/web-search/loop.ts +57 -16
  172. package/gui/dist/assets/index-CjKFJHSC.js +0 -65
  173. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Upstream connection failures share one message shape across the three catch sites in
3
+ * core.ts. A TLS certificate/hostname mismatch deserves its own wording: the generic
4
+ * "Provider unreachable" reads as if opencodex built a wrong endpoint, which sent issue
5
+ * #553 looking for an adapter URL bug that does not exist. Name the likely cause and the
6
+ * command that settles it.
7
+ */
8
+ export function describeUpstreamConnectFailure(err: unknown, connectMs: number): string {
9
+ if (err instanceof Error && err.name === "TimeoutError") {
10
+ return `Provider connect timeout after ${connectMs}ms`;
11
+ }
12
+ const detail = err instanceof Error ? err.message : String(err);
13
+ const code = err instanceof Error ? (err as { code?: unknown }).code : undefined;
14
+ // `code` is the reliable signal. The message fallback is anchored to the head because Bun
15
+ // renders this rejection as `ERR_TLS_CERT_ALTNAME_INVALID fetching "<url>"`; matching the
16
+ // bare substring anywhere would also fire on text that merely quotes the code back at us.
17
+ // Only transport failures reach these call sites, so that is defensive rather than load-bearing.
18
+ if (code === "ERR_TLS_CERT_ALTNAME_INVALID" || detail.startsWith("ERR_TLS_CERT_ALTNAME_INVALID")) {
19
+ const host = extractHostname(detail);
20
+ const target = host ?? "the provider host";
21
+ const probe = host ?? "<host>";
22
+ return `Provider TLS certificate does not match ${target}: ${redactUrlUserinfo(detail)}. `
23
+ + "opencodex did not rewrite this hostname — a certificate that does not cover it normally "
24
+ + "means TLS interception (corporate proxy, VPN, or local MITM tooling) or a poisoned DNS "
25
+ + `answer. Check with: openssl s_client -connect ${probe}:443 -servername ${probe} `
26
+ + "</dev/null | openssl x509 -noout -subject -ext subjectAltName";
27
+ }
28
+ return `Provider unreachable: ${redactUrlUserinfo(detail)}`;
29
+ }
30
+
31
+ /**
32
+ * A provider base URL may carry credentials as userinfo (`https://user:token@host/`), and the
33
+ * runtime error echoes the URL it was fetching. Strip it before the message reaches a client
34
+ * or a log line.
35
+ */
36
+ function redactUrlUserinfo(detail: string): string {
37
+ return detail.replace(/(https?:\/\/)[^/\s"'@]*@/g, "$1<redacted>@");
38
+ }
39
+
40
+ function extractHostname(detail: string): string | null {
41
+ const match = detail.match(/https?:\/\/([^/\s"']+)/);
42
+ if (!match?.[1]) return null;
43
+ try {
44
+ return new URL(`https://${match[1]}`).hostname || null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
@@ -0,0 +1,118 @@
1
+ import { collectResponsesToolGroups } from "../responses/tool-groups";
2
+ import { relaySseWithPayloadRewrite, type SsePayloadRewrite } from "./sse-payload-rewrite";
3
+
4
+ interface NamespacedTool {
5
+ namespace: string;
6
+ name: string;
7
+ }
8
+
9
+ const IMAGE_GEN_NAMESPACE = "image_gen";
10
+ const IMAGE_GEN_DOTTED_PREFIX = `${IMAGE_GEN_NAMESPACE}.`;
11
+
12
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
13
+ return !!value && typeof value === "object" && !Array.isArray(value);
14
+ }
15
+
16
+ /**
17
+ * Restrict the generic bridge map to Codex's standalone image namespace and accept the dotted
18
+ * alias emitted by earlier compatibility attempts. Legacy flat dotted declarations are recovered
19
+ * from the raw request because the generic parser does not assign them a namespace. Exact declared
20
+ * names avoid splitting arbitrary double-underscore function names that belong to unrelated tools.
21
+ */
22
+ export function imageGenToolCallAliases(
23
+ toolNsMap: ReadonlyMap<string, NamespacedTool>,
24
+ requestBody?: unknown,
25
+ ): Map<string, NamespacedTool> {
26
+ const aliases = new Map<string, NamespacedTool>();
27
+ for (const [wireName, tool] of toolNsMap) {
28
+ if (tool.namespace !== IMAGE_GEN_NAMESPACE) continue;
29
+ aliases.set(wireName, tool);
30
+ aliases.set(`${tool.namespace}.${tool.name}`, tool);
31
+ }
32
+ for (const group of collectResponsesToolGroups(requestBody)) {
33
+ for (const tool of group) {
34
+ if (
35
+ !isPlainObject(tool)
36
+ || tool.type !== "function"
37
+ || typeof tool.name !== "string"
38
+ || !tool.name.startsWith(IMAGE_GEN_DOTTED_PREFIX)
39
+ || tool.name.length === IMAGE_GEN_DOTTED_PREFIX.length
40
+ ) continue;
41
+ const localName = tool.name.slice(IMAGE_GEN_DOTTED_PREFIX.length);
42
+ const target = { namespace: IMAGE_GEN_NAMESPACE, name: localName };
43
+ aliases.set(`${IMAGE_GEN_NAMESPACE}__${localName}`, target);
44
+ aliases.set(tool.name, target);
45
+ }
46
+ }
47
+ return aliases;
48
+ }
49
+
50
+ /** Recursively restore only exact image-gen function-call aliases in a parsed Responses payload. */
51
+ function restoreImageGenCalls(
52
+ value: unknown,
53
+ aliases: ReadonlyMap<string, NamespacedTool>,
54
+ ): { value: unknown; changed: boolean } {
55
+ if (Array.isArray(value)) {
56
+ let changed = false;
57
+ const restored = value.map(entry => {
58
+ const result = restoreImageGenCalls(entry, aliases);
59
+ changed ||= result.changed;
60
+ return result.value;
61
+ });
62
+ return changed ? { value: restored, changed: true } : { value, changed: false };
63
+ }
64
+ if (!isPlainObject(value)) return { value, changed: false };
65
+
66
+ let changed = false;
67
+ const restored: Record<string, unknown> = {};
68
+ for (const [key, entry] of Object.entries(value)) {
69
+ const result = restoreImageGenCalls(entry, aliases);
70
+ restored[key] = result.value;
71
+ changed ||= result.changed;
72
+ }
73
+
74
+ const name = typeof value.name === "string" ? value.name : undefined;
75
+ const target = value.type === "function_call" && name ? aliases.get(name) : undefined;
76
+ if (target) {
77
+ restored.name = target.name;
78
+ restored.namespace = target.namespace;
79
+ changed = true;
80
+ }
81
+ return changed ? { value: restored, changed: true } : { value, changed: false };
82
+ }
83
+
84
+ /** Restore image-gen function calls in a non-streaming Responses JSON document. */
85
+ export function restoreImageGenCallsInJson(
86
+ text: string,
87
+ aliases: ReadonlyMap<string, NamespacedTool>,
88
+ ): string {
89
+ if (aliases.size === 0) return text;
90
+ let payload: unknown;
91
+ try {
92
+ payload = JSON.parse(text);
93
+ } catch {
94
+ return text;
95
+ }
96
+ const restored = restoreImageGenCalls(payload, aliases);
97
+ return restored.changed ? JSON.stringify(restored.value) : text;
98
+ }
99
+
100
+ /** Payload rewrite for composition with other client-facing SSE transforms. */
101
+ export function createImageGenCallRestoreRewrite(
102
+ aliases: ReadonlyMap<string, NamespacedTool>,
103
+ ): SsePayloadRewrite | undefined {
104
+ if (aliases.size === 0) return undefined;
105
+ return (payload) => restoreImageGenCallsInJson(payload, aliases);
106
+ }
107
+
108
+ /**
109
+ * Restore upstream-safe image-gen aliases only on the client-facing SSE branch. The inspection and
110
+ * continuation-cache branch must retain raw upstream names so a replay can be sent back unchanged.
111
+ */
112
+ export function relaySseWithImageGenCallRestore(
113
+ body: ReadableStream<Uint8Array>,
114
+ aliases: ReadonlyMap<string, NamespacedTool>,
115
+ ): ReadableStream<Uint8Array> {
116
+ const rewrite = createImageGenCallRestoreRewrite(aliases);
117
+ return rewrite ? relaySseWithPayloadRewrite(body, rewrite) : body;
118
+ }
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { ResponsesItemIdRepairConfig } from "../types";
3
+ import { relaySseWithPayloadRewrite, type SsePayloadRewrite } from "./sse-payload-rewrite";
3
4
 
4
5
  type RepairableItemType = "message" | "reasoning";
5
6
 
@@ -35,44 +36,6 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
35
36
  return !!value && typeof value === "object" && !Array.isArray(value);
36
37
  }
37
38
 
38
- function nextSseBlock(buffer: string): { block: string; delimiter: string; rest: string } | null {
39
- const match = buffer.match(/\r?\n\r?\n/);
40
- if (!match || match.index === undefined) return null;
41
- return {
42
- block: buffer.slice(0, match.index),
43
- delimiter: match[0],
44
- rest: buffer.slice(match.index + match[0].length),
45
- };
46
- }
47
-
48
- function sseDataPayload(block: string): string | null {
49
- const data: string[] = [];
50
- for (const line of block.split(/\r?\n/)) {
51
- if (!line.startsWith("data:")) continue;
52
- const value = line.slice(5);
53
- data.push(value.startsWith(" ") ? value.slice(1) : value);
54
- }
55
- return data.length > 0 ? data.join("\n") : null;
56
- }
57
-
58
- function replaceSseDataPayload(block: string, payload: string): string {
59
- const newline = block.includes("\r\n") ? "\r\n" : "\n";
60
- const lines = block.split(/\r?\n/);
61
- const rewritten: string[] = [];
62
- let replaced = false;
63
- for (const line of lines) {
64
- if (!line.startsWith("data:")) {
65
- rewritten.push(line);
66
- continue;
67
- }
68
- if (!replaced) {
69
- rewritten.push(`data: ${payload}`);
70
- replaced = true;
71
- }
72
- }
73
- return replaced ? rewritten.join(newline) : block;
74
- }
75
-
76
39
  function asOutputIndex(value: unknown): number | null {
77
40
  return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
78
41
  }
@@ -221,57 +184,19 @@ function repairEventPayload(
221
184
  * type에 재사용해도 function_call id/call_id는 보존된다. opt-in 게이트웨이는 sequential streams에서도
222
185
  * 고유한 canonical id를 얻지만, 보정이 필요한 경우에만 JS stream 재작성 비용을 지불한다.
223
186
  */
187
+ /** Stateful payload rewrite for composition with other client-facing SSE transforms. */
188
+ export function createResponsesItemIdPayloadRewrite(
189
+ config: ResponsesItemIdRepairConfig,
190
+ ): SsePayloadRewrite {
191
+ const state = createRepairState(config);
192
+ return (payload) => repairEventPayload(payload, state);
193
+ }
194
+
224
195
  export function relaySseWithResponsesItemIdRepair(
225
196
  body: ReadableStream<Uint8Array>,
226
197
  config: ResponsesItemIdRepairConfig,
227
198
  ): ReadableStream<Uint8Array> {
228
- const reader = body.getReader();
229
- const decoder = new TextDecoder();
230
- const encoder = new TextEncoder();
231
- const state = createRepairState(config);
232
- let buffer = "";
233
-
234
- const emitProcessedBlocks = (
235
- controller: ReadableStreamDefaultController<Uint8Array>,
236
- flushFinal = false,
237
- ): void => {
238
- let next: { block: string; delimiter: string; rest: string } | null;
239
- while ((next = nextSseBlock(buffer))) {
240
- buffer = next.rest;
241
- const payload = sseDataPayload(next.block);
242
- const repairedPayload = payload ? repairEventPayload(payload, state) : undefined;
243
- const block = payload && repairedPayload !== undefined && repairedPayload !== payload
244
- ? replaceSseDataPayload(next.block, repairedPayload)
245
- : next.block;
246
- controller.enqueue(encoder.encode(block + next.delimiter));
247
- }
248
- if (flushFinal && buffer.length > 0) {
249
- const payload = sseDataPayload(buffer);
250
- const repairedPayload = payload ? repairEventPayload(payload, state) : undefined;
251
- const block = payload && repairedPayload !== undefined && repairedPayload !== payload
252
- ? replaceSseDataPayload(buffer, repairedPayload)
253
- : buffer;
254
- controller.enqueue(encoder.encode(block));
255
- buffer = "";
256
- }
257
- };
258
-
259
- return new ReadableStream<Uint8Array>({
260
- async pull(controller) {
261
- const { done, value } = await reader.read();
262
- if (done) {
263
- buffer += decoder.decode();
264
- emitProcessedBlocks(controller, true);
265
- controller.close();
266
- return;
267
- }
268
- buffer += decoder.decode(value, { stream: true });
269
- emitProcessedBlocks(controller);
270
- },
271
- cancel(reason) {
272
- reader.cancel(reason).catch(() => {});
273
- },
274
- });
199
+ return relaySseWithPayloadRewrite(body, createResponsesItemIdPayloadRewrite(config));
275
200
  }
276
201
 
277
202
  export function hasResponsesItemIdRepair(config: ResponsesItemIdRepairConfig | undefined): boolean {
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Shared client-facing SSE payload rewrite shell.
3
+ *
4
+ * Multiple opt-in transforms (image-gen namespace restore, item-id repair, …) compose into one
5
+ * parse/stringify pass so a tee'd stream is not re-framed twice per event.
6
+ */
7
+
8
+ export type SsePayloadRewrite = (payload: string) => string;
9
+
10
+ /** Split one complete SSE event block while retaining its original blank-line delimiter. */
11
+ export function nextSseBlock(buffer: string): { block: string; delimiter: string; rest: string } | null {
12
+ const match = buffer.match(/\r?\n\r?\n/);
13
+ if (!match || match.index === undefined) return null;
14
+ return {
15
+ block: buffer.slice(0, match.index),
16
+ delimiter: match[0],
17
+ rest: buffer.slice(match.index + match[0].length),
18
+ };
19
+ }
20
+
21
+ /** Join all data lines from one SSE event according to the event-stream field rules. */
22
+ export function sseDataPayload(block: string): string | null {
23
+ const data: string[] = [];
24
+ for (const line of block.split(/\r?\n/)) {
25
+ if (!line.startsWith("data:")) continue;
26
+ const value = line.slice(5);
27
+ data.push(value.startsWith(" ") ? value.slice(1) : value);
28
+ }
29
+ return data.length > 0 ? data.join("\n") : null;
30
+ }
31
+
32
+ /** Replace an SSE event's data field while preserving non-data fields and newline style. */
33
+ export function replaceSseDataPayload(block: string, payload: string): string {
34
+ const newline = block.includes("\r\n") ? "\r\n" : "\n";
35
+ const lines = block.split(/\r?\n/);
36
+ const rewritten: string[] = [];
37
+ let replaced = false;
38
+ for (const line of lines) {
39
+ if (!line.startsWith("data:")) {
40
+ rewritten.push(line);
41
+ continue;
42
+ }
43
+ if (!replaced) {
44
+ rewritten.push(`data: ${payload}`);
45
+ replaced = true;
46
+ }
47
+ }
48
+ return replaced ? rewritten.join(newline) : block;
49
+ }
50
+
51
+ /** Apply rewrites left-to-right; empty list is identity. */
52
+ export function composeSsePayloadRewrites(...rewrites: SsePayloadRewrite[]): SsePayloadRewrite {
53
+ if (rewrites.length === 0) return (payload) => payload;
54
+ if (rewrites.length === 1) return rewrites[0]!;
55
+ return (payload) => {
56
+ let next = payload;
57
+ for (const rewrite of rewrites) next = rewrite(next);
58
+ return next;
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Relay an SSE body through a single JS pull wrapper, rewriting each event's data payload in place.
64
+ * Non-data fields and framing are preserved; invalid JSON payloads are left to the rewrite callback.
65
+ */
66
+ export function relaySseWithPayloadRewrite(
67
+ body: ReadableStream<Uint8Array>,
68
+ rewrite: SsePayloadRewrite,
69
+ ): ReadableStream<Uint8Array> {
70
+ const reader = body.getReader();
71
+ const decoder = new TextDecoder();
72
+ const encoder = new TextEncoder();
73
+ let buffer = "";
74
+
75
+ const emitProcessedBlocks = (
76
+ controller: ReadableStreamDefaultController<Uint8Array>,
77
+ flushFinal = false,
78
+ ): void => {
79
+ let next: { block: string; delimiter: string; rest: string } | null;
80
+ while ((next = nextSseBlock(buffer))) {
81
+ buffer = next.rest;
82
+ const payload = sseDataPayload(next.block);
83
+ const rewrittenPayload = payload ? rewrite(payload) : undefined;
84
+ const block = payload && rewrittenPayload !== undefined && rewrittenPayload !== payload
85
+ ? replaceSseDataPayload(next.block, rewrittenPayload)
86
+ : next.block;
87
+ controller.enqueue(encoder.encode(block + next.delimiter));
88
+ }
89
+ if (flushFinal && buffer.length > 0) {
90
+ const payload = sseDataPayload(buffer);
91
+ const rewrittenPayload = payload ? rewrite(payload) : undefined;
92
+ const block = payload && rewrittenPayload !== undefined && rewrittenPayload !== payload
93
+ ? replaceSseDataPayload(buffer, rewrittenPayload)
94
+ : buffer;
95
+ controller.enqueue(encoder.encode(block));
96
+ buffer = "";
97
+ }
98
+ };
99
+
100
+ return new ReadableStream<Uint8Array>({
101
+ async pull(controller) {
102
+ const { done, value } = await reader.read();
103
+ if (done) {
104
+ buffer += decoder.decode();
105
+ emitProcessedBlocks(controller, true);
106
+ controller.close();
107
+ return;
108
+ }
109
+ buffer += decoder.decode(value, { stream: true });
110
+ emitProcessedBlocks(controller);
111
+ },
112
+ cancel(reason) {
113
+ reader.cancel(reason).catch(() => {});
114
+ },
115
+ });
116
+ }
@@ -106,10 +106,14 @@ export function resetStartupInstallStateForTests(): void {
106
106
  installState = { status: "idle" };
107
107
  }
108
108
 
109
- export function startupInstallArgv(action: StartupInstallAction): string[] {
110
- return action === "install-service"
111
- ? ["service", "install"]
112
- : ["codex-shim", "install"];
109
+ export function startupInstallArgv(
110
+ action: StartupInstallAction,
111
+ options?: { repair?: boolean },
112
+ ): string[] {
113
+ if (action === "install-service") {
114
+ return options?.repair ? ["service", "repair"] : ["service", "install"];
115
+ }
116
+ return ["codex-shim", "install"];
113
117
  }
114
118
 
115
119
  export interface CliInstallFailure {
@@ -152,10 +156,13 @@ export function installFailureDetail(stdout: string, stderr: string, error: Erro
152
156
  return classifyCliInstallFailure(stdout, stderr, error).detail;
153
157
  }
154
158
 
155
- function runCliInstall(action: StartupInstallAction): Promise<{ stdout: string; stderr: string }> {
159
+ function runCliInstall(
160
+ action: StartupInstallAction,
161
+ options?: { repair?: boolean },
162
+ ): Promise<{ stdout: string; stderr: string }> {
156
163
  const bun = durableBunPath();
157
164
  const cli = join(import.meta.dir, "..", "cli", "index.ts");
158
- const argv = [cli, ...startupInstallArgv(action)];
165
+ const argv = [cli, ...startupInstallArgv(action, options)];
159
166
  return new Promise((resolve, reject) => {
160
167
  execFile(bun, argv, {
161
168
  encoding: "utf8",
@@ -219,29 +226,37 @@ function applyReconciliationOutcome(
219
226
  /**
220
227
  * Execute the existing fixed CLI installer outside the proxy event loop.
221
228
  *
229
+ * Repair mode (`options.repair`) runs `ocx service repair` — asset rewrite + restart
230
+ * without Task Scheduler re-registration, so it must not enter the UAC elevation path.
231
+ *
222
232
  * After an elevation request timeout the lock becomes `indeterminate` until the
223
233
  * original elevated transaction completes and is reconciled. A process restart
224
234
  * clears this in-memory lock — callers must then inspect Task Scheduler reality
225
235
  * (see evaluateSchedulerInstallRestartReconciliation) before installing again.
226
236
  */
227
- export function runStartupInstallAction(action: StartupInstallAction): Promise<{ message: string }> {
237
+ export function runStartupInstallAction(
238
+ action: StartupInstallAction,
239
+ options?: { repair?: boolean },
240
+ ): Promise<{ message: string }> {
228
241
  const busy = rejectIfBusy(action);
229
242
  if (busy) return Promise.reject(busy);
230
243
 
244
+ const repair = options?.repair === true;
231
245
  const attemptId = randomUUID();
232
246
  const startedAt = Date.now();
233
247
  installState = { status: "running", action, attemptId, startedAt };
234
248
 
235
249
  const operation = (async () => {
236
250
  try {
237
- await runCliInstall(action);
251
+ await runCliInstall(action, { repair });
238
252
  } catch (error) {
239
253
  const code = installFailureCode(error);
240
254
  const detail = error instanceof Error ? error.message : String(error);
241
- // Elevate only for a structured Task Scheduler /create access denial — never for
242
- // WinSW removal, asset writes, or generic permission errors.
255
+ // Elevate only for fresh install + structured Task Scheduler /create access denial —
256
+ // never for repair, WinSW removal, asset writes, or generic permission errors.
243
257
  if (
244
- action === "install-service"
258
+ !repair
259
+ && action === "install-service"
245
260
  && process.platform === "win32"
246
261
  && (code === WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED_MARKER
247
262
  || isWindowsSchtasksCreateAccessDenied(detail))
@@ -276,10 +291,11 @@ export function runStartupInstallAction(action: StartupInstallAction): Promise<{
276
291
  throw error;
277
292
  }
278
293
  }
294
+ if (action === "install-service") {
295
+ return { message: repair ? "Background service repaired." : "Background service installed." };
296
+ }
279
297
  return {
280
- message: action === "install-service"
281
- ? "Background service installed."
282
- : "Codex launcher shim installed.",
298
+ message: repair ? "Codex launcher shim repaired." : "Codex launcher shim installed.",
283
299
  };
284
300
  })();
285
301
 
@@ -1,4 +1,4 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync } from "node:child_process";
2
2
  import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { getConfigDir } from "../config";
@@ -6,6 +6,7 @@ import { resolveAutoContext, type AutoContextMode } from "../claude/context-wind
6
6
  import { PROXY_MARKER, defaultAuthDetectDeps, detectClaudeAuth, ownAdmissionTokens } from "../claude/auth-detect";
7
7
  import { resolveClaudeAuthMode } from "../claude/auth-mode";
8
8
  import type { OcxConfig } from "../types";
9
+ import { recordOwnedConfigPath } from "../lib/config-ownership";
9
10
 
10
11
  /**
11
12
  * Does the opencodex dummy marker belong in the system environment?
@@ -72,8 +73,10 @@ function writeShellEnvFile(port: number, config: OcxConfig, modelEnv: Record<str
72
73
  if (config.claudeCode?.alwaysEnableEffort === true) {
73
74
  lines.push(conditional("CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", "1"));
74
75
  }
76
+ const shellEnvPath = getShellEnvFilePath();
77
+ recordOwnedConfigPath(getConfigDir(), shellEnvPath);
75
78
  mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
76
- writeFileSync(getShellEnvFilePath(), lines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
79
+ writeFileSync(shellEnvPath, lines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
77
80
  }
78
81
 
79
82
  function removeShellEnvFile(): void {
@@ -128,6 +131,19 @@ const SYSTEM_ENV_NAMES = [
128
131
  "ANTHROPIC_AUTH_TOKEN",
129
132
  ] as const;
130
133
 
134
+ const MANAGED_SYSTEM_ENV_NAMES = new Set<string>([
135
+ ...SYSTEM_ENV_NAMES,
136
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
137
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
138
+ "ANTHROPIC_DEFAULT_FABLE_MODEL",
139
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
140
+ "ANTHROPIC_SMALL_FAST_MODEL",
141
+ "CLAUDE_CODE_MAX_CONTEXT_TOKENS",
142
+ "DISABLE_COMPACT",
143
+ "CLAUDE_CODE_AUTO_COMPACT_WINDOW",
144
+ "CLAUDE_CODE_ALWAYS_ENABLE_EFFORT",
145
+ ]);
146
+
131
147
  interface SystemEnvTracking {
132
148
  pid: number;
133
149
  port: number;
@@ -147,7 +163,7 @@ export function getSystemEnvTrackingPath(): string {
147
163
  export function launchctlGetenv(name: string): string | undefined {
148
164
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return undefined;
149
165
  try {
150
- const value = execSync(`launchctl getenv ${name}`, { encoding: "utf8" }).trim();
166
+ const value = execFileSync("/bin/launchctl", ["getenv", name], { encoding: "utf8" }).trim();
151
167
  return value || undefined;
152
168
  } catch {
153
169
  return undefined;
@@ -160,22 +176,23 @@ function readTracking(): SystemEnvTracking | undefined {
160
176
  if (!Number.isInteger(tracking.port) || typeof tracking.pid !== "number" || typeof tracking.injectedAt !== "string") {
161
177
  return undefined;
162
178
  }
163
- return tracking as SystemEnvTracking;
179
+ const injectedKeys = Array.isArray(tracking.injectedKeys)
180
+ ? [...new Set(tracking.injectedKeys.filter(
181
+ (name): name is string => typeof name === "string" && MANAGED_SYSTEM_ENV_NAMES.has(name),
182
+ ))]
183
+ : undefined;
184
+ return { ...tracking, injectedKeys } as SystemEnvTracking;
164
185
  } catch {
165
186
  return undefined;
166
187
  }
167
188
  }
168
189
 
169
- function shellArg(value: string): string {
170
- return /^[A-Za-z0-9_./:-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
171
- }
172
-
173
190
  function setLaunchctlEnv(name: string, value: string): void {
174
- execSync(`launchctl setenv ${name} ${shellArg(value)}`);
191
+ execFileSync("/bin/launchctl", ["setenv", name, value]);
175
192
  }
176
193
 
177
194
  function unsetLaunchctlEnv(name: string): void {
178
- execSync(`launchctl unsetenv ${name}`);
195
+ execFileSync("/bin/launchctl", ["unsetenv", name]);
179
196
  }
180
197
 
181
198
  function ownedBaseUrl(port: number): string {
@@ -183,6 +200,7 @@ function ownedBaseUrl(port: number): string {
183
200
  }
184
201
 
185
202
  function writeTracking(port: number, injectedKeys: string[]): void {
203
+ recordOwnedConfigPath(getConfigDir(), getSystemEnvTrackingPath());
186
204
  mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
187
205
  writeFileSync(getSystemEnvTrackingPath(), JSON.stringify({
188
206
  pid: process.pid,