@bitkyc08/opencodex 2.10.2 → 2.11.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 (121) hide show
  1. package/README.md +31 -0
  2. package/bin/ocx.mjs +10 -0
  3. package/gui/dist/assets/index-Bk-PN-70.css +1 -0
  4. package/gui/dist/assets/index-BynIEIV-.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -2
  7. package/src/adapters/cursor/effort-map.ts +11 -0
  8. package/src/adapters/cursor/live-transport.ts +11 -0
  9. package/src/adapters/cursor/native-exec-fs.ts +9 -6
  10. package/src/adapters/cursor/native-exec.ts +4 -2
  11. package/src/adapters/cursor/protobuf-events.ts +176 -4
  12. package/src/adapters/cursor/request-builder.ts +15 -4
  13. package/src/adapters/cursor/tool-definitions.ts +118 -2
  14. package/src/adapters/google.ts +15 -5
  15. package/src/adapters/openai-chat.ts +24 -2
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +9 -5
  18. package/src/chat/outbound.ts +4 -3
  19. package/src/claude/desktop-3p.ts +222 -2
  20. package/src/claude/outbound.ts +15 -6
  21. package/src/cli/account-api.ts +4 -0
  22. package/src/cli/account-extended.ts +112 -0
  23. package/src/cli/account.ts +23 -6
  24. package/src/cli/claude-desktop.ts +26 -3
  25. package/src/cli/config-command.ts +9 -0
  26. package/src/cli/help.ts +18 -2
  27. package/src/cli/index.ts +277 -55
  28. package/src/cli/models.ts +5 -1
  29. package/src/cli/provider.ts +8 -2
  30. package/src/cli/ready.ts +301 -0
  31. package/src/cli/system-restart-client.ts +146 -0
  32. package/src/cli/tray-proxy.ts +153 -6
  33. package/src/clients/config-export.ts +12 -19
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-namespaces.ts +49 -3
  36. package/src/codex/account-priority.ts +83 -0
  37. package/src/codex/auth-api.ts +83 -0
  38. package/src/codex/auth-context.ts +5 -2
  39. package/src/codex/catalog/provider-fetch.ts +11 -0
  40. package/src/codex/catalog/sync.ts +23 -1
  41. package/src/codex/codex-write-lock.ts +16 -4
  42. package/src/codex/desired-state.ts +37 -4
  43. package/src/codex/history-job.ts +15 -5
  44. package/src/codex/history-provider.ts +31 -14
  45. package/src/codex/history-worker.ts +28 -4
  46. package/src/codex/inject-coordination.ts +13 -1
  47. package/src/codex/inject.ts +360 -66
  48. package/src/codex/internal/history-writer.ts +1 -1
  49. package/src/codex/native-main-lock-file.ts +5 -1
  50. package/src/codex/native-main-owner.ts +17 -3
  51. package/src/codex/native-profile-manager.ts +19 -0
  52. package/src/codex/native-profile-startup.ts +8 -0
  53. package/src/codex/native-residue.ts +140 -27
  54. package/src/codex/pool-rotation.ts +74 -4
  55. package/src/codex/refresh.ts +7 -0
  56. package/src/codex/routing.ts +177 -36
  57. package/src/codex/subagent-model-fallback.ts +34 -4
  58. package/src/codex/sync.ts +61 -0
  59. package/src/codex/upstream-host-health.ts +329 -31
  60. package/src/combos/request.ts +2 -0
  61. package/src/config.ts +221 -2
  62. package/src/images/loop.ts +1 -1
  63. package/src/integrations/native/ownership-preflight.ts +39 -2
  64. package/src/lib/bun-stream-caps.ts +3 -3
  65. package/src/lib/sse-decoder.ts +41 -0
  66. package/src/lib/system-restart-contract.ts +73 -0
  67. package/src/lib/windows-secret-acl.ts +141 -39
  68. package/src/lib/windows-user-principal.ts +283 -0
  69. package/src/lib/winsw.ts +18 -2
  70. package/src/oauth/key-providers.ts +12 -0
  71. package/src/providers/derive.ts +54 -2
  72. package/src/providers/free-directory.ts +6 -5
  73. package/src/providers/model-discovery.ts +9 -3
  74. package/src/providers/quota.ts +592 -0
  75. package/src/providers/registry.ts +316 -13
  76. package/src/responses/parser.ts +26 -10
  77. package/src/responses/reasoning-replay-cache.ts +1 -0
  78. package/src/routing/profile-namespace.ts +15 -0
  79. package/src/routing/profile.ts +2 -1
  80. package/src/server/auth-cors.ts +44 -13
  81. package/src/server/chat-completions.ts +0 -4
  82. package/src/server/claude-messages.ts +73 -15
  83. package/src/server/github-copilot-responses-repair.ts +338 -0
  84. package/src/server/index.ts +328 -111
  85. package/src/server/lifecycle.ts +36 -0
  86. package/src/server/management/agent-settings-routes.ts +147 -56
  87. package/src/server/management/config-routes.ts +7 -2
  88. package/src/server/management/context.ts +4 -0
  89. package/src/server/management/native-integration-routes.ts +199 -20
  90. package/src/server/management/provider-routes.ts +41 -0
  91. package/src/server/management/routing-profile-routes.ts +234 -5
  92. package/src/server/management/system-restart.ts +12 -10
  93. package/src/server/management/system-routes.ts +20 -0
  94. package/src/server/management-auth.ts +51 -3
  95. package/src/server/ports.ts +41 -1
  96. package/src/server/proxy-liveness.ts +129 -4
  97. package/src/server/readiness.ts +99 -0
  98. package/src/server/relay.ts +113 -97
  99. package/src/server/request-log.ts +10 -4
  100. package/src/server/responses/compact.ts +107 -12
  101. package/src/server/responses/core.ts +220 -39
  102. package/src/server/responses-item-id-repair.ts +22 -3
  103. package/src/server/responses-model-rewrite.ts +29 -0
  104. package/src/server/sse-frame-buffer.ts +292 -0
  105. package/src/server/sse-payload-rewrite.ts +25 -14
  106. package/src/server/ws-bridge.ts +27 -22
  107. package/src/service-manager-probe.ts +520 -10
  108. package/src/service.ts +134 -2
  109. package/src/storage/worker-lifecycle.ts +14 -14
  110. package/src/tray/windows-tray.ps1 +74 -9
  111. package/src/types.ts +68 -2
  112. package/src/update/index.ts +12 -0
  113. package/src/update/job.ts +392 -18
  114. package/src/update/npm-cache-preflight.d.mts +47 -0
  115. package/src/update/npm-cache-preflight.mjs +201 -0
  116. package/src/usage/log.ts +1 -1
  117. package/src/vision/index.ts +77 -2
  118. package/src/web-search/loop.ts +1 -1
  119. package/src/web-search/parse.ts +4 -1
  120. package/gui/dist/assets/index-BKVqyYqT.js +0 -70
  121. package/gui/dist/assets/index-Ca_3269W.css +0 -1
