@bitkyc08/opencodex 2.39.0 → 2.40.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 (144) hide show
  1. package/README.md +49 -21
  2. package/gui/dist/assets/index-BHe2rl_C.js +112 -0
  3. package/gui/dist/assets/index-CJSb3HPe.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/catalog.ts +140 -7
  7. package/src/adapters/cursor/discovery.ts +79 -87
  8. package/src/adapters/cursor/effort-map.ts +11 -0
  9. package/src/adapters/cursor/request-builder.ts +31 -4
  10. package/src/adapters/cursor.ts +17 -0
  11. package/src/adapters/openai-chat.ts +95 -9
  12. package/src/adapters/openai-responses.ts +73 -9
  13. package/src/bridge.ts +58 -13
  14. package/src/claude/auth-mode.ts +6 -9
  15. package/src/claude/gateway-cache.ts +23 -3
  16. package/src/claude/model-info.ts +13 -2
  17. package/src/cli/access.ts +30 -0
  18. package/src/cli/account-extended.ts +19 -10
  19. package/src/cli/capabilities.ts +40 -1
  20. package/src/cli/claude-agent-startup-sync.ts +3 -0
  21. package/src/cli/claude.ts +134 -36
  22. package/src/cli/connect.ts +226 -0
  23. package/src/cli/dispatch.ts +126 -21
  24. package/src/cli/gui-pair-client.ts +170 -0
  25. package/src/cli/gui.ts +87 -0
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/index.ts +76 -22
  28. package/src/cli/models-runtime-subcommands.ts +34 -0
  29. package/src/cli/models-runtime.ts +4 -0
  30. package/src/cli/models.ts +2 -1
  31. package/src/cli/opencode.ts +2 -1
  32. package/src/cli/provider-runtime.ts +31 -1
  33. package/src/cli/registry.ts +32 -2
  34. package/src/cli/runtime-api.ts +10 -1
  35. package/src/cli/status.ts +26 -0
  36. package/src/cli/system-command.ts +9 -3
  37. package/src/client/connect.ts +650 -0
  38. package/src/client/hub-client.ts +457 -0
  39. package/src/client/hub-relay.ts +288 -0
  40. package/src/client/machine-api.ts +139 -0
  41. package/src/client/machine-auth.ts +54 -0
  42. package/src/client/machine-listener.ts +143 -0
  43. package/src/client/runtime.ts +93 -0
  44. package/src/client/state.ts +175 -0
  45. package/src/clients/config-export.ts +16 -5
  46. package/src/codex/auth-api.ts +169 -15
  47. package/src/codex/auth-context.ts +61 -6
  48. package/src/codex/catalog/aggregation.ts +17 -13
  49. package/src/codex/catalog/metadata.ts +28 -0
  50. package/src/codex/catalog/provider-fetch.ts +38 -5
  51. package/src/codex/catalog.ts +1 -1
  52. package/src/codex/desired-state.ts +13 -3
  53. package/src/codex/inject.ts +212 -31
  54. package/src/codex/journal.ts +54 -6
  55. package/src/codex/main-account.ts +105 -33
  56. package/src/codex/native-main-claim.ts +23 -1
  57. package/src/codex/native-residue.ts +7 -0
  58. package/src/codex/reset-credit-auto-redeem.ts +237 -0
  59. package/src/combos/request.ts +15 -3
  60. package/src/combos/types.ts +12 -0
  61. package/src/config/paths.ts +15 -1
  62. package/src/config/provider-validation.ts +40 -0
  63. package/src/config.ts +447 -3
  64. package/src/generated/compatibility-version.json +224 -108
  65. package/src/images/artifacts.ts +73 -37
  66. package/src/images/fulfill.ts +6 -1
  67. package/src/images/index.ts +1 -1
  68. package/src/images/plan.ts +16 -2
  69. package/src/images/synthetic-tool.ts +5 -0
  70. package/src/images/xai-client.ts +34 -1
  71. package/src/integrations/cursor-detect.ts +133 -0
  72. package/src/integrations/cursor-seen.ts +31 -0
  73. package/src/lib/errors.ts +18 -0
  74. package/src/lib/gui-pair-capability.ts +104 -0
  75. package/src/lib/lab-live-route-production.ts +2 -1
  76. package/src/lib/process-control.ts +4 -3
  77. package/src/lib/service-secrets.ts +161 -1
  78. package/src/lib/windows-system-proxy.ts +115 -0
  79. package/src/oauth/index.ts +3 -2
  80. package/src/oauth/pool-settings-capability.ts +55 -0
  81. package/src/providers/api-keys.ts +3 -1
  82. package/src/providers/codex-capacity.ts +34 -4
  83. package/src/providers/derive.ts +10 -0
  84. package/src/providers/fastwire.ts +10 -2
  85. package/src/providers/key-store.ts +197 -0
  86. package/src/providers/model-rename-migration.ts +3 -0
  87. package/src/providers/openai-sidecar.ts +3 -3
  88. package/src/providers/quota.ts +91 -42
  89. package/src/providers/registry.ts +33 -3
  90. package/src/reasoning-effort.ts +32 -0
  91. package/src/remote/protocol.ts +109 -0
  92. package/src/responses/citation-markers.ts +101 -0
  93. package/src/responses/parser.ts +43 -3
  94. package/src/responses/spill-store.ts +9 -4
  95. package/src/router.ts +72 -8
  96. package/src/server/auth-cors.ts +37 -1
  97. package/src/server/gui-session.ts +449 -0
  98. package/src/server/gui-static.ts +24 -7
  99. package/src/server/images.ts +237 -10
  100. package/src/server/index.ts +356 -30
  101. package/src/server/management/agent-settings-routes.ts +7 -1
  102. package/src/server/management/api-key-rotation.ts +74 -0
  103. package/src/server/management/combo-routes.ts +18 -6
  104. package/src/server/management/config-routes.ts +22 -3
  105. package/src/server/management/context.ts +5 -1
  106. package/src/server/management/cursor-integration-routes.ts +98 -0
  107. package/src/server/management/logs-usage-routes.ts +2 -1
  108. package/src/server/management/model-routes.ts +79 -1
  109. package/src/server/management/model-rows.ts +23 -1
  110. package/src/server/management/oauth-account-routes.ts +135 -2
  111. package/src/server/management/provider-routes.ts +143 -9
  112. package/src/server/management/route-registry.ts +10 -0
  113. package/src/server/management/session-routes.ts +13 -0
  114. package/src/server/management/system-routes.ts +13 -1
  115. package/src/server/management-api.ts +8 -3
  116. package/src/server/management-auth.ts +124 -82
  117. package/src/server/models-capabilities.ts +124 -0
  118. package/src/server/ports.ts +7 -0
  119. package/src/server/proxy-liveness.ts +7 -0
  120. package/src/server/request-log.ts +11 -5
  121. package/src/server/responses/agent-task-recovery.ts +1 -0
  122. package/src/server/responses/codex-auth-error.ts +3 -1
  123. package/src/server/responses/collaboration.ts +26 -1
  124. package/src/server/responses/combo-stream-preflight.ts +25 -6
  125. package/src/server/responses/compact.ts +32 -12
  126. package/src/server/responses/context-overflow.ts +49 -0
  127. package/src/server/responses/core.ts +196 -12
  128. package/src/server/responses/fetch-helpers.ts +2 -1
  129. package/src/server/responses/outbound-body-guard.ts +110 -0
  130. package/src/server/responses/ws-upstream.ts +87 -6
  131. package/src/server/responses-self-named-namespace-scrub.ts +181 -0
  132. package/src/server/system-env.ts +78 -25
  133. package/src/service.ts +218 -42
  134. package/src/types/config.ts +134 -0
  135. package/src/types/provider.ts +54 -1
  136. package/src/types/request.ts +2 -0
  137. package/src/types.ts +5 -0
  138. package/src/update/job.ts +33 -5
  139. package/src/usage/cost.ts +32 -1
  140. package/src/usage/expected-prices.ts +16 -0
  141. package/src/usage/log.ts +6 -2
  142. package/src/usage/summary.ts +48 -6
  143. package/gui/dist/assets/index-D-lchsPw.js +0 -112
  144. package/gui/dist/assets/index-uvENYLin.css +0 -1
