@bitkyc08/opencodex 2.7.42 → 2.7.43

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 (143) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +13 -14
  3. package/gui/dist/assets/index-Czw-jpTU.css +1 -0
  4. package/gui/dist/assets/index-cmds12BG.js +67 -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/native-exec-shell.ts +18 -6
  10. package/src/adapters/cursor/protobuf-events.ts +24 -2
  11. package/src/adapters/cursor/protobuf-request.ts +1 -2
  12. package/src/adapters/cursor/tool-definitions.ts +68 -29
  13. package/src/adapters/google-wire-compiler.ts +4 -0
  14. package/src/adapters/google.ts +128 -2
  15. package/src/adapters/identity.ts +12 -2
  16. package/src/adapters/kiro.ts +49 -6
  17. package/src/adapters/mimo-free.ts +2 -0
  18. package/src/adapters/openai-responses.ts +246 -59
  19. package/src/claude/agents-inject.ts +5 -0
  20. package/src/claude/inbound.ts +26 -9
  21. package/src/cli/account-auth.ts +1 -1
  22. package/src/cli/agent-driven.ts +37 -0
  23. package/src/cli/claude.ts +3 -3
  24. package/src/cli/doctor.ts +23 -18
  25. package/src/cli/help.ts +42 -6
  26. package/src/cli/index.ts +88 -19
  27. package/src/cli/interactive-confirm.ts +129 -0
  28. package/src/cli/opencode.ts +701 -0
  29. package/src/cli/provider-runtime.ts +3 -0
  30. package/src/cli/provider.ts +31 -10
  31. package/src/cli/star-prompt.ts +55 -16
  32. package/src/cli/status.ts +47 -13
  33. package/src/codex/account-id.ts +34 -0
  34. package/src/codex/account-lifecycle.ts +4 -1
  35. package/src/codex/account-namespace-match.ts +63 -0
  36. package/src/codex/account-namespaces.ts +149 -0
  37. package/src/codex/account-pause.ts +20 -0
  38. package/src/codex/account-usability.ts +6 -1
  39. package/src/codex/app-server-processes.ts +511 -0
  40. package/src/codex/auth-api.ts +293 -34
  41. package/src/codex/auth-collision.ts +2 -1
  42. package/src/codex/auth-context.ts +60 -17
  43. package/src/codex/catalog/parsing.ts +16 -1
  44. package/src/codex/catalog/provider-fetch.ts +48 -12
  45. package/src/codex/catalog/sync.ts +18 -3
  46. package/src/codex/catalog.ts +1 -1
  47. package/src/codex/history-provider.ts +145 -1
  48. package/src/codex/inject.ts +114 -14
  49. package/src/codex/main-account.ts +2 -8
  50. package/src/codex/pool-rotation.ts +186 -0
  51. package/src/codex/quota.ts +15 -0
  52. package/src/codex/routing.ts +695 -106
  53. package/src/codex/shim.ts +4 -1
  54. package/src/codex/subagent-defaults.ts +550 -0
  55. package/src/codex/subagent-model-fallback.ts +2 -0
  56. package/src/codex/sync.ts +3 -0
  57. package/src/config.ts +566 -25
  58. package/src/images/artifacts.ts +516 -0
  59. package/src/images/fulfill-video.ts +163 -0
  60. package/src/images/fulfill.ts +111 -0
  61. package/src/images/index.ts +4 -0
  62. package/src/images/loop.ts +789 -0
  63. package/src/images/plan.ts +133 -0
  64. package/src/images/synthetic-tool.ts +133 -0
  65. package/src/images/types.ts +41 -0
  66. package/src/images/xai-client.ts +141 -0
  67. package/src/images/xai-video-client.ts +163 -0
  68. package/src/lib/admin-secrets.ts +25 -0
  69. package/src/lib/config-ownership.ts +327 -0
  70. package/src/lib/crash-guard.ts +2 -0
  71. package/src/lib/destination-policy.ts +122 -4
  72. package/src/lib/pinned-http.ts +151 -0
  73. package/src/lib/process-control.ts +2 -2
  74. package/src/lib/provider-outbound.ts +164 -0
  75. package/src/lib/provider-url.ts +14 -0
  76. package/src/lib/proxy-env.ts +18 -0
  77. package/src/lib/windows-elevation.ts +81 -3
  78. package/src/lib/windows-secret-acl.ts +189 -12
  79. package/src/lib/winsw.ts +2 -0
  80. package/src/oauth/anthropic-routing.ts +570 -0
  81. package/src/oauth/health.ts +6 -0
  82. package/src/oauth/index.ts +283 -72
  83. package/src/oauth/key-providers.ts +17 -6
  84. package/src/oauth/kimi.ts +2 -0
  85. package/src/oauth/kiro-credentials.ts +322 -10
  86. package/src/oauth/kiro.ts +423 -43
  87. package/src/oauth/login-cli.ts +32 -5
  88. package/src/oauth/store.ts +54 -4
  89. package/src/oauth/types.ts +11 -0
  90. package/src/providers/alibaba-region-migration.ts +16 -3
  91. package/src/providers/antigravity-models.ts +3 -0
  92. package/src/providers/api-keys.ts +13 -6
  93. package/src/providers/derive.ts +6 -0
  94. package/src/providers/key-failover.ts +24 -4
  95. package/src/providers/quota.ts +233 -29
  96. package/src/providers/registry.ts +11 -3
  97. package/src/responses/parser.ts +11 -0
  98. package/src/responses/state.ts +22 -8
  99. package/src/responses/tool-groups.ts +19 -0
  100. package/src/router.ts +14 -4
  101. package/src/server/auth-cors.ts +110 -22
  102. package/src/server/claude-messages.ts +8 -1
  103. package/src/server/gui-static.ts +30 -6
  104. package/src/server/images.ts +303 -9
  105. package/src/server/index.ts +77 -9
  106. package/src/server/lifecycle.ts +25 -1
  107. package/src/server/management/agent-settings-routes.ts +28 -4
  108. package/src/server/management/combo-routes.ts +7 -0
  109. package/src/server/management/config-routes.ts +5 -2
  110. package/src/server/management/logs-usage-routes.ts +167 -3
  111. package/src/server/management/oauth-account-routes.ts +163 -17
  112. package/src/server/management/provider-routes.ts +35 -7
  113. package/src/server/management/system-restart.ts +172 -0
  114. package/src/server/management/system-routes.ts +33 -10
  115. package/src/server/management-api.ts +2 -2
  116. package/src/server/management-auth.ts +216 -0
  117. package/src/server/proxy-liveness.ts +14 -3
  118. package/src/server/responses/compact.ts +21 -13
  119. package/src/server/responses/core.ts +583 -152
  120. package/src/server/responses-image-gen-repair.ts +118 -0
  121. package/src/server/responses-item-id-repair.ts +10 -85
  122. package/src/server/sse-payload-rewrite.ts +116 -0
  123. package/src/server/system-env.ts +28 -10
  124. package/src/service.ts +49 -2
  125. package/src/storage/cleanup-job.ts +57 -0
  126. package/src/storage/cleanup.ts +1504 -28
  127. package/src/storage/policy-job.ts +366 -0
  128. package/src/storage/policy-scheduler.ts +40 -0
  129. package/src/storage/policy-worker.ts +53 -0
  130. package/src/storage/policy.ts +522 -0
  131. package/src/storage/restore-job.ts +242 -0
  132. package/src/storage/restore-worker.ts +52 -0
  133. package/src/storage/storage-mutation-coordinator.ts +109 -0
  134. package/src/tray/windows.ts +2 -0
  135. package/src/types.ts +96 -1
  136. package/src/update/index.ts +36 -18
  137. package/src/update/job.ts +103 -12
  138. package/src/update/npm-invocation.d.mts +23 -0
  139. package/src/update/npm-invocation.mjs +94 -0
  140. package/src/usage/debug.ts +2 -0
  141. package/src/usage/log.ts +4 -0
  142. package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
  143. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -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