package/src/update/job.ts CHANGED
@@ -37,6 +37,11 @@ import {
37
37
  import { isNewer } from "./notify";
38
38
  import { isRealBunBinary } from "../lib/bun-binary-validator.mjs";
39
39
  import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs";
40
+ import {
41
+ npmCachePreflightFailureMessage,
42
+ runNpmCachePreflight,
43
+ type NpmCachePreflightReason,
44
+ } from "./npm-cache-preflight.mjs";
40
45
 
41
46
  const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest";
42
47
  const UPDATE_JOB_FILENAME = "update-job.json";
@@ -238,9 +243,152 @@ function ensureJobDir(): void {
238
243
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
239
244
  }
240
245
 
246
+ /**
247
+ * Describe external text without reproducing it.
248
+ *
249
+ * Use this wherever an `Error.message`, a vendor stream, or any string this module did not
250
+ * compose would otherwise be interpolated into a persisted field. The result names the error's
251
+ * TYPE and size — enough to tell a reader what class of failure occurred — and never its text,
252
+ * which is where the paths and account names live.
253
+ */
254
+ /**
255
+ * A version string we are willing to repeat in a persisted field.
256
+ *
257
+ * Semver plus an optional prerelease/build tail, capped in length. Anything else is dropped
258
+ * rather than logged: `/healthz` is answered by whatever holds the port, so its `version` is
259
+ * external input on the same footing as an error message.
260
+ */
261
+ function isVersionLike(value: unknown): value is string {
262
+ return typeof value === "string"
263
+ && value.length <= 64
264
+ && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value);
265
+ }
266
+
267
+ function withheldSummary(error: unknown): string {
268
+ // `error.name` is writable, so it is external text like the message. A fixed classification
269
+ // is the only part of an unknown error we can state without repeating something we were
270
+ // handed: `new Error(...)` with `error.name = "Jane Doe"` was persisting the name verbatim.
271
+ const name = error instanceof Error ? "Error" : typeof error;
272
+ // NO MESSAGE TEXT, ever. An earlier version kept messages that carried no path, which sounds
273
+ // reasonable and is wrong: `spawn denied for Jane Doe` has no path in it and still names a
274
+ // person. There is no test on message CONTENT that separates a diagnostic from an identity,
275
+ // so the message does not cross this boundary at all.
276
+ const code = (error as { code?: unknown } | null)?.code;
277
+ // Only recognized codes — an arbitrary uppercase `error.code` can be attacker-shaped too.
278
+ const codeNote = typeof code === "string" && NPM_ERROR_CODES.has(code) ? ` ${code}` : "";
279
+ const text = error instanceof Error ? error.message : String(error ?? "");
280
+ // Node's own errors are structured the same way npm's output is: `syscall` and `errno` are
281
+ // named properties, not prose. Reading those gives a user the actual cause —
282
+ // `Error EACCES · syscall: mkdir · errno: -13` — without repeating a message that could name
283
+ // a person or a path. Both are shape-validated: a syscall is a short lowercase identifier and
284
+ // an errno is an integer, so neither can carry arbitrary text.
285
+ const parts = [`${name}${codeNote}`];
286
+ const syscall = (error as { syscall?: unknown } | null)?.syscall;
287
+ // Same explicit vocabulary as the npm field: a shape check accepts `janedoe`.
288
+ if (typeof syscall === "string" && POSIX_SYSCALLS.has(syscall)) parts.push(`syscall: ${syscall}`);
289
+ const errno = (error as { errno?: unknown } | null)?.errno;
290
+ if (typeof errno === "number" && Number.isInteger(errno)) parts.push(`errno: ${errno}`);
291
+ parts.push(`${Buffer.byteLength(text, "utf8")} bytes withheld`);
292
+ return parts.join(" · ");
293
+ }
294
+
295
+ /**
296
+ * Decide, per field, whether the value is ours to keep.
297
+ *
298
+ * `log` and `error` are composed from this module's own templates; every place that would have
299
+ * interpolated external text now calls `withheldSummary()` first, so the strings arriving here
300
+ * are ours by construction. `releaseNotesUrl` is compared against the module constant rather
301
+ * than pattern-matched, which is what stops a URL-shaped value from smuggling a path.
302
+ * `command` is rendered from validated parts.
303
+ */
304
+ function brandOwnComposedText(key: string, value: unknown): unknown {
305
+ if (key === "releaseNotesUrl") {
306
+ return value === RELEASE_NOTES_URL ? value : "";
307
+ }
308
+ if (key === "command") {
309
+ // Render the command shape first, then apply the same path test as every other field. The
310
+ // renderer only understands space-separated arguments; anything else reaching this field is
311
+ // not a command we built and must not be trusted because of where it was stored.
312
+ return typeof value === "string" ? withholdIfPathBearing(renderSafeCommand(value)) : value;
313
+ }
314
+ // `log` and `error` are ours by construction, but a caller can still slip external text in by
315
+ // interpolating it. Withhold any value that carries an absolute path of any form — that is a
316
+ // narrow, unambiguous test on strings we already control, not the free-text classification
317
+ // that failed nine times.
318
+ if (typeof value === "string") return withholdIfPathBearing(value);
319
+ if (Array.isArray(value)) return value.map(item => (typeof item === "string" ? withholdIfPathBearing(item) : item));
320
+ return value;
321
+ }
322
+
323
+ /** Absolute paths cannot appear in text this module composed; if one does, it came from outside. */
324
+ function withholdIfPathBearing(value: string): string {
325
+ const pathBearing = /[A-Za-z]:[\\/]/.test(value) // C:\ or C:/
326
+ || /\\\\/.test(value) // \\server\share
327
+ || /\\/.test(value) // any backslash
328
+ || /~[\w.-]*\//.test(value) // ~/ or ~user/ anywhere
329
+ || /[%$][A-Za-z_]/.test(value) // %APPDATA%, $HOME
330
+ || /\/[\w.\-~%]+\//.test(value) // any two-segment path run
331
+ || /\b(?:Users|home|Documents and Settings|AppData|Profiles)\b/i.test(value)
332
+ || /\r?\n/.test(value); // multi-line vendor output
333
+ if (!pathBearing) return value;
334
+ return `<withheld: ${Buffer.byteLength(value, "utf8")} bytes, may contain local paths>`;
335
+ }
336
+
337
+ /**
338
+ * Keep a command readable without persisting the launcher path it contains.
339
+ *
340
+ * The real npm worker command is `node /Users/<name>/.../bin/ocx.mjs update --tag latest`, so
341
+ * the account name is inside it by construction. Absolute path arguments are replaced with a
342
+ * placeholder and everything else — the binary name, the flags, the tag — is kept, which is the
343
+ * part a reader actually needs.
344
+ */
345
+ function renderSafeCommand(value: string): string {
346
+ if (!value) return value;
347
+ // Rebuild from a recognized shape rather than filtering the string we were handed. Content
348
+ // cannot distinguish `npm install Mary-Jane` — an account name — from a legitimate package
349
+ // argument, so anything that is not this exact shape is withheld by the caller's path test.
350
+ const parts = value.trim().split(/\s+/);
351
+ const tool = parts[0] === "$" ? parts[1] : parts[0];
352
+ if (tool !== undefined && /^(?:npm|bun|pnpm|yarn|node)$/.test(tool)) {
353
+ const rendered = parts.map(part =>
354
+ /^(?:[A-Za-z]:[\\/]|[\\/]|~|\\\\)/.test(part) ? "<path>" : part);
355
+ // Only fixed flags, our own package spec, and placeholders survive; a bare word that is not
356
+ // one of those is treated as unknown input and the whole value is withheld.
357
+ const allowed = rendered.every(part =>
358
+ part === "$" || part === "<path>"
359
+ || /^(?:npm|bun|pnpm|yarn|node)$/.test(part)
360
+ || /^-{1,2}[\w-]+$/.test(part)
361
+ || /^(?:install|add|update|i)$/.test(part)
362
+ || /^opencodex(?:@[\w.\-]+)?$/.test(part)
363
+ || /^(?:latest|preview|next|beta)$/.test(part)
364
+ || /^\d[\w.\-]*$/.test(part));
365
+ if (allowed) return rendered.join(" ");
366
+ }
367
+ return `<withheld: ${Buffer.byteLength(value, "utf8")} bytes, unrecognized command shape>`;
368
+ }
369
+
370
+ /**
371
+ * Fields that can carry free-form text and therefore need checking at the write boundary.
372
+ *
373
+ * The rest of the record is a closed vocabulary — statuses, channels, installers, versions, an
374
+ * id, timestamps — so checking it only risks mangling values that were never a disclosure
375
+ * route. Naming the risky fields keeps the boundary narrow and auditable.
376
+ */
377
+ const FREE_TEXT_JOB_FIELDS = new Set(["command", "error", "log", "releaseNotesUrl"]);
378
+
379
+ /** Apply the per-field rule at the single point where a job reaches disk. */
380
+ function sanitizePersistedUpdateJob(job: UpdateJobState): UpdateJobState {
381
+ return Object.fromEntries(
382
+ Object.entries(job).map(([key, item]) => [
383
+ key,
384
+ FREE_TEXT_JOB_FIELDS.has(key) ? brandOwnComposedText(key, item) : item,
385
+ ]),
386
+ ) as UpdateJobState;
387
+ }
388
+
241
389
  function writeJob(job: UpdateJobState): void {
242
390
  ensureJobDir();
243
- atomicWriteFile(updateJobPath(), `${JSON.stringify(job, null, 2)}\n`);
391
+ atomicWriteFile(updateJobPath(), `${JSON.stringify(sanitizePersistedUpdateJob(job), null, 2)}\n`);
244
392
  }