package/src/service.ts CHANGED
@@ -660,6 +660,23 @@ export function installedServiceListenPort(): number {
660
660
 
661
661
  export const SERVICE_INSTALL_HEALTH_MS = 20_000;
662
662
 
663
+ /**
664
+ * Windows gets a longer budget because its cold start does more before the
665
+ * listener exists: NTFS ACL hardening and previous-session journal recovery
666
+ * both run first, and #3009 recorded a service that bound a few seconds past
667
+ * the 20s deadline and then stayed healthy. Reporting that as a terminal
668
+ * repair failure is worse than waiting — the caller's fallback is to start a
669
+ * second proxy against a port that is about to be taken.
670
+ */
671
+ export const SERVICE_INSTALL_HEALTH_WINDOWS_MS = 45_000;
672
+
673
+ /** The health budget for the platform this is running on. */
674
+ export function serviceInstallHealthMs(
675
+ platform: NodeJS.Platform = process.platform,
676
+ ): number {
677
+ return platform === "win32" ? SERVICE_INSTALL_HEALTH_WINDOWS_MS : SERVICE_INSTALL_HEALTH_MS;
678
+ }
679
+
663
680
  /**
664
681
  * Whether a proxy actually answers on the port this install/start just produced.
665
682
  *
@@ -690,12 +707,23 @@ export async function confirmServiceServing(
690
707
  const now = deps.now ?? Date.now;
691
708
  const sleep = deps.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
692
709
  const probe = deps.probe ?? (async (p, h) => !!(await proxyIdentityAt(p, { hostname: h })));
693
- const deadline = now() + (deps.timeoutMs ?? SERVICE_INSTALL_HEALTH_MS);
710
+ const deadline = now() + (deps.timeoutMs ?? serviceInstallHealthMs());
711
+ let waited = false;
694
712
  for (;;) {
695
713
  if (await probe(port, hostname)) return { ok: true, port };
696
- if (now() >= deadline) return { ok: false, port };
714
+ if (now() >= deadline) break;
697
715
  await sleep(500);
716
+ waited = true;
698
717
  }
718
+ // The probe that ran last started before the deadline, so a service that binds
719
+ // during it is reported as dead (#3009). Knock once more after a short grace
720
+ // before calling it a failure. A zero budget means the caller asked not to
721
+ // wait, so it gets exactly the single probe it asked for and nothing more.
722
+ if (waited) {
723
+ await sleep(500);
724
+ if (await probe(port, hostname)) return { ok: true, port };
725
+ }
726
+ return { ok: false, port };
699
727
  }
700
728
 
701
729
  /**
@@ -707,18 +735,27 @@ export async function confirmServiceServing(
707
735
  * fall back to a direct proxy start rather than reporting a successful update over a
708
736
  * dead port.
709
737
  */
710
- async function reportServiceServing(
738
+ export async function reportServiceServing(
711
739
  verb: "installed" | "started" | "repaired",
712
740
  deps: Parameters<typeof confirmServiceServing>[0] = {},
713
741
  ): Promise<void> {
714
- const serving = await confirmServiceServing(deps);
742
+ const healthBudgetMs = deps.timeoutMs ?? serviceInstallHealthMs();
743
+ // Timed here rather than reported from the budget. confirmServiceServing knocks once
744
+ // more after a grace sleep whenever it waited at all, so the real wait is the budget
745
+ // plus that grace — and printing the budget states a number the run did not spend.
746
+ // What the reader is deciding is whether the service was still coming up, which is a
747
+ // judgement about elapsed time (#3009).
748
+ const now = deps.now ?? Date.now;
749
+ const startedAt = now();
750
+ const serving = await confirmServiceServing({ ...deps, timeoutMs: healthBudgetMs });
751
+ const waitedMs = Math.max(0, now() - startedAt);
715
752
  if (serving.ok) {
716
753
  console.log(`✅ opencodex service ${verb} and serving on port ${serving.port}.`);
717
754
  return;
718
755
  }
719
756
  console.error(
720
- `⚠️ Service ${verb}, but no proxy answered on port ${serving.port} within `
721
- + `${Math.trunc(SERVICE_INSTALL_HEALTH_MS / 1000)}s.\n`
757
+ `⚠️ Service ${verb}, but no proxy answered on port ${serving.port} after `
758
+ + `${Math.round(waitedMs / 1000)}s.\n`
722
759
  + ` The manager registered the job; that is not the same as serving.\n`
723
760
  + ` Log: ${serviceLogPath()}\n`
724
761
  + ` Meanwhile: ocx start (serves in the foreground)`,
@@ -1087,9 +1124,10 @@ export function evaluateWindowsSchedulerInstallVerification(inputs: {
1087
1124
  nativeStatus: "started" | "stopped" | "nonexistent" | "unknown";
1088
1125
  wscript?: string;
1089
1126
  launcher?: string;
1127
+ expectedUserId?: ExpectedWindowsTaskUserId | null;
1090
1128
  }): WindowsSchedulerInstallVerification {
1091
1129
  const registrationHealthy = inputs.xml.length > 0
1092
- && windowsTaskRegistrationHealthy(inputs.xml, inputs.wscript, inputs.launcher);
1130
+ && windowsTaskRegistrationHealthy(inputs.xml, inputs.wscript, inputs.launcher, inputs.expectedUserId);
1093
1131
  // Permanent invalidity: the XML IS published but violates the registration
1094
1132
  // contract — no amount of settling changes it. Empty/unreadable XML stays
1095
1133
  // transient (publication lag).
@@ -1804,18 +1842,16 @@ export function buildWindowsTaskXml(
1804
1842
  script = windowsServiceScriptPath(),
1805
1843
  launcher = windowsLauncherVbsPath(),
1806
1844
  attemptNonce?: string,
1807
- sessionTriggerUserId = cachedCurrentWindowsIdentity()?.name,
1845
+ sessionTriggerUserId = cachedCurrentWindowsIdentity()?.sid,
1808
1846
  ): string {
1809
1847
  const escapedWscript = taskXmlString(windowsWscript());
1810
1848
  // Escape the launcher path independently for the <Arguments> element; quoting it
1811
1849
  // keeps spaces intact, and /b (batch mode) suppresses script error popups.
1812
1850
  const escapedLauncherArgs = taskXmlString(`/b /nologo "${launcher}"`);
1813
1851
  // `UserId` is optional in the schema, and omitting it makes a SessionStateChangeTrigger
1814
- // fire for ANY account's session change. Scope it to the installing account when that
1815
- // account is already known. The lookup is never forced here: this builder is synchronous
1816
- // and its output is validated before registration, so a failed or unavailable lookup must
1817
- // degrade to the unscoped trigger rather than leave the task with no recovery at all.
1818
- // `LogonTrigger` above is unscoped for the same reason and predates this change.
1852
+ // fire for ANY account's session change. Production registration resolves and passes the
1853
+ // installing account SID explicitly; the optional parameter remains only for deterministic
1854
+ // builders/tests, and the live validator rejects an unscoped recovery trigger.
1819
1855
  const sessionUserIdElement = sessionTriggerUserId
1820
1856
  ? `\n <UserId>${taskXmlString(sessionTriggerUserId)}</UserId>`
1821
1857
  : "";
@@ -1866,6 +1902,34 @@ export function buildWindowsTaskXml(
1866
1902
  `;
1867
1903
  }
1868
1904
 
1905
+ type ExpectedWindowsTaskUserId = string | readonly string[];
1906
+
1907
+ function cachedWindowsTaskUserIds(): readonly string[] | null {
1908
+ const identity = cachedCurrentWindowsIdentity();
1909
+ return identity ? [identity.sid, identity.name] : null;
1910
+ }
1911
+
1912
+ function resolvedWindowsTaskSid(): string {
1913
+ let identity = cachedCurrentWindowsIdentity();
1914
+ if (!identity) {
1915
+ const principal = resolveCurrentWindowsPrincipal(WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS);
1916
+ identity = cachedCurrentWindowsIdentity();
1917
+ if (!identity && /^\*S-1-(?:\d+-)+\d+$/i.test(principal)) return principal.slice(1).toUpperCase();
1918
+ }
1919
+ if (!identity) throw new Error("Windows Task Scheduler identity could not be resolved.");
1920
+ return identity.sid;
1921
+ }
1922
+
1923
+ /** Render the exact UTF-16 task document published by production registration paths. */
1924
+ export function buildWindowsTaskXmlDocument(
1925
+ script = windowsServiceScriptPath(),
1926
+ launcher = windowsLauncherVbsPath(),
1927
+ attemptNonce?: string,
1928
+ sessionTriggerUserId = resolvedWindowsTaskSid(),
1929
+ ): string {
1930
+ return `\uFEFF${buildWindowsTaskXml(script, launcher, attemptNonce, sessionTriggerUserId)}`;
1931
+ }
1932
+
1869
1933
  function taskXmlSection(xml: string, tag: string): string {
1870
1934
  return new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, "i").exec(xml)?.[1] ?? "";
1871
1935
  }
@@ -1935,6 +1999,67 @@ function taskXmlDecodedValueEquals(xml: string, tag: string, expected: string):
1935
1999
  return taskXmlDecodeEntities(value).trim().toLowerCase() === expected.trim().toLowerCase();
1936
2000
  }
1937
2001
 
2002
+ /**
2003
+ * Characters a console code page substitutes when it cannot carry the original.
2004
+ * Windows writes `?` per unrepresentable character, some layers write U+FFFD, and a
2005
+ * few drop them entirely.
2006
+ */
2007
+ const CODE_PAGE_SUBSTITUTIONS = /^[?\uFFFD]*$/;
2008
+
2009
+ /**
2010
+ * Compare a value that OpenCodex itself wrote against what `schtasks /query /xml` read
2011
+ * back, tolerating ONLY the characters the console code page could not carry.
2012
+ *
2013
+ * `runFile` already reads the query as bytes, so this is not a spawn-decoding bug: the
2014
+ * conversion happens inside `schtasks` before the bytes exist. A profile named outside
2015
+ * the active code page — `C:\\Users\\김병준\\...` — comes back as `C:\\Users\\???\\...`, so an
2016
+ * exact comparison rejected a registration this process had just created correctly and
2017
+ * `ocx service install` rolled it back (#3064).
2018
+ *
2019
+ * The tolerance is deliberately narrow. Each unrepresentable RUN in the expected value
2020
+ * may match only a run of substitution characters — never arbitrary text, and never a
2021
+ * path separator. A wildcard as wide as `[^\\\\/]*` would leave a fully non-ASCII segment with
2022
+ * no anchors at all, so `C:\\Users\\김병준\\x.vbs` would match `C:\\Users\\Admin\\x.vbs` and this
2023
+ * process would adopt, repair, or delete another account's task. Accepting a foreign
2024
+ * live task is a worse failure than the rollback this fixes.
2025
+ */
2026
+ function taskXmlLossyValueEquals(reported: string, expected: string): boolean {
2027
+ const a = reported.trim().toLowerCase();
2028
+ const b = expected.trim().toLowerCase();
2029
+ if (a === b) return true;
2030
+ // Nothing unrepresentable in the expectation means there was nothing to mangle,
2031
+ // so any difference is a real one.
2032
+ if (!/[^\x00-\x7F]/.test(b)) return false;
2033
+ const parts = b.split(/([^\x00-\x7F]+)/);
2034
+ let rest = a;
2035
+ for (let i = 0; i < parts.length; i += 1) {
2036
+ const part = parts[i]!;
2037
+ if (i % 2 === 0) {
2038
+ // Literal ASCII run: it must be present verbatim, which is what keeps every
2039
+ // directory boundary and file name in the path verified.
2040
+ if (!rest.startsWith(part)) return false;
2041
+ rest = rest.slice(part.length);
2042
+ continue;
2043
+ }
2044
+ // Unrepresentable run: consume only substitution characters, and stop at the
2045
+ // next literal so a trailing run cannot swallow the remainder of the string.
2046
+ const next = parts[i + 1] ?? "";
2047
+ const end = next === "" ? rest.length : rest.indexOf(next);
2048
+ if (end < 0) return false;
2049
+ if (!CODE_PAGE_SUBSTITUTIONS.test(rest.slice(0, end))) return false;
2050
+ rest = rest.slice(end);
2051
+ }
2052
+ return rest === "";
2053
+ }
2054
+
2055
+ function taskXmlDecodedLossyValueEquals(xml: string, tag: string, expected: string): boolean {
2056
+ if (taskXmlHasPrefixedTag(xml, tag)) return false;
2057
+ if (taskXmlElementCount(xml, tag) !== 1) return false;
2058
+ const value = new RegExp(`<${tag}(?:\\s[^>]*?)?>([^<]*)<\\/${tag}>`, "i").exec(xml)?.[1];
2059
+ if (value === undefined) return false;
2060
+ return taskXmlLossyValueEquals(taskXmlDecodeEntities(value), expected);
2061
+ }
2062
+
1938
2063
  function taskXmlOptionalValueEquals(xml: string, tag: string, expected: string): boolean {
1939
2064
  // Check the prefixed form first: treating `<t:Enabled>false</t:Enabled>` as an
1940
2065
  // omission would turn an explicitly disabled task into a healthy one.
@@ -1968,7 +2093,10 @@ export function windowsTaskRegistrationOwnedByAttempt(xml: string, attemptNonce:
1968
2093
  * carrying one disabled trigger plus a different enabled one must not pass because the two
1969
2094
  * halves were found in unrelated elements.
1970
2095
  */
1971
- function windowsTaskHasSessionRecoveryTriggers(triggers: string, expectedUserId: string | undefined): boolean {
2096
+ function windowsTaskHasSessionRecoveryTriggers(
2097
+ triggers: string,
2098
+ expectedUserId: ExpectedWindowsTaskUserId | undefined,
2099
+ ): boolean {
1972
2100
  const scoped = triggers.match(/<SessionStateChangeTrigger(?:\s[^>]*)?>[\s\S]*?<\/SessionStateChangeTrigger>/gi) ?? [];
1973
2101
  return WINDOWS_SESSION_RECOVERY_STATE_CHANGES.every(stateChange =>
1974
2102
  scoped.some(element =>
@@ -1978,26 +2106,34 @@ function windowsTaskHasSessionRecoveryTriggers(triggers: string, expectedUserId:
1978
2106
  }
1979
2107
 
1980
2108
  /**
1981
- * A trigger's scope is acceptable when it is unscoped, or names the expected account.
2109
+ * A trigger's scope is acceptable only when it names the expected account exactly.
1982
2110
  *
1983
- * An unscoped trigger is accepted rather than rejected: the schema makes `UserId` optional,
1984
- * the pre-existing `LogonTrigger` is unscoped for the same reason, and rejecting it would
1985
- * mean an installation whose account lookup is unavailable loses session recovery entirely.
1986
- * An explicitly scoped trigger is accepted only when the current account is known and matches.
2111
+ * An unscoped recovery trigger is not identity proof. Production registration resolves a SID
2112
+ * before writing XML; a missing scope therefore means the fixed-name task is legacy or foreign
2113
+ * and must be refreshed from an exact legacy snapshot or preserved for manual review.
1987
2114
  * Treating an unknown expected identity as a wildcard would let a fresh status process accept a
1988
2115
  * task bound to another user's session and suppress the repair that should replace it.
1989
2116
  */
1990
- function windowsTaskTriggerScopeAcceptable(element: string, expectedUserId: string | undefined): boolean {
2117
+ function windowsTaskTriggerScopeAcceptable(
2118
+ element: string,
2119
+ expectedUserId: ExpectedWindowsTaskUserId | undefined,
2120
+ ): boolean {
1991
2121
  // A prefixed `<t:UserId>` is a real scope this validator cannot read: taskXmlElementCount()
1992
2122
  // counts only unprefixed tags, so without this the element below would look ABSENT and the
1993
2123
  // trigger would be accepted as unscoped even though it is bound to some other account.
1994
2124
  // Reject it outright rather than guess, and do so before the optional-field check.
1995
2125
  if (taskXmlHasPrefixedTag(element, "UserId")) return false;
1996
2126
  const userIdCount = taskXmlElementCount(element, "UserId");
1997
- if (userIdCount === 0) return true;
2127
+ if (userIdCount === 0) return false;
1998
2128
  if (userIdCount !== 1) return false;
1999
2129
  if (expectedUserId === undefined) return false;
2000
- return taskXmlDecodedValueEquals(element, "UserId", expectedUserId);
2130
+ // Scope is an identity boundary, unlike the launcher path. Newly generated tasks
2131
+ // use the locale-independent SID from cachedCurrentWindowsIdentity(), so there is
2132
+ // no reason to forgive code-page substitutions here. A lossy account-name compare
2133
+ // lets two non-ASCII users collapse to the same `???` value and can make repair
2134
+ // start another account's fixed-name task.
2135
+ const expectedValues = typeof expectedUserId === "string" ? [expectedUserId] : expectedUserId;
2136
+ return expectedValues.some(value => taskXmlDecodedValueEquals(element, "UserId", value));
2001
2137
  }
2002
2138
 
2003
2139
  /** Validate the stable OpenCodex action, principal, settings, and logon trigger. */
@@ -2005,6 +2141,7 @@ function windowsTaskRegistrationBaseHealthy(
2005
2141
  xml: string,
2006
2142
  wscript = windowsWscript(),
2007
2143
  launcher = windowsLauncherVbsPath(),
2144
+ allowLossyPaths = true,
2008
2145
  ): boolean {
2009
2146
  const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
2010
2147
  // taskXmlSection() takes the FIRST match and the schema allows arbitrary XML under
@@ -2030,8 +2167,15 @@ function windowsTaskRegistrationBaseHealthy(
2030
2167
  // quotes we wrote as `&quot;` back to literal `"` on export, so an escaped
2031
2168
  // needle never matched and a healthy task read as permanently stale (#608).
2032
2169
  // Case-insensitive: elevated `schtasks /create` may rewrite System32 casing.
2033
- && taskXmlDecodedValueEquals(action, "Command", wscript)
2034
- && taskXmlDecodedValueEquals(action, "Arguments", `/b /nologo "${launcher}"`);
2170
+ // Lossy on purpose: both name paths under the user profile, which the query
2171
+ // cannot carry when the profile is named outside the code page (#3064). Only
2172
+ // unrepresentable characters are forgiven; every ASCII segment and every
2173
+ // separator is still matched literally.
2174
+ && (allowLossyPaths
2175
+ ? taskXmlDecodedLossyValueEquals(action, "Command", wscript)
2176
+ && taskXmlDecodedLossyValueEquals(action, "Arguments", `/b /nologo "${launcher}"`)
2177
+ : taskXmlDecodedValueEquals(action, "Command", wscript)
2178
+ && taskXmlDecodedValueEquals(action, "Arguments", `/b /nologo "${launcher}"`));
2035
2179
  }
2036
2180
 
2037
2181
  /** Validate the security/lifecycle-critical fields of the registered scheduler task. */
@@ -2039,7 +2183,7 @@ export function windowsTaskRegistrationHealthy(
2039
2183
  xml: string,
2040
2184
  wscript = windowsWscript(),
2041
2185
  launcher = windowsLauncherVbsPath(),
2042
- expectedUserId: string | null = cachedCurrentWindowsIdentity()?.name ?? null,
2186
+ expectedUserId: ExpectedWindowsTaskUserId | null = cachedWindowsTaskUserIds(),
2043
2187
  ): boolean {
2044
2188
  const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
2045
2189
  const triggers = taskXmlSection(scrubbed, "Triggers");
@@ -2052,13 +2196,17 @@ export function windowsTaskRegistrationHealthy(
2052
2196
 
2053
2197
  /**
2054
2198
  * The only stale definition repair may replace automatically: the previous OpenCodex task
2055
- * shape whose action/principal/settings are still exact and which has no session triggers yet.
2199
+ * shape whose action/principal/settings are byte-exact and which has no session triggers yet.
2056
2200
  * Arbitrary unhealthy or partially modified fixed-name tasks are preserved for manual review.
2057
2201
  */
2058
- function windowsTaskRegistrationRefreshableLegacy(xml: string): boolean {
2202
+ function windowsTaskRegistrationRefreshableLegacy(
2203
+ xml: string,
2204
+ wscript = windowsWscript(),
2205
+ launcher = windowsLauncherVbsPath(),
2206
+ ): boolean {
2059
2207
  const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
2060
2208
  const triggers = taskXmlSection(scrubbed, "Triggers");
2061
- return windowsTaskRegistrationBaseHealthy(xml)
2209
+ return windowsTaskRegistrationBaseHealthy(xml, wscript, launcher, false)
2062
2210
  && taskXmlElementCount(triggers, "SessionStateChangeTrigger") === 0
2063
2211
  && !taskXmlHasPrefixedTag(triggers, "SessionStateChangeTrigger");
2064
2212
  }
@@ -2078,7 +2226,7 @@ export function readWindowsSchedulerXmlState(
2078
2226
  xml: string,
2079
2227
  wscript?: string,
2080
2228
  launcher?: string,
2081
- expectedUserId: string | null = cachedCurrentWindowsIdentity()?.name ?? null,
2229
+ expectedUserId: ExpectedWindowsTaskUserId | null = cachedWindowsTaskUserIds(),
2082
2230
  ): WindowsSchedulerXmlState {
2083
2231
  const installed = xml.length > 0;
2084
2232
  if (!installed) return { installed: false, enabled: false, registrationHealthy: false };
@@ -2242,7 +2390,11 @@ function writeWindowsSchedulerAssets(): void {
2242
2390
  // UTF-16LE + BOM: a BOM-less UTF-8 VBS mis-decodes non-ASCII (e.g. Korean) profile
2243
2391
  // paths on some WSH/codepage combinations — same contract as the task XML below.
2244
2392
  writeServiceAssetWithRetry(windowsLauncherVbsPath(), `\uFEFF${buildWindowsLauncherVbs(script)}`, "utf16le");
2245
- writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
2393
+ writeServiceAssetWithRetry(
2394
+ windowsTaskXmlPath(),
2395
+ buildWindowsTaskXmlDocument(script, windowsLauncherVbsPath()),
2396
+ "utf16le",
2397
+ );
2246
2398
  }
2247
2399
 
2248
2400
  const WINDOWS_SCHEDULER_STAGE_PREFIX = "opencodex-service-stage-";
@@ -2315,7 +2467,12 @@ export function stageWindowsSchedulerRegistrationXml(
2315
2467
  // document while UAC is pending; the file harden independently proves its identity.
2316
2468
  writeXml(
2317
2469
  xmlPath,
2318
- `\uFEFF${buildWindowsTaskXml(windowsServiceScriptPath(), windowsLauncherVbsPath(), attemptNonce)}`,
2470
+ buildWindowsTaskXmlDocument(
2471
+ windowsServiceScriptPath(),
2472
+ windowsLauncherVbsPath(),
2473
+ attemptNonce,
2474
+ resolvedWindowsTaskSid(),
2475
+ ),
2319
2476
  );
2320
2477
  hardenPath(xmlPath);
2321
2478
  ownedWindowsSchedulerStages.add(xmlPath);
@@ -2655,7 +2812,10 @@ export interface RepairServiceDeps {
2655
2812
  /** Publishes the captured registration only when the fixed task name remains absent. */
2656
2813
  restoreSchedulerIfAbsent?: (registeredXml: string) => Promise<void>;
2657
2814
  /** Resolves the account the registered triggers must match; null when it cannot be resolved. */
2658
- resolveExpectedUserId?: (registeredXml: string) => string | null;
2815
+ resolveExpectedUserId?: (registeredXml: string) => ExpectedWindowsTaskUserId | null;
2816
+ /** Exact scheduler action values used by validation; defaults to the installed paths. */
2817
+ schedulerWscript?: string;
2818
+ schedulerLauncher?: string;
2659
2819
  /** Test seam — defaults to process.platform so Linux CI cannot hit real installSystemd. */
2660
2820
  platform?: NodeJS.Platform;
2661
2821
  }
@@ -2756,11 +2916,26 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise<void>
2756
2916
  const expectedUserId = (deps.resolveExpectedUserId ?? resolveWindowsTaskDiagnosticUserId)(registeredXml);
2757
2917
  const registrationHealthy = windowsTaskRegistrationHealthy(
2758
2918
  registeredXml,
2759
- undefined,
2760
- undefined,
2919
+ deps.schedulerWscript,
2920
+ deps.schedulerLauncher,
2761
2921
  expectedUserId,
2762
2922
  );
2763
- if (!registrationHealthy && !windowsTaskRegistrationRefreshableLegacy(registeredXml)) {
2923
+ const expectedValues = expectedUserId === null
2924
+ ? []
2925
+ : typeof expectedUserId === "string" ? [expectedUserId] : expectedUserId;
2926
+ const preferredSid = expectedValues[0];
2927
+ const triggers = taskXmlSection(taskXmlWithoutCommentsAndCdata(registeredXml), "Triggers");
2928
+ // An exact legacy account name is safe to recognize, but rewrite it to the
2929
+ // locale-independent SID while repair already owns the mutation boundary.
2930
+ const identityUpgradeNeeded = registrationHealthy
2931
+ && preferredSid !== undefined
2932
+ && !windowsTaskHasSessionRecoveryTriggers(triggers, preferredSid);
2933
+ const refreshableLegacy = windowsTaskRegistrationRefreshableLegacy(
2934
+ registeredXml,
2935
+ deps.schedulerWscript,
2936
+ deps.schedulerLauncher,
2937
+ );
2938
+ if (!registrationHealthy && !refreshableLegacy) {
2764
2939
  const scopedButUnresolved = expectedUserId === null
2765
2940
  && taskXmlElementCount(
2766
2941
  taskXmlSection(taskXmlWithoutCommentsAndCdata(registeredXml), "Triggers"),
@@ -2781,7 +2956,7 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise<void>
2781
2956
  // Re-register only when the registered XML is actually stale, so the ordinary repair
2782
2957
  // stays free of `schtasks /create` and its UAC prompt.
2783
2958
  let startExpectedXml = registeredXml;
2784
- if (!registrationHealthy) {
2959
+ if (!registrationHealthy || identityUpgradeNeeded) {
2785
2960
  // The task was stopped above, so a failed replacement must not exit here: `/create /f`
2786
2961
  // can be rejected, elevation can be cancelled, and staging or verification can fail.
2787
2962
  // Any of those would leave a previously runnable proxy stopped and the user worse off
@@ -3855,7 +4030,7 @@ export function serviceStartableFromTray(service: ServiceDiagnostic): boolean {
3855
4030
  }
3856
4031
 
3857
4032
  export interface WindowsTaskDiagnosticIdentityDeps {
3858
- currentIdentity?: () => Readonly<{ name: string }> | null;
4033
+ currentIdentity?: () => Readonly<{ sid: string; name: string }> | null;
3859
4034
  resolvePrincipal?: (timeoutMs: number) => string;
3860
4035
  }
3861
4036
 
@@ -3867,10 +4042,10 @@ export interface WindowsTaskDiagnosticIdentityDeps {
3867
4042
  export function resolveWindowsTaskDiagnosticUserId(
3868
4043
  schedulerXml: string,
3869
4044
  deps: WindowsTaskDiagnosticIdentityDeps = {},
3870
- ): string | null {
4045
+ ): readonly string[] | null {
3871
4046
  const currentIdentity = deps.currentIdentity ?? cachedCurrentWindowsIdentity;
3872
4047
  const cached = currentIdentity();
3873
- if (cached) return cached.name;
4048
+ if (cached) return [cached.sid, cached.name];
3874
4049
 
3875
4050
  const scrubbed = taskXmlWithoutCommentsAndCdata(schedulerXml);
3876
4051
  const triggers = taskXmlSection(scrubbed, "Triggers");
@@ -3881,7 +4056,8 @@ export function resolveWindowsTaskDiagnosticUserId(
3881
4056
  } catch {
3882
4057
  return null;
3883
4058
  }
3884
- return currentIdentity()?.name ?? null;
4059
+ const resolved = currentIdentity();
4060
+ return resolved ? [resolved.sid, resolved.name] : null;
3885
4061
  }
3886
4062
 
3887
4063
  export interface WindowsServiceDiagnosticInputs {
@@ -3893,7 +4069,7 @@ export interface WindowsServiceDiagnosticInputs {
3893
4069
  */
3894
4070
  schedulerXml: string;
3895
4071
  /** Resolved effective account for explicit scheduler trigger scopes; null means unknown. */
3896
- schedulerExpectedUserId?: string | null;
4072
+ schedulerExpectedUserId?: ExpectedWindowsTaskUserId | null;
3897
4073
  /** Whether the on-disk service assets exist. A filesystem concern, not an XML one. */
3898
4074
  schedulerAssetsPresent: boolean;
3899
4075
  nativeStatus: "started" | "stopped" | "nonexistent" | "unknown";
@@ -3905,7 +4081,7 @@ export interface WindowsServiceDiagnosticInputs {
3905
4081
 
3906
4082
  export function deriveWindowsServiceDiagnostic(inputs: WindowsServiceDiagnosticInputs): ServiceDiagnostic {
3907
4083
  const expectedUserId = inputs.schedulerExpectedUserId === undefined
3908
- ? cachedCurrentWindowsIdentity()?.name ?? null
4084
+ ? cachedWindowsTaskUserIds()
3909
4085
  : inputs.schedulerExpectedUserId;
3910
4086
  const schedulerState = readWindowsSchedulerXmlState(
3911
4087
  inputs.schedulerXml,
@@ -219,6 +219,14 @@ export interface OcxApiKeyEntry {
219
219
  name: string;
220
220
  key: string;
221
221
  createdAt: string;
222
+ pendingRotation?: OcxPendingApiKeyRotation;
223
+ }
224
+
225
+ export interface OcxPendingApiKeyRotation {
226
+ id: string;
227
+ key: string;
228
+ createdAt: string;
229
+ expiresAt: string;
222
230
  }
223
231
 
224
232
  /**
@@ -244,8 +252,87 @@ export interface OcxConfigRebaseProvenance {
244
252
  deletedTopLevelKeys: string[];
245
253
  }
246
254
 
255
+ export type OcxRuntimeRole = "standalone" | "hub" | "client";
256
+
257
+ export interface OcxHubConfig {
258
+ /** Canonical browser-reachable management origin advertised by a hub. */
259
+ managementPublicOrigin?: string;
260
+ /**
261
+ * Optional management-only listener for a local HTTPS frontend such as Tailscale Serve.
262
+ * The hostname is deliberately not configurable: when enabled the socket is always bound
263
+ * to 127.0.0.1, and only GUI, session-bootstrap, and management API routes are admitted.
264
+ */
265
+ managementIngress?:
266
+ | { enabled: false }
267
+ | { enabled: true; port: number };
268
+ }
269
+
270
+ export interface OcxRemoteGuiConfig {
271
+ /** Exact Tailscale login identities permitted to receive an automatic remote GUI session. */
272
+ allowedTailscaleUsers?: string[];
273
+ /**
274
+ * Retired. Once permitted a one-time pairing exchange over non-loopback plaintext HTTP.
275
+ *
276
+ * Still parsed so an existing config file keeps loading, but it grants nothing: a pairing
277
+ * grant now crosses loopback or authenticated HTTPS only. A persisted `true` is reported
278
+ * once and otherwise ignored. Kept in the type rather than deleted because the schema is
279
+ * strict — dropping the key outright would make an older config fail to load entirely,
280
+ * which is a worse outcome than ignoring one retired field.
281
+ *
282
+ * @deprecated has no effect; remove it from your config.
283
+ */
284
+ allowInsecureHttp?: boolean;
285
+ }
286
+
287
+ export type OcxConnectedClientId = "codex" | "claude";
288
+
289
+ export interface OcxClientConnectionConfig {
290
+ serverUrl: string;
291
+ managementUrl: string;
292
+ managementTransport: "direct" | "relay";
293
+ selectedClients: OcxConnectedClientId[];
294
+ tokenEnv: "OPENCODEX_API_AUTH_TOKEN";
295
+ apiKeyId: string;
296
+ tokenFingerprint: string;
297
+ protocolVersion: 1;
298
+ connectedAt: string;
299
+ /**
300
+ * sha256/base64url of the catalog bytes this connection wrote, used to tell "still ours"
301
+ * from "edited or replaced" before removing the file on disconnect.
302
+ *
303
+ * Our own hash rather than the hub's ETag: /v1/catalog emits no validator, and this was
304
+ * always an ownership check on local bytes rather than a cache concern.
305
+ */
306
+ catalogFingerprint?: string;
307
+ /**
308
+ * The catalog that was on disk before connect overwrote it, base64-encoded, or the
309
+ * empty string when there was none.
310
+ *
311
+ * Durable because disconnect runs in a different process than connect: an in-memory
312
+ * snapshot only covers a connect that fails and rolls back on the spot. Without this,
313
+ * disconnect deletes the remote catalog and reports a restored native state while the
314
+ * user's own catalog is simply gone.
315
+ */
316
+ priorCatalog?: string;
317
+ catalogSyncedAt?: string;
318
+ pendingOperation?: {
319
+ kind: "rotate";
320
+ rotationId: string;
321
+ newKeyIssuedAt: string;
322
+ oldKeyBackupPath: string;
323
+ };
324
+ }
325
+
247
326
  export interface OcxConfig {
248
327
  port: number;
328
+ /** Runtime topology role. Absence preserves the historical standalone behavior. */
329
+ runtimeRole?: OcxRuntimeRole;
330
+ /** Hub-only public management metadata. Presence is inert outside the hub role. */
331
+ hub?: OcxHubConfig;
332
+ /** Opt-in remote dashboard issuance policy. Presence is inert outside the hub role. */
333
+ remoteGui?: OcxRemoteGuiConfig;
334
+ /** Remote-hub client state. The admission secret is stored only in service-api-token. */
335
+ client?: OcxClientConnectionConfig;
249
336
  /** Opt in to one identical-turn retry when a Responses completion has no text or tool call. */
250
337
  emptyCompletionRetry?: boolean;
251
338
  /**
@@ -511,6 +598,10 @@ export interface OcxConfig {
511
598
  * Outbound HTTP(S) proxy URL for provider requests (e.g. "http://user:pass@proxy:8080", or
512
599
  * "${HTTPS_PROXY}"-style env reference). Mirrored into HTTP_PROXY/HTTPS_PROXY at startup when
513
600
  * those are unset — Bun's fetch honors them for all outbound calls; localhost is excluded.
601
+ * The literal `"auto"` reads the Windows WinINET static proxy (`ProxyEnable`/`ProxyServer`)
602
+ * once at process start; on other platforms, or when the system proxy is off, SOCKS-only,
603
+ * or unreadable, it degrades to direct egress with one log line (#1525). PAC/WPAD and live
604
+ * changes are not followed.
514
605
  */
515
606
  proxy?: string;
516
607
  /**
@@ -543,6 +634,13 @@ export interface OcxConfig {
543
634
  codexAutoStart?: boolean;
544
635
  /** Restore an installed shim after a stable external Codex update replaces it. Default true. */
545
636
  codexShimAutoRestore?: boolean;
637
+ /**
638
+ * Opt-in authless Codex Desktop routing (#1107). On a loopback bind, inject the dedicated
639
+ * `[model_providers.opencodex]` table with `requires_openai_auth = false` instead of the root
640
+ * `openai_base_url` override, so Desktop opens without a ChatGPT login. Default off; ignored on
641
+ * non-loopback binds, whose admission token contract is unchanged.
642
+ */
643
+ codexDesktopAuthless?: boolean;
546
644
  /**
547
645
  * Compatibility mode: temporarily rewrite Codex resume-history metadata while the proxy is active
548
646
  * so Codex App can show old OpenAI chats and opencodex-created exec chats under its default
@@ -600,6 +698,14 @@ export interface OcxConfig {
600
698
  * whole config.
601
699
  */
602
700
  showCodexSparkQuota?: boolean;
701
+ /**
702
+ * Opt-in auto-redemption of a main-account Codex reset credit shortly before it expires
703
+ * (#822). Default off. `leadTimeMinutes` (1–60, default 10) is how long before
704
+ * `expires_at` the redeem is attempted; the credit list is re-read upstream right before
705
+ * every dispatch and the request id is journaled first, so a manual redeem or a crash never
706
+ * spends a second credit. A malformed value reads as off.
707
+ */
708
+ resetCreditAutoRedeem?: { enabled?: boolean; leadTimeMinutes?: number };
603
709
  /** Active pool account id for next session. undefined = main (passthrough as-is). */
604
710
  activeCodexAccountId?: string;
605
711
  /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */
@@ -615,6 +721,17 @@ export interface OcxConfig {
615
721
  * Default 0 (disabled); range 0..20. The circuit never counts timeouts or HTTP responses.
616
722
  */
617
723
  upstreamHostCircuitThreshold?: number;
724
+ /**
725
+ * Opt-in ceiling, in bytes, for a serialized native Responses **passthrough** body. When the
726
+ * built body exceeds it OpenCodex refuses locally instead of sending, naming the size and any
727
+ * embedded image payload. Translated adapter paths are not covered.
728
+ *
729
+ * Omitted or 0 = disabled, which is the default: no implicit ceiling is inferred for any
730
+ * destination. The only measured limit in this codebase is the WebSocket create-frame size,
731
+ * and the same body still succeeds over HTTP SSE, so a default here would refuse requests
732
+ * that work today — on Azure and custom Responses gateways as well, whose limits are unknown.
733
+ */
734
+ maxUpstreamBodyBytes?: number;
618
735
  /**
619
736
  * Opt-in Anthropic OAuth account pool (#294). Default OFF.
620
737
  * Failover on 429 + sticky affinity; new sessions may pick lowest known 5h usage.
@@ -669,6 +786,18 @@ export type OcxAccountPoolQuotaWindow = "five-hour" | "weekly" | "max-utilizatio
669
786
  export type OcxComboStrategy = "failover" | "round-robin" | "random" | "least-used" | "reset-window";
670
787
  export type OcxComboDefaultEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultra";
671
788
 
789
+ /**
790
+ * How a combo derives the reasoning ladder it publishes to the picker.
791
+ *
792
+ * `strict` (default) intersects every advertised ladder, so a target that explicitly
793
+ * advertises no effort control (`reasoningEfforts: []`) empties the combo's picker.
794
+ * `adaptive` excludes those empty ladders from the published intersection, keeping the
795
+ * control usable for a mixed-capability group. Unknown (`undefined`) ladders stay
796
+ * wildcards in both modes. Dispatch is unchanged: each concrete target still resolves
797
+ * its own effort at request time.
798
+ */
799
+ export type OcxComboReasoningEffortMode = "strict" | "adaptive";
800
+
672
801
  export interface OcxComboTarget {
673
802
  provider: string;
674
803
  model: string;
@@ -684,6 +813,11 @@ export interface OcxComboConfig {
684
813
  stickyLimit?: number;
685
814
  /** Used when the client omits reasoning.effort. null/omitted leaves the target default unchanged. */
686
815
  defaultEffort?: OcxComboDefaultEffort | null;
816
+ /**
817
+ * Picker-ladder derivation policy. Omitted / `"strict"` keeps the legacy rule where an
818
+ * explicitly empty target ladder suppresses the whole combo's effort control.
819
+ */
820
+ reasoningEffortMode?: OcxComboReasoningEffortMode;
687
821
  /**
688
822
  * Disable image input even when every target supports it.
689
823
  * Omitted / `"auto"` keeps automatic capability derivation (default: enabled when