+ }
@@ -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,
package/src/service.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  import { defaultWinswEntry, installWinswService, startWinswService, stopWinswService, statusWinswRaw, uninstallWinswService, winswStatusSummary, WINSW_SERVICE_ID, WINSW_SHA256, WINSW_VERSION } from "./lib/winsw";
35
35
  import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
36
36
  import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths";
37
+ import { recordOwnedConfigPath } from "./lib/config-ownership";
37
38
 
38
39
  const LABEL = "com.opencodex.proxy";
39
40
  const TASK = "opencodex-proxy";
@@ -146,6 +147,7 @@ function writeServiceInstallState(backend: ServiceBackend = "scheduler"): void {
146
147
  };
147
148
  for (const path of serviceStatePaths()) {
148
149
  const dir = dirname(path);
150
+ recordOwnedConfigPath(getConfigDir(), path);
149
151
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
150
152
  writeFileSync(path, JSON.stringify(state, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
151
153
  try { chmodSync(path, 0o600); } catch { /* best-effort */ }
@@ -254,6 +256,7 @@ function writeServiceApiTokenFile(): string | null {
254
256
  if (!token) return null;
255
257
  const path = serviceApiTokenFilePath();
256
258
  const dir = getConfigDir();
259
+ recordOwnedConfigPath(dir, path);
257
260
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
258
261
  if (process.platform === "win32") hardenSecretDir(dir, { required: true });
259
262
  writeFileSync(path, `${token}\n`, { encoding: "utf8", mode: 0o600 });
@@ -1039,6 +1042,43 @@ function taskXmlHasPrefixedTag(xml: string, tag: string): boolean {
1039
1042
  * Absence means the documented schema default (#432); a present element must still
1040
1043
  * match exactly, so a malformed or explicitly unsafe value never reads as healthy.
1041
1044
  */
1045
+ /**
1046
+ * Decode XML's five predefined entities, exactly once.
1047
+ *
1048
+ * Task Scheduler re-encodes element text when it exports a task, so a needle we
1049
+ * escaped ourselves can never match its output (#608). Compare decoded values
1050
+ * instead of encoded ones.
1051
+ *
1052
+ * The single pass is the point: decoding twice would turn `&amp;quot;` into `"`,
1053
+ * letting a doubly-encoded value impersonate the expected launcher path.
1054
+ */
1055
+ function taskXmlDecodeEntities(value: string): string {
1056
+ return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, name: string) => (
1057
+ name === "amp" ? "&"
1058
+ : name === "lt" ? "<"
1059
+ : name === "gt" ? ">"
1060
+ : name === "quot" ? "\""
1061
+ : "'"
1062
+ ));
1063
+ }
1064
+
1065
+ /**
1066
+ * Exactly one unprefixed `<tag>` whose DECODED text equals `expected`.
1067
+ *
1068
+ * Unlike taskXmlOptionalValueEquals(), an absent element is NOT a pass: these
1069
+ * elements name what actually gets executed, so a missing <Command>/<Arguments>
1070
+ * must fail the health check rather than inherit a schema default.
1071
+ */
1072
+ function taskXmlDecodedValueEquals(xml: string, tag: string, expected: string): boolean {
1073
+ // Same reasoning as the optional helper: `<t:Arguments>` must not read as absent.
1074
+ if (taskXmlHasPrefixedTag(xml, tag)) return false;
1075
+ if (taskXmlElementCount(xml, tag) !== 1) return false;
1076
+ // `[^<]*` refuses nested markup, so a decoy inside a child element cannot match.
1077
+ const value = new RegExp(`<${tag}(?:\\s[^>]*?)?>([^<]*)<\\/${tag}>`, "i").exec(xml)?.[1];
1078
+ if (value === undefined) return false;
1079
+ return taskXmlDecodeEntities(value).trim() === expected.trim();
1080
+ }
1081
+
1042
1082
  function taskXmlOptionalValueEquals(xml: string, tag: string, expected: string): boolean {
1043
1083
  // Check the prefixed form first: treating `<t:Enabled>false</t:Enabled>` as an
1044
1084
  // omission would turn an explicitly disabled task into a healthy one.
@@ -1076,8 +1116,11 @@ export function windowsTaskRegistrationHealthy(
1076
1116
  && taskXmlOptionalValueEquals(settings, "Enabled", "true")
1077
1117
  && /<MultipleInstancesPolicy>\s*IgnoreNew\s*<\/MultipleInstancesPolicy>/i.test(settings)
1078
1118
  && /<ExecutionTimeLimit>\s*PT0S\s*<\/ExecutionTimeLimit>/i.test(settings)
1079
- && action.includes(`<Command>${taskXmlString(wscript)}</Command>`)
1080
- && action.includes(`<Arguments>${taskXmlString(`/b /nologo "${launcher}"`)}</Arguments>`);
1119
+ // Compare decoded VALUES, not encodings: Task Scheduler canonicalizes the
1120
+ // quotes we wrote as `&quot;` back to literal `"` on export, so an escaped
1121
+ // needle never matched and a healthy task read as permanently stale (#608).
1122
+ && taskXmlDecodedValueEquals(action, "Command", wscript)
1123
+ && taskXmlDecodedValueEquals(action, "Arguments", `/b /nologo "${launcher}"`);
1081
1124
  }
1082
1125
 
1083
1126
  export interface WindowsSchedulerXmlState {
@@ -1112,6 +1155,7 @@ export function readWindowsSchedulerXmlState(
1112
1155
  function installLaunchd(): void {
1113
1156
  const dir = join(homedir(), "Library", "LaunchAgents");
1114
1157
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1158
+ recordOwnedConfigPath(getConfigDir(), serviceStatePath());
1115
1159
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
1116
1160
  writeServiceApiTokenFile();
1117
1161
  const p = plistPath();
@@ -1148,6 +1192,7 @@ function writeServiceAssetWithRetry(path: string, content: string, encoding: "ut
1148
1192
  }
1149
1193
 
1150
1194
  function installWindows(): void {
1195
+ recordOwnedConfigPath(getConfigDir(), serviceStatePath());
1151
1196
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
1152
1197
  writeServiceApiTokenFile();
1153
1198
  // Transactional backend switch: installing the scheduler backend removes a native
@@ -1183,6 +1228,7 @@ function installWindows(): void {
1183
1228
  * reported) — never a silent fallback to the scheduler.
1184
1229
  */
1185
1230
  async function installWindowsNative(): Promise<void> {
1231
+ recordOwnedConfigPath(getConfigDir(), serviceStatePath());
1186
1232
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
1187
1233
  writeServiceApiTokenFile();
1188
1234
  let hadScheduler = false;
@@ -1321,6 +1367,7 @@ function installSystemd(): void {
1321
1367
  ensureUserBusEnv(); // reach the user bus over a bare SSH session (F9)
1322
1368
  const dir = unitDir();
1323
1369
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1370
+ recordOwnedConfigPath(getConfigDir(), serviceStatePath());
1324
1371
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
1325
1372
  writeServiceApiTokenFile();
1326
1373
  writeFileSync(unitPath(), buildUnit(), "utf8");
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Single-flight wrapper for manual archived cleanup (management API).
3
+ *
4
+ * Shares the CODEX_HOME storage-mutation coordinator with trash restore and
5
+ * (Phase 3) policy-driven cleanup.
6
+ */
7
+ import { resolveCodexHomeDir } from "../codex/home";
8
+ import {
9
+ executeArchivedCleanup,
10
+ type CleanupResult,
11
+ type ExecuteCleanupOptions,
12
+ } from "./cleanup";
13
+ import {
14
+ resetStorageMutationCoordinatorForTests,
15
+ setStorageMutationCoordinatorTestHooks,
16
+ withStorageMutationSlot,
17
+ type StorageMutationCoordinatorTestHooks,
18
+ } from "./storage-mutation-coordinator";
19
+
20
+ export type CleanupJobTestHooks = StorageMutationCoordinatorTestHooks;
21
+
22
+ export function setArchivedCleanupJobTestHooks(hooks: CleanupJobTestHooks | null): void {
23
+ setStorageMutationCoordinatorTestHooks(hooks);
24
+ }
25
+
26
+ export function resetArchivedCleanupJobForTests(): void {
27
+ resetStorageMutationCoordinatorForTests();
28
+ }
29
+
30
+ /**
31
+ * Run archived cleanup under the shared storage-mutation gate.
32
+ * Returns immediately with `storage_mutation_busy` when restore or another
33
+ * cleanup is in flight for the same CODEX_HOME.
34
+ */
35
+ export async function runArchivedCleanupJob(
36
+ options: ExecuteCleanupOptions,
37
+ ): Promise<CleanupResult> {
38
+ const codexHome = options.codexHome ?? resolveCodexHomeDir();
39
+ const mode = options.mode;
40
+ const percent = options.percent;
41
+ const result = await withStorageMutationSlot("cleanup", codexHome, () =>
42
+ executeArchivedCleanup(options),
43
+ );
44
+ if (result && typeof result === "object" && "ok" in result && result.ok === false
45
+ && "error" in result && result.error === "storage_mutation_busy") {
46
+ return {
47
+ ok: false,
48
+ mode,
49
+ percent,
50
+ count: 0,
51
+ bytes: 0,
52
+ removedPaths: [],
53
+ error: "storage_mutation_busy",
54
+ };
55
+ }
56
+ return result as CleanupResult;
57
+ }