245
393
 
246
394
  export function readUpdateJob(jobId?: string | null): UpdateJobState | null {
@@ -254,6 +402,13 @@ export function readUpdateJob(jobId?: string | null): UpdateJobState | null {
254
402
  }
255
403
  }
256
404
 
405
+ /**
406
+ * Log lines are composed by this module, so brand them here rather than at nineteen call sites.
407
+ *
408
+ * The one thing a caller must never do is interpolate external text into a log line — an
409
+ * `Error.message`, a vendor stream, a path we were handed. Those go through
410
+ * `withheldSummary()`, which produces a branded description WITHOUT the text itself.
411
+ */
257
412
  function updateJob(job: UpdateJobState, patch: Partial<UpdateJobState>, logLine?: string): UpdateJobState {
258
413
  const current = readUpdateJob(job.id) ?? job;
259
414
  const next = {
@@ -495,8 +650,7 @@ export function startUpdateJob(
495
650
  try {
496
651
  child = resolvedDeps.spawnWorkerFn(id, channel, restart);
497
652
  } catch (error) {
498
- const message = error instanceof Error ? error.message : String(error);
499
- updateJob(job, { status: "failed", error: `Could not start update worker: ${message}` }, "Update worker failed to start.");
653
+ updateJob(job, { status: "failed", error: `Could not start update worker: ${withheldSummary(error)}` }, "Update worker failed to start.");
500
654
  throw new UpdateJobError("Could not start update worker", 500, "update_worker_start_failed");
501
655
  }
502
656
  if (typeof child.pid !== "number" || !Number.isSafeInteger(child.pid) || child.pid <= 0) {
@@ -509,7 +663,7 @@ export function startUpdateJob(
509
663
  if (!current || current.pid !== child.pid || (current.status !== "running" && current.status !== "restarting")) return;
510
664
  updateJob(
511
665
  current,
512
- { status: "failed", error: `Update worker failed to start: ${error.message}` },
666
+ { status: "failed", error: `Update worker failed to start: ${withheldSummary(error)}` },
513
667
  "Update worker emitted a startup error.",
514
668
  );
515
669
  });
@@ -517,6 +671,20 @@ export function startUpdateJob(
517
671
  return startedJob;
518
672
  }
519
673
 
674
+ /**
675
+ * Run an update step and record WHAT HAPPENED, not what the tool printed.
676
+ *
677
+ * Raw installer output used to be persisted verbatim, which put local paths and account names
678
+ * into a stored file. Six rounds of trying to sanitize it after the fact each produced a new
679
+ * leak — a wrap inside the keyword, a wrap inside the account name, an indented continuation,
680
+ * three consecutive wraps, an empty continuation line. Every fix was an attempt to reconstruct
681
+ * arbitrary multi-line text well enough to match it, and that is not a problem a redactor can
682
+ * win: the leak surface is whatever npm decides to print.
683
+ *
684
+ * So the raw stream is no longer persisted at all. The job keeps the command, its exit status,
685
+ * and a bounded, structured summary — enough to tell a user which step failed and how, with no
686
+ * free-form vendor text passing through the boundary. Detailed output stays ephemeral.
687
+ */
520
688
  function runLoggedCommand(job: UpdateJobState, bin: string, args: string[], timeout: number): { status: number | null; signal: NodeJS.Signals | null } {
521
689
  job = updateJob(job, {}, `$ ${formatCommand(bin, args)}`);
522
690
  const result = spawnSync(bin, args, {
@@ -526,11 +694,171 @@ function runLoggedCommand(job: UpdateJobState, bin: string, args: string[], time
526
694
  });
527
695
  const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
528
696
  const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
529
- if (stdout) job = updateJob(job, {}, stdout.slice(-4000));
530
- if (stderr) updateJob(job, {}, stderr.slice(-4000));
697
+ const summary = summarizeCommandOutput(stdout, stderr, result.status, result.signal);
698
+ if (summary) updateJob(job, {}, summary);
531
699
  return { status: result.status, signal: result.signal };
532
700
  }
533
701
 
702
+ /**
703
+ * Recognized npm/libc error codes, as an explicit set.
704
+ *
705
+ * A shape pattern like `E[A-Z]{3,}` is NOT a vocabulary: `C:\Users\ERROR\.npm` matches it, and
706
+ * the summary then re-emits the username the withheld output was protecting. Only codes on this
707
+ * list are surfaced, and only when they appear in npm's canonical `code <CODE>` position.
708
+ */
709
+ const NPM_ERROR_CODES = new Set([
710
+ "EACCES", "EPERM", "ENOENT", "EEXIST", "ENOTDIR", "EISDIR", "EMFILE", "ENFILE",
711
+ "ENOSPC", "EROFS", "EXDEV", "ELOOP", "ENAMETOOLONG", "ENOTEMPTY", "EBUSY",
712
+ "EAGAIN", "ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN",
713
+ "EPROTO", "ECONNABORTED", "EHOSTUNREACH", "ENETUNREACH", "EPIPE",
714
+ "E401", "E403", "E404", "E409", "E429", "E500", "E503",
715
+ "EINTEGRITY", "ERESOLVE", "ETARGET", "EPUBLISHCONFLICT", "ENEEDAUTH",
716
+ "EUSAGE", "EJSONPARSE", "EOTP", "EINVALIDTYPE", "ELIFECYCLE",
717
+ "ERR_SOCKET_TIMEOUT", "ERR_INVALID_ARG_TYPE", "ERR_MODULE_NOT_FOUND",
718
+ ]);
719
+
720
+ /** npm prints `npm ERR! code EACCES`; anchor on that position rather than scanning free text. */
721
+ const NPM_CODE_RECORD = /^\s*npm\s+ERR!\s+code\s+([A-Z][A-Z0-9_]{2,})\s*$/gm;
722
+
723
+ /**
724
+ * npm's failure output is STRUCTURED, not prose: `npm error <field> <value>`, one field per
725
+ * line (`npm ERR!` on npm 9 and earlier). That is what makes a useful summary possible without
726
+ * reproducing text — we can read named fields and keep the ones whose value cannot be a path.
727
+ *
728
+ * Fields kept, with a real example of each:
729
+ * code E404, EACCES, ETARGET the single most useful line for diagnosis
730
+ * syscall mkdir, open, getaddrinfo what npm was doing
731
+ * errno -13 the OS errno
732
+ * notarget No matching version ... version-resolution explanation, no path
733
+ * 404 404 Not Found - GET <url> registry URL, no local path
734
+ *
735
+ * Deliberately NOT kept: `path`, `dest`, `file`, `stack`, and the bare `Error: ...` line —
736
+ * every one of those is a filesystem path by definition. `A complete log of this run can be
737
+ * found in: <path>` is dropped for the same reason.
738
+ */
739
+ const NPM_FIELD_LINE = /^\s*npm\s+(?:error|ERR!)\s+([a-z0-9]+)\s+(.*)$/gim;
740
+
741
+ /**
742
+ * POSIX syscall names npm actually reports. An explicit vocabulary, not a shape.
743
+ *
744
+ * `^[a-z][a-z0-9_]{1,20}$` accepts `janedoe`, which is the whole problem: allowlisting the
745
+ * FIELD NAME while leaving its VALUE free-form just moves the leak one level in.
746
+ */
747
+ const POSIX_SYSCALLS = new Set([
748
+ "open", "openat", "close", "read", "write", "stat", "lstat", "fstat", "mkdir", "rmdir",
749
+ "unlink", "rename", "symlink", "readlink", "link", "chmod", "chown", "utimes", "access",
750
+ "scandir", "readdir", "copyfile", "realpath", "futime", "ftruncate", "fchmod", "fchown",
751
+ "connect", "getaddrinfo", "getnameinfo", "socket", "bind", "listen", "accept", "send",
752
+ "recv", "shutdown", "spawn", "spawnSync", "kill", "watch", "lchown", "lutimes", "mkdtemp",
753
+ ]);
754
+
755
+ /** Per-field value contracts. A field is only kept when its value satisfies its own rule. */
756
+ const KNOWN_REGISTRY_HOSTS = new Set([
757
+ "registry.npmjs.org",
758
+ "registry.yarnpkg.com",
759
+ "registry.npmmirror.com",
760
+ "npm.pkg.github.com",
761
+ ]);
762
+
763
+ const NPM_FIELD_VALIDATORS: Record<string, (value: string) => string | null> = {
764
+ // A recognized code, nothing else.
765
+ code: value => (NPM_ERROR_CODES.has(value) ? value : null),
766
+ // A known syscall name, nothing else.
767
+ syscall: value => (POSIX_SYSCALLS.has(value) ? value : null),
768
+ // An integer, rendered from the parsed number so the original string never passes through.
769
+ errno: value => (/^-?\d{1,10}$/.test(value) ? String(Number(value)) : null),
770
+ // Version resolution: the FACT only.
771
+ //
772
+ // Two narrowing attempts failed here and the second is the instructive one. Extracting any
773
+ // `name@version` also matched `jane.doe@example.com`. Pinning the NAME to our own package
774
+ // still left the VERSION free: `@bitkyc08/opencodex@99.99.99-JaneDoe` is a valid-looking
775
+ // spec, and a semver prerelease identifier can encode anything — the same lesson the
776
+ // `/healthz` version taught in round 13.
777
+ //
778
+ // There is no trusted resolved version available at this call site, so the spec is not
779
+ // rendered at all. `code: ETARGET` plus this fact already tells a user their requested
780
+ // version does not exist, which is the diagnostic that matters.
781
+ notarget: () => "no matching version",
782
+ };
783
+
784
+ /**
785
+ * HTTP status lines carry a registry URL. Render it from parsed parts rather than echoing the
786
+ * line: a URL can embed userinfo (`https://Jane:pw@host/`) or a path, and the raw text also
787
+ * defeats the path test because `https:/` looks like a drive letter.
788
+ */
789
+ function npmHttpStatusValue(field: string, value: string): string | null {
790
+ const url = /\bhttps?:\/\/[^\s]+/.exec(value)?.[0];
791
+ if (!url) return `HTTP ${field}`;
792
+ let parsed: URL;
793
+ try { parsed = new URL(url); } catch { return `HTTP ${field}`; }
794
+ // Only hosts we can name in advance. A shape check (`^[\w.-]+$`) accepts
795
+ // `janedoe.example`, a numeric host, or a punycode host — an arbitrary hostname is a
796
+ // disclosure channel, not a diagnostic. Knowing it was the public registry versus "some
797
+ // other host" is the part that helps, and that fits in an allowlist.
798
+ return KNOWN_REGISTRY_HOSTS.has(parsed.hostname.toLowerCase()) && !parsed.username && !parsed.password
799
+ ? `HTTP ${field} from ${parsed.hostname.toLowerCase()}`
800
+ : `HTTP ${field}`;
801
+ }
802
+
803
+ /**
804
+ * Extract the diagnostic fields npm names explicitly.
805
+ *
806
+ * Each kept value still passes `withholdIfPathBearing` before it is used: a registry URL is
807
+ * fine, but `syscall` and friends are only safe by convention, and a convention is not a
808
+ * guarantee. Values are length-capped so a hostile responder cannot pad the record.
809
+ */
810
+ function npmDiagnosticFields(text: string): string[] {
811
+ const seen = new Map<string, string>();
812
+ for (const match of text.matchAll(NPM_FIELD_LINE)) {
813
+ const field = match[1]!.toLowerCase();
814
+ const value = match[2]!.trim();
815
+ if (seen.has(field) || !value || value.length > 160) continue;
816
+ // Every kept field is RENDERED from a validated value, never echoed. Allowlisting the field
817
+ // name alone left the value free-form, so `npm error syscall janedoe` walked straight
818
+ // through — the field was recognized and the value was never checked against anything.
819
+ const validate = NPM_FIELD_VALIDATORS[field];
820
+ const rendered = validate
821
+ ? validate(value)
822
+ : (/^(?:404|401|403|409|429)$/.test(field) ? npmHttpStatusValue(field, value) : null);
823
+ if (rendered === null) continue;
824
+ seen.set(field, rendered);
825
+ }
826
+ return [...seen].map(([field, value]) => `${field}: ${value}`);
827
+ }
828
+
829
+ /**
830
+ * Build a structured, path-free summary of a command's result.
831
+ *
832
+ * Only three things cross the boundary: how the process ended, how much it printed, and any
833
+ * recognized error codes. None of those can carry a filesystem path or an account name.
834
+ */
835
+ export function summarizeCommandOutput(
836
+ stdout: string,
837
+ stderr: string,
838
+ status: number | null,
839
+ signal: NodeJS.Signals | null,
840
+ ): string | null {
841
+ if (!stdout && !stderr && status === 0) return null;
842
+
843
+ const parts: string[] = [];
844
+ parts.push(signal ? `terminated by ${signal}` : `exit ${status ?? "null"}`);
845
+
846
+ // Read npm's own named fields rather than reproducing its text. This is what makes a failed
847
+ // update diagnosable again: `code: E404 · 404: 404 Not Found - GET https://registry...` tells
848
+ // a user exactly what happened, and none of it can be a local path.
849
+ const fields = npmDiagnosticFields(`${stderr}\n${stdout}`);
850
+ if (fields.length > 0) parts.push(...fields);
851
+
852
+ const bytes = Buffer.byteLength(stdout, "utf8") + Buffer.byteLength(stderr, "utf8");
853
+ if (bytes > 0) {
854
+ parts.push(fields.length > 0
855
+ ? `${bytes} bytes of full output withheld`
856
+ : `${bytes} bytes of output withheld (no recognized diagnostic fields)`);
857
+ }
858
+
859
+ return parts.join(" · ");
860
+ }
861
+
534
862
  /**
535
863
  * Tear down anything that would make `ocx start` exit 1 with "already running"
536
864
  * (service wrapper respawn, stale pidfile + live /healthz) before a pinned spawn.
@@ -598,7 +926,7 @@ function spawnDetachedStart(
598
926
  });
599
927
  child.once("error", err => {
600
928
  try {
601
- updateJob(job, {}, `Pinned start spawn error: ${err instanceof Error ? err.message : String(err)}`);
929
+ updateJob(job, {}, `Pinned start spawn error: ${withheldSummary(err)}`);
602
930
  } catch { /* best-effort */ }
603
931
  });
604
932
  // Foreground `ocx start` keeps the listen process; EADDRINUSE/ghost races exit quickly
@@ -1187,7 +1515,11 @@ async function defaultProbeProxyIdentity(
1187
1515
  if (!isOpencodexHealthz(body)) return null;
1188
1516
  return {
1189
1517
  pid: typeof body?.pid === "number" ? body.pid : null,
1190
- ...(typeof body?.version === "string" ? { version: body.version } : {}),
1518
+ // Validate the shape at the boundary where the value ENTERS, not where it is logged.
1519
+ // `/healthz` is answered by whatever is listening on that port, so a hostile or confused
1520
+ // responder can return any string here — and the restart-evidence reasons below
1521
+ // interpolate it into a persisted field. A version is a version or it is nothing.
1522
+ ...(isVersionLike(body?.version) ? { version: body.version } : {}),
1191
1523
  };
1192
1524
  } catch {
1193
1525
  return null;
@@ -1222,18 +1554,22 @@ export function npmSelfUpdateRestartEvidence(
1222
1554
  }
1223
1555
  if (livePid !== null) {
1224
1556
  if (expected !== null && identity.version && identity.version !== expected) {
1225
- return { ok: false, reason: `new pid but version ${identity.version} !== expected ${expected}` };
1557
+ // Never echo the REPORTED version: `/healthz` is answered by whatever holds the port,
1558
+ // and `2.7.41-JaneDoe` is valid semver. Say that it mismatched, and name only the
1559
+ // version we expected — which is ours.
1560
+ return { ok: false, reason: `new pid but reported version did not match expected ${expected}` };
1226
1561
  }
1227
1562
  return { ok: true, detail: `pid changed ${oldPid}→${livePid}` };
1228
1563
  }
1229
1564
  // Pre-update PID known but healthz omitted pid — only accept matching target version.
1230
- if (versionMatches) return { ok: true, detail: `version ${identity.version}` };
1565
+ // On a match the reported value equals `expected`, so render the trusted one.
1566
+ if (versionMatches) return { ok: true, detail: `version ${expected}` };
1231
1567
  return { ok: false, reason: "no PID in healthz and version did not match the update target" };
1232
1568
  }
1233
1569
 
1234
- if (versionMatches) return { ok: true, detail: `version ${identity.version}` };
1570
+ if (versionMatches) return { ok: true, detail: `version ${expected}` };
1235
1571
  if (expected !== null && identity.version && identity.version !== expected) {
1236
- return { ok: false, reason: `version ${identity.version} !== expected ${expected}` };
1572
+ return { ok: false, reason: `reported version did not match expected ${expected}` };
1237
1573
  }
1238
1574
  return { ok: false, reason: "no pre-update PID capture and no expected-version match" };
1239
1575
  }
