@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
@@ -3,6 +3,7 @@ import { readFileSync, readdirSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { dirname, join } from "node:path";
5
5
  import { getConfigDir, loadConfig, readPid, readRuntimePort } from "../config";
6
+ import { npmInvocation } from "./npm-invocation.mjs";
6
7
  import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs";
7
8
 
8
9
  /**
@@ -50,19 +51,24 @@ export function updateTag(current: string): Channel {
50
51
  return defaultUpdateTag(current);
51
52
  }
52
53
 
53
- /**
54
- * npm is `npm.cmd` on Windows, and Node/Bun refuse shell-less .cmd spawns
55
- * (CVE-2024-27980 hardening) route Windows npm invocations through the shell.
56
- */
57
- function npmSpawnTarget(bin: string): { bin: string; shell: boolean } {
58
- if (process.platform !== "win32" || bin !== "npm") return { bin, shell: false };
59
- return { bin: "npm.cmd", shell: true };
54
+ function npmSpawnTarget(args: readonly string[]): { bin: string; args: string[]; options: { windowsVerbatimArguments?: boolean } } | null {
55
+ const invocation = npmInvocation(args);
56
+ if (!invocation) return null;
57
+ return { bin: invocation.file, args: invocation.args, options: invocation.options };
58
+ }
59
+
60
+ function updateSpawnTarget(bin: string, args: readonly string[]): { bin: string; args: string[]; options: { windowsVerbatimArguments?: boolean } } | null {
61
+ if (bin === "npm") return npmSpawnTarget(args);
62
+ if (process.platform === "win32" && bin === "bun") {
63
+ return { bin: process.execPath, args: [...args], options: {} };
64
+ }
65
+ return { bin, args: [...args], options: {} };
60
66
  }
61
67
 
62
68
  /**
63
69
  * The GUI update worker sets OCX_SERVICE=1 and has stdio ignored — inheriting that for
64
- * `npm.cmd` (shell:true) opens stacked visible consoles on Windows. Pipe instead and
65
- * relay bounded output after the child exits. (Ported from PR #167.)
70
+ * Background package-manager children can open stacked visible consoles on Windows.
71
+ * Pipe instead and relay bounded output after the child exits. (Ported from PR #167.)
66
72
  */
67
73
  function updateChildStdio(): "inherit" | "pipe" {
68
74
  if (process.env.OCX_SERVICE === "1") return "pipe";
@@ -79,8 +85,14 @@ function logSpawnOutput(label: string, result: { stdout?: string | Buffer | null
79
85
 
80
86
  /** Latest published version from the registry (best-effort; null if npm isn't available). */
81
87
  export function latestVersion(tag: string): string | null {
82
- const npm = npmSpawnTarget("npm");
83
- const r = spawnSync(npm.bin, ["view", `${PKG}@${tag}`, "version"], { encoding: "utf8", timeout: 12000, windowsHide: true, shell: npm.shell });
88
+ const npm = npmSpawnTarget(["view", `${PKG}@${tag}`, "version"]);
89
+ if (!npm) return null;
90
+ const r = spawnSync(npm.bin, npm.args, {
91
+ encoding: "utf8",
92
+ timeout: 12000,
93
+ windowsHide: true,
94
+ ...npm.options,
95
+ });
84
96
  return r.status === 0 ? (r.stdout.trim() || null) : null;
85
97
  }
86
98
 
@@ -116,11 +128,12 @@ export function checkUpdatePackageIntegrity(
116
128
  spawn: typeof spawnSync = spawnSync,
117
129
  ): { ok: true; integrity: string } | { ok: false; reason: string } | { ok: "skipped"; reason: string } {
118
130
  if (!version) return { ok: "skipped", reason: "no resolved version (registry unavailable)" };
119
- const npm = npmSpawnTarget("npm");
131
+ const npm = npmSpawnTarget(["view", `${PKG}@${version}`, "dist.integrity"]);
132
+ if (!npm) return { ok: "skipped", reason: "npm executable was not found on a trusted PATH entry" };
120
133
  const r = spawn(
121
134
  npm.bin,
122
- ["view", `${PKG}@${version}`, "dist.integrity"],
123
- { encoding: "utf8", timeout: 12000, windowsHide: true, shell: npm.shell },
135
+ npm.args,
136
+ { encoding: "utf8", timeout: 12000, windowsHide: true, ...npm.options },
124
137
  );
125
138
  // status !== 0 covers nonzero exits AND timeouts (status === null).
126
139
  if (r.status !== 0) return { ok: "skipped", reason: `registry integrity query failed (status ${r.status ?? "timeout"})` };
@@ -164,6 +177,13 @@ export async function runUpdate(): Promise<void> {
164
177
  console.log(`Verified ${PKG}@${latest} integrity metadata ${integrity.integrity.slice(0, 24)}…`);
165
178
  }
166
179
 
180
+ const { bin, args: cmdArgs } = updateCommand(installer, tag, latest);
181
+ const target = updateSpawnTarget(bin, cmdArgs);
182
+ if (!target) {
183
+ console.error("⚠️ Could not resolve npm from a trusted absolute PATH entry; aborting before stopping the proxy.");
184
+ process.exit(1);
185
+ }
186
+
167
187
  // Remember whether a background service manages the proxy BEFORE stopping — `ocx stop`
168
188
  // unloads it permanently, so a successful update must reinstall/restart it afterwards.
169
189
  let serviceWasInstalled = false;
@@ -240,17 +260,15 @@ export async function runUpdate(): Promise<void> {
240
260
  }
241
261
  }
242
262
 
243
- const { bin, args: cmdArgs } = updateCommand(installer, tag, latest);
244
263
  console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`);
245
264
 
246
- const target = npmSpawnTarget(bin);
247
265
  const installStdio = updateChildStdio();
248
- const r = spawnSync(target.bin, cmdArgs, {
266
+ const r = spawnSync(target.bin, target.args, {
249
267
  stdio: installStdio,
250
268
  encoding: installStdio === "pipe" ? "utf8" : undefined,
251
269
  timeout: 180000,
252
270
  windowsHide: true,
253
- shell: target.shell,
271
+ ...target.options,
254
272
  });
255
273
  if (installStdio === "pipe") logSpawnOutput("", r);
256
274
  if (r.status === 0) {
package/src/update/job.ts CHANGED
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { atomicWriteFile, getConfigDir, loadConfig, readPid, readRuntimePort } from "../config";
6
- import { killProxy } from "../lib/process-control";
6
+ import { isProcessAlive, killProxy } from "../lib/process-control";
7
7
  import { reclaimListenPort } from "../server/port-reclaim";
8
8
  import { isOpencodexHealthz, probeHostname, proxyIdentityAt, type HealthzIdentity } from "../server/proxy-liveness";
9
9
  import { isServiceInstalled } from "../service";
@@ -28,6 +28,8 @@ const UPDATE_TIMEOUT_MS = 180_000;
28
28
  const RESTART_TIMEOUT_MS = 60_000;
29
29
  const RESTART_HEALTH_TIMEOUT_MS = 15_000;
30
30
  const RESTART_STABILITY_WINDOW_MS = 15_000;
31
+ /** Legacy active records did not persist a worker PID, so age is their only safe recovery signal. */
32
+ export const UPDATE_JOB_LEGACY_STALE_MS = 10 * 60_000;
31
33
  /** How long update restart waits for the captured port to become bindable after stop. */
32
34
  export const RESTART_PORT_RECLAIM_MS = 30_000;
33
35
 
@@ -77,6 +79,19 @@ export interface UpdateCheckDeps {
77
79
  latestVersion: (tag: Channel) => string | null;
78
80
  }
79
81
 
82
+ interface UpdateWorkerProcess {
83
+ pid?: number;
84
+ unref(): void;
85
+ once(event: "error", listener: (error: Error) => void): unknown;
86
+ }
87
+
88
+ export interface StartUpdateJobDeps {
89
+ checkForUpdateFn: (channel: Channel) => UpdateCheckResult;
90
+ spawnWorkerFn: (jobId: string, channel: Channel, restart: boolean) => UpdateWorkerProcess;
91
+ isProcessAliveFn: (pid: number) => boolean;
92
+ nowMs: () => number;
93
+ }
94
+
80
95
  const defaultCheckDeps: UpdateCheckDeps = {
81
96
  currentVersion,
82
97
  detectInstall,
@@ -155,7 +170,9 @@ export function updateExecutionCommand(
155
170
  return { bin, args, display: formatCommand(bin, args) };
156
171
  }
157
172
  if (installer === "bun") {
158
- const { bin, args } = updateCommand(installer, channel, resolvedVersion);
173
+ const command = updateCommand(installer, channel, resolvedVersion);
174
+ const bin = process.platform === "win32" ? process.execPath : command.bin;
175
+ const { args } = command;
159
176
  return { bin, args, display: updateCommandStr(installer, channel, resolvedVersion) };
160
177
  }
161
178
  return { bin: "sh", args: ["-lc", manualSourceCommand()], display: manualSourceCommand() };
@@ -225,19 +242,77 @@ function newJobId(): string {
225
242
  return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
226
243
  }
227
244
 
228
- export function startUpdateJob(channel: Channel, restart: boolean): UpdateJobState {
245
+ /**
246
+ * [Decision Log]
247
+ * - Purpose: recover dashboard updates after a detached worker dies without unlocking concurrent live updates.
248
+ * - Existing constraints: legacy records have no PID, while a healthy update may legitimately run for minutes.
249
+ * - Alternatives considered: clear every active record by age, or require operators to delete the file manually.
250
+ * - Chosen approach: trust PID liveness first and use a conservative age limit only for legacy no-PID records.
251
+ * - Why: age-only recovery can start two installers, while never recovering leaves the dashboard permanently blocked.
252
+ * - Impact: live PID records remain locked regardless of age; dead PIDs recover immediately; legacy records recover after ten minutes.
253
+ */
254
+ export function staleActiveUpdateJobReason(
255
+ job: Pick<UpdateJobState, "status" | "pid" | "updatedAt">,
256
+ now = Date.now(),
257
+ isAlive: (pid: number) => boolean = isProcessAlive,
258
+ ): string | null {
259
+ if (job.status !== "running" && job.status !== "restarting") return null;
260
+ if (typeof job.pid === "number" && Number.isSafeInteger(job.pid) && job.pid > 0) {
261
+ return isAlive(job.pid) ? null : `update worker PID ${job.pid} is no longer running`;
262
+ }
263
+ const updatedAt = Date.parse(job.updatedAt);
264
+ if (Number.isFinite(updatedAt) && now - updatedAt >= UPDATE_JOB_LEGACY_STALE_MS) {
265
+ return "legacy active update record has no worker PID and exceeded the stale window";
266
+ }
267
+ return null;
268
+ }
269
+
270
+ const defaultStartUpdateJobDeps: StartUpdateJobDeps = {
271
+ checkForUpdateFn: channel => checkForUpdate(channel),
272
+ spawnWorkerFn: (jobId, channel, restart) => spawn(
273
+ process.execPath,
274
+ [process.argv[1], "__gui-update-worker", jobId, channel, restart ? "restart" : "no-restart"],
275
+ {
276
+ detached: true,
277
+ stdio: "ignore",
278
+ windowsHide: true,
279
+ env: { ...process.env, OCX_SERVICE: "1" },
280
+ },
281
+ ),
282
+ isProcessAliveFn: isProcessAlive,
283
+ nowMs: Date.now,
284
+ };
285
+
286
+ export function startUpdateJob(
287
+ channel: Channel,
288
+ restart: boolean,
289
+ deps: Partial<StartUpdateJobDeps> = {},
290
+ ): UpdateJobState {
291
+ const resolvedDeps = { ...defaultStartUpdateJobDeps, ...deps };
229
292
  const running = readUpdateJob();
230
293
  if (running?.status === "running" || running?.status === "restarting") {
231
- throw new UpdateJobError("An update job is already running", 409, "update_already_running");
294
+ const staleReason = staleActiveUpdateJobReason(
295
+ running,
296
+ resolvedDeps.nowMs(),
297
+ resolvedDeps.isProcessAliveFn,
298
+ );
299
+ if (!staleReason) {
300
+ throw new UpdateJobError("An update job is already running", 409, "update_already_running");
301
+ }
302
+ updateJob(
303
+ running,
304
+ { status: "failed", error: `Recovered stale update job: ${staleReason}.`, exitCode: null },
305
+ `Recovered stale update job: ${staleReason}.`,
306
+ );
232
307
  }
233
308
 
234
- const check = checkForUpdate(channel);
309
+ const check = resolvedDeps.checkForUpdateFn(channel);
235
310
  if (!check.canUpdate) {
236
311
  throw new UpdateJobError(check.reason ?? "No update is available", 409, check.reason ?? "update_unavailable");
237
312
  }
238
313
 
239
314
  const id = newJobId();
240
- const now = new Date().toISOString();
315
+ const now = new Date(resolvedDeps.nowMs()).toISOString();
241
316
  const job: UpdateJobState = {
242
317
  id,
243
318
  status: "running",
@@ -254,14 +329,30 @@ export function startUpdateJob(channel: Channel, restart: boolean): UpdateJobSta
254
329
  };
255
330
  writeJob(job);
256
331
 
257
- const child = spawn(process.execPath, [process.argv[1], "__gui-update-worker", id, channel, restart ? "restart" : "no-restart"], {
258
- detached: true,
259
- stdio: "ignore",
260
- windowsHide: true,
261
- env: { ...process.env, OCX_SERVICE: "1" },
332
+ let child: UpdateWorkerProcess;
333
+ try {
334
+ child = resolvedDeps.spawnWorkerFn(id, channel, restart);
335
+ } catch (error) {
336
+ const message = error instanceof Error ? error.message : String(error);
337
+ updateJob(job, { status: "failed", error: `Could not start update worker: ${message}` }, "Update worker failed to start.");
338
+ throw new UpdateJobError("Could not start update worker", 500, "update_worker_start_failed");
339
+ }
340
+ if (typeof child.pid !== "number" || !Number.isSafeInteger(child.pid) || child.pid <= 0) {
341
+ updateJob(job, { status: "failed", error: "Could not start update worker: no worker PID was returned." }, "Update worker failed to start.");
342
+ throw new UpdateJobError("Could not start update worker", 500, "update_worker_start_failed");
343
+ }
344
+ const startedJob = updateJob(job, { pid: child.pid }, `Update worker started as PID ${child.pid}.`);
345
+ child.once("error", error => {
346
+ const current = readUpdateJob(id);
347
+ if (!current || current.pid !== child.pid || (current.status !== "running" && current.status !== "restarting")) return;
348
+ updateJob(
349
+ current,
350
+ { status: "failed", error: `Update worker failed to start: ${error.message}` },
351
+ "Update worker emitted a startup error.",
352
+ );
262
353
  });
263
354
  child.unref();
264
- return { ...job, pid: child.pid };
355
+ return startedJob;
265
356
  }
266
357
 
267
358
  function runLoggedCommand(job: UpdateJobState, bin: string, args: string[], timeout: number): { status: number | null; signal: NodeJS.Signals | null } {
@@ -0,0 +1,23 @@
1
+ export interface NpmInvocationDeps {
2
+ cwd?: string;
3
+ exists?: (path: string) => boolean;
4
+ }
5
+
6
+ export interface NpmInvocation {
7
+ file: string;
8
+ args: string[];
9
+ options: { windowsVerbatimArguments?: boolean };
10
+ }
11
+
12
+ export declare function resolveNpmCommand(
13
+ platform?: NodeJS.Platform,
14
+ env?: Record<string, string | undefined>,
15
+ deps?: NpmInvocationDeps,
16
+ ): string | null;
17
+
18
+ export declare function npmInvocation(
19
+ args: readonly string[],
20
+ platform?: NodeJS.Platform,
21
+ env?: Record<string, string | undefined>,
22
+ deps?: NpmInvocationDeps,
23
+ ): NpmInvocation | null;
@@ -0,0 +1,94 @@
1
+ import { existsSync } from "node:fs";
2
+ import { win32 } from "node:path";
3
+
4
+ const CMD_META = /([()%!^"`<>&|;, *?])/g;
5
+
6
+ function escapeCmdArg(arg) {
7
+ let out = String(arg).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1");
8
+ return `"${out}"`.replace(CMD_META, "^$1");
9
+ }
10
+
11
+ function escapeCmdCommand(command) {
12
+ return command.replace(CMD_META, "^$1");
13
+ }
14
+
15
+ /**
16
+ * Whether a PATH entry *is* the current directory. The hijack this guards against is
17
+ * cmd.exe resolving a bare `npm` out of the directory opencodex was launched from, so
18
+ * only that exact directory has to be skipped — every candidate we hand to spawn is an
19
+ * absolute path, which is what actually defeats the implicit cwd-first search.
20
+ *
21
+ * Deliberately not a subtree test: npm's default Windows global prefix is
22
+ * `%AppData%\npm` (`C:\Users\x\AppData\Roaming\npm`), so excluding everything under the
23
+ * cwd would fail closed for anyone whose shell sits in their home directory — a normal
24
+ * setup, not the untrusted-project case this hardening is for.
25
+ */
26
+ function isCurrentDirectory(cwd, entry) {
27
+ const left = win32.resolve(entry);
28
+ const right = win32.resolve(cwd);
29
+ return left.toLowerCase() === right.toLowerCase();
30
+ }
31
+
32
+ function cleanPathEntry(entry) {
33
+ const trimmed = entry.trim();
34
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) return trimmed.slice(1, -1);
35
+ return trimmed;
36
+ }
37
+
38
+ export function resolveNpmCommand(
39
+ platform = process.platform,
40
+ env = process.env,
41
+ deps = {},
42
+ ) {
43
+ if (platform !== "win32") return "npm";
44
+ const exists = deps.exists ?? existsSync;
45
+ const cwd = deps.cwd ?? process.cwd();
46
+ const extensions = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
47
+ .split(";")
48
+ .filter(Boolean);
49
+ const pathEntries = (env.PATH ?? env.Path ?? "")
50
+ .split(win32.delimiter)
51
+ .map(cleanPathEntry)
52
+ .filter(Boolean);
53
+
54
+ for (const entry of pathEntries) {
55
+ if (!win32.isAbsolute(entry)) continue;
56
+ if (isCurrentDirectory(cwd, entry)) continue;
57
+ for (const extension of extensions) {
58
+ const candidate = win32.join(entry, `npm${extension.toLowerCase()}`);
59
+ if (exists(candidate)) return win32.resolve(candidate);
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+
65
+ function systemCommandProcessor(env) {
66
+ const systemRoot = env.SystemRoot ?? env.windir;
67
+ if (systemRoot && win32.isAbsolute(systemRoot)) {
68
+ return win32.join(systemRoot, "System32", "cmd.exe");
69
+ }
70
+ const comSpec = env.ComSpec;
71
+ return comSpec && win32.isAbsolute(comSpec) ? win32.resolve(comSpec) : null;
72
+ }
73
+
74
+ export function npmInvocation(
75
+ args,
76
+ platform = process.platform,
77
+ env = process.env,
78
+ deps = {},
79
+ ) {
80
+ const npm = resolveNpmCommand(platform, env, deps);
81
+ if (!npm) return null;
82
+ if (platform !== "win32" || !/\.(cmd|bat)$/i.test(npm)) {
83
+ return { file: npm, args: [...args], options: {} };
84
+ }
85
+
86
+ const commandProcessor = systemCommandProcessor(env);
87
+ if (!commandProcessor) return null;
88
+ const line = [escapeCmdCommand(npm), ...args.map(escapeCmdArg)].join(" ");
89
+ return {
90
+ file: commandProcessor,
91
+ args: ["/d", "/s", "/c", `"${line}"`],
92
+ options: { windowsVerbatimArguments: true },
93
+ };
94
+ }
@@ -4,6 +4,7 @@ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFi
4
4
  import { join } from "node:path";
5
5
  import { getConfigDir } from "../config";
6
6
  import { DEBUG_ENV } from "../lib/debug-settings";
7
+ import { recordOwnedConfigPath } from "../lib/config-ownership";
7
8
  import type { DebugLogEntry } from "../lib/debug-log-buffer";
8
9
  import { redactSecretString, redactSecrets } from "../lib/redact";
9
10
  import type { OcxUsage } from "../types";
@@ -42,6 +43,7 @@ export function truncateForDebug(text: string, max = USAGE_DEBUG_BODY_SAMPLE_BYT
42
43
 
43
44
  function ensureUsageDebugDir(): void {
44
45
  const dir = getConfigDir();
46
+ recordOwnedConfigPath(dir, usageDebugPath());
45
47
  mkdirSync(dir, { recursive: true, mode: 0o700 });
46
48
  try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
47
49
  }
package/src/usage/log.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, appendFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getConfigDir } from "../config";
4
+ import { recordOwnedConfigPath } from "../lib/config-ownership";
4
5
  import { usageDisplayTotalTokens } from "./totals";
5
6
  import type { OcxUsage } from "../types";
6
7
 
@@ -11,6 +12,7 @@ export type AttemptRecoveryKind =
11
12
  | "connection-reset"
12
13
  | "oauth-401"
13
14
  | "key-429"
15
+ | "anthropic-oauth-429"
14
16
  | "image-413";
15
17
 
16
18
  export interface PersistedUsageAttempt {
@@ -140,6 +142,7 @@ const ATTEMPT_RECOVERY_KINDS = new Set<AttemptRecoveryKind>([
140
142
  "connection-reset",
141
143
  "oauth-401",
142
144
  "key-429",
145
+ "anthropic-oauth-429",
143
146
  "image-413",
144
147
  ]);
145
148
  const USAGE_STATUSES = new Set<UsageStatus>([
@@ -315,6 +318,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
315
318
 
316
319
  function ensureUsageLogDir(): void {
317
320
  const dir = getConfigDir();
321
+ recordOwnedConfigPath(dir, usageLogPath());
318
322
  mkdirSync(dir, { recursive: true, mode: 0o700 });
319
323
  try { chmodSync(dir, 0o700); } catch { /* best-effort on platforms that ignore chmod */ }
320
324
  }