@@ -1375,9 +1711,36 @@ async function confirmNpmExplicitRestart(
1375
1711
  return true;
1376
1712
  }
1377
1713
 
1378
- export async function runGuiUpdateWorker(jobId: string, channel: Channel, restart: boolean): Promise<void> {
1714
+ /**
1715
+ * Test seams for the GUI update worker.
1716
+ *
1717
+ * The cache pre-flight and the install/stop step were previously reached only through module
1718
+ * globals, so "the gate runs before the stop" could only be asserted by comparing source-string
1719
+ * positions — a test that stays green even if the call is unreachable. These make the ordering
1720
+ * observable: a failed pre-flight must leave `runCommand` untouched.
1721
+ */
1722
+ export interface GuiUpdateWorkerIo {
1723
+ cachePreflightFn?: () => { ok: boolean; reason: string };
1724
+ /** Force the resolved update target. A source checkout otherwise aborts before the npm branch. */
1725
+ checkForUpdateFn?: (channel: Channel) => ReturnType<typeof checkForUpdate>;
1726
+ /** Bypass the registry integrity probe, which runs before the cache gate and needs network. */
1727
+ integrityFn?: (version: string | null) => ReturnType<typeof checkUpdatePackageIntegrity>;
1728
+ runCommandFn?: (
1729
+ job: UpdateJobState,
1730
+ bin: string,
1731
+ args: string[],
1732
+ timeout: number,
1733
+ ) => { status: number | null; signal: NodeJS.Signals | null };
1734
+ }
1735
+
1736
+ export async function runGuiUpdateWorker(
1737
+ jobId: string,
1738
+ channel: Channel,
1739
+ restart: boolean,
1740
+ io: GuiUpdateWorkerIo = {},
1741
+ ): Promise<void> {
1379
1742
  let job = readUpdateJob(jobId);
1380
- const check = checkForUpdate(channel);
1743
+ const check = (io.checkForUpdateFn ?? checkForUpdate)(channel);
1381
1744
  const now = new Date().toISOString();
1382
1745
  // Capture the live listen target BEFORE the update command runs: the stop-first update
1383
1746
  // flow clears pid/runtime state, so this is the last moment the real port is knowable.
@@ -1422,7 +1785,7 @@ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restar
1422
1785
  // Pre-flight integrity metadata check (same lanes as the CLI): anomalous registry
1423
1786
  // metadata for a resolved version fails the job BEFORE anything is spawned or the
1424
1787
  // proxy is stopped; transient registry failure degrades to a logged skip.
1425
- const integrity = checkUpdatePackageIntegrity(check.latestVersion);
1788
+ const integrity = (io.integrityFn ?? checkUpdatePackageIntegrity)(check.latestVersion);
1426
1789
  if (integrity.ok === false) {
1427
1790
  updateJob(job, { status: "failed", error: integrity.reason });
1428
1791
  return;
@@ -1439,6 +1802,17 @@ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restar
1439
1802
  command: cmd.display,
1440
1803
  }, integrityLine);
1441
1804
 
1805
+ if (check.installer === "npm") {
1806
+ const cachePreflight = (io.cachePreflightFn ?? runNpmCachePreflight)();
1807
+ if (!cachePreflight.ok) {
1808
+ updateJob(job, {
1809
+ status: "failed",
1810
+ error: npmCachePreflightFailureMessage(cachePreflight.reason as NpmCachePreflightReason),
1811
+ }, "Update aborted before stopping the proxy because the npm cache pre-flight failed.");
1812
+ return;
1813
+ }
1814
+ }
1815
+
1442
1816
  if (process.platform === "win32") {
1443
1817
  try {
1444
1818
  const { getWindowsTrayStatus, startWindowsTray, stopWindowsTray } = await import("../tray/windows");
@@ -1455,7 +1829,7 @@ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restar
1455
1829
  } catch (error) {
1456
1830
  updateJob(job, {
1457
1831
  status: "failed",
1458
- error: `Could not stop the Windows tray; aborting before package replacement: ${error instanceof Error ? error.message : String(error)}`,
1832
+ error: `Could not stop the Windows tray; aborting before package replacement: ${withheldSummary(error)}`,
1459
1833
  });
1460
1834
  return;
1461
1835
  }
@@ -1466,7 +1840,7 @@ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restar
1466
1840
  - 대안 분석: (1) 서버에서 runUpdate 직접 호출: process.exit/stdio/실행 파일 교체 위험. (2) GUI에서 CLI 명령 안내만 제공: 자동 업데이트 UX 부족. (3) 숨은 worker가 Node launcher/Bun 전역 명령을 실행: 상태 추적과 안전한 재시작이 가능.
1467
1841
  - 선택 근거: 현재 CLI의 npm self-update 우회를 재사용하면서도 GUI 서버 요청 생명주기와 설치 작업을 분리할 수 있어 가장 안정적이다.
1468
1842
  */
1469
- const result = runLoggedCommand(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS);
1843
+ const result = (io.runCommandFn ?? runLoggedCommand)(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS);
1470
1844
  if (result.status !== 0) {
1471
1845
  if (trayWasRunning) {
1472
1846
  try {
@@ -1509,7 +1883,7 @@ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restar
1509
1883
  }
1510
1884
  updateJob(job, {
1511
1885
  status: "failed",
1512
- error: err instanceof Error ? err.message : String(err),
1886
+ error: withheldSummary(err),
1513
1887
  });
1514
1888
  }
1515
1889
  }
@@ -0,0 +1,47 @@
1
+ import type { spawnSync } from "node:child_process";
2
+
3
+ export type NpmCachePreflightReason =
4
+ | "cache_accessible"
5
+ | "cache_entry_foreign_owner"
6
+ | "cache_entry_inaccessible"
7
+ | "cache_path_malformed"
8
+ | "inspection_incomplete"
9
+ | "npm_config_failed"
10
+ | "npm_unavailable"
11
+ | "windows_skip"
12
+ | "worker_failed"
13
+ | "worker_output_malformed"
14
+ | "worker_timeout";
15
+
16
+ export interface NpmCachePreflightResult {
17
+ ok: boolean;
18
+ reason: NpmCachePreflightReason;
19
+ }
20
+
21
+ export interface NpmCacheInspectionOptions {
22
+ expectedUid?: number;
23
+ maxDepth?: number;
24
+ maxEntries?: number;
25
+ nowMs?: () => number;
26
+ /** Test seam: resolve a symlinked cache root. Defaults to realpathSync. */
27
+ realpathFn?: (path: string) => string;
28
+ /** Test seam: resolve an entry's owner uid. Defaults to the lstat result. */
29
+ uidOf?: (path: string, stat: { uid: number }) => number;
30
+ timeoutMs?: number;
31
+ }
32
+
33
+ export interface NpmCachePreflightOptions {
34
+ env?: NodeJS.ProcessEnv;
35
+ execPath?: string;
36
+ platform?: NodeJS.Platform;
37
+ spawnSyncFn?: typeof spawnSync;
38
+ timeoutMs?: number;
39
+ }
40
+
41
+ export function inspectNpmCacheDirectory(
42
+ cachePath: string,
43
+ options?: NpmCacheInspectionOptions,
44
+ ): NpmCachePreflightResult;
45
+
46
+ export function runNpmCachePreflight(options?: NpmCachePreflightOptions): NpmCachePreflightResult;
47
+ export function npmCachePreflightFailureMessage(reason: NpmCachePreflightReason): string;