@bitkyc08/opencodex 2.82.0 → 2.84.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 (266) hide show
  1. package/gui/dist/assets/App-CZtqiGcw.js +56 -0
  2. package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
  3. package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
  4. package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
  5. package/gui/dist/assets/{quota-summary-DTBC8n7-.js → quota-summary-BbEeRfPY.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +2 -2
  8. package/src/adapters/coding-agent/protocol.ts +15 -8
  9. package/src/adapters/cursor/catalog.ts +2 -2
  10. package/src/adapters/cursor/effort-map.ts +1 -1
  11. package/src/adapters/devin/live-models.ts +2 -2
  12. package/src/adapters/google-truncation.ts +22 -0
  13. package/src/adapters/google.ts +7 -3
  14. package/src/adapters/ollama-native.ts +47 -15
  15. package/src/adapters/openai-chat/messages.ts +8 -4
  16. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  17. package/src/adapters/openai-responses/passthrough.ts +5 -0
  18. package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
  19. package/src/adapters/openai-responses/reasoning.ts +11 -1
  20. package/src/adapters/openai-responses/request-strips.ts +10 -2
  21. package/src/adapters/qoder/adapter.ts +16 -14
  22. package/src/bridge/errors.ts +26 -1
  23. package/src/bridge.ts +1 -1
  24. package/src/claude/context-windows.ts +57 -4
  25. package/src/claude/desktop-picker.ts +1 -1
  26. package/src/claude/intercept/connect-proxy.ts +10 -3
  27. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  28. package/src/claude/intercept/picker-listener.ts +49 -1
  29. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  30. package/src/claude/intercept/settings.ts +101 -9
  31. package/src/cli/account-api.ts +65 -0
  32. package/src/cli/account-auth.ts +44 -12
  33. package/src/cli/account.ts +41 -5
  34. package/src/cli/capabilities-access-remote.ts +8 -0
  35. package/src/cli/capabilities-base.ts +13 -1
  36. package/src/cli/catalog-command-result.ts +2 -1
  37. package/src/cli/claude.ts +15 -6
  38. package/src/cli/config-command.ts +8 -2
  39. package/src/cli/debug.ts +8 -0
  40. package/src/cli/dispatch.ts +16 -3
  41. package/src/cli/export-command.ts +14 -1
  42. package/src/cli/hub-disable.ts +99 -0
  43. package/src/cli/hub.ts +4 -2
  44. package/src/cli/index.ts +13 -4
  45. package/src/cli/link-child.ts +104 -0
  46. package/src/cli/link-enrollment.ts +96 -0
  47. package/src/cli/link.ts +39 -24
  48. package/src/cli/observe-stream.ts +6 -1
  49. package/src/cli/observe.ts +10 -2
  50. package/src/cli/provider.ts +12 -0
  51. package/src/cli/registry.ts +5 -4
  52. package/src/cli/resolve.ts +1 -1
  53. package/src/cli/runtime-api.ts +27 -2
  54. package/src/cli/status.ts +104 -20
  55. package/src/client/connect.ts +39 -4
  56. package/src/client/hub-client.ts +14 -7
  57. package/src/client/hub-state.ts +25 -14
  58. package/src/client/link-home.ts +248 -0
  59. package/src/client/link-management-relay.ts +61 -0
  60. package/src/client/link-relay.ts +6 -1
  61. package/src/client/link-status.ts +14 -0
  62. package/src/client/machine-api.ts +14 -7
  63. package/src/client/machine-listener.ts +29 -4
  64. package/src/clients/config-export/model-metadata.ts +16 -0
  65. package/src/clients/config-export/zcode-store.ts +46 -11
  66. package/src/clients/config-export/zcode.ts +3 -5
  67. package/src/clients/config-export.ts +47 -17
  68. package/src/clients/omo-role-models.ts +6 -3
  69. package/src/codex/agent-role-models.ts +26 -8
  70. package/src/codex/auth-api/account-list.ts +17 -2
  71. package/src/codex/auth-api/account-selection.ts +17 -7
  72. package/src/codex/auth-api/pool-mode-gate.ts +4 -5
  73. package/src/codex/auth-api/routes.ts +5 -1
  74. package/src/codex/auth-context.ts +8 -3
  75. package/src/codex/autostart-health.ts +3 -1
  76. package/src/codex/catalog/bundled.ts +5 -1
  77. package/src/codex/catalog/effort.ts +2 -2
  78. package/src/codex/catalog/metadata.ts +4 -2
  79. package/src/codex/catalog/native-models.ts +17 -3
  80. package/src/codex/catalog/provider-models.ts +25 -0
  81. package/src/codex/desktop-app/darwin.ts +1 -1
  82. package/src/codex/desktop-app/handoff.ts +1 -1
  83. package/src/codex/desktop-app/linux.ts +1 -1
  84. package/src/codex/desktop-app/lock.ts +1 -1
  85. package/src/codex/desktop-app/types.ts +1 -1
  86. package/src/codex/desktop-app/windows.ts +1 -1
  87. package/src/codex/desktop-app-restart.ts +1 -1
  88. package/src/codex/main-account.ts +6 -7
  89. package/src/codex/native-profile-startup.ts +4 -1
  90. package/src/codex/prompt-journal.ts +20 -2
  91. package/src/codex/prompt-layers/encoding.ts +49 -0
  92. package/src/codex/prompt-layers/import-source.ts +30 -0
  93. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  94. package/src/codex/prompt-layers/toml-read.ts +52 -4
  95. package/src/codex/prompt-layers.ts +383 -20
  96. package/src/codex/prompt-lock-claim.ts +13 -3
  97. package/src/codex/prompt-lock-evidence.ts +3 -3
  98. package/src/codex/prompt-lock-owner.ts +14 -6
  99. package/src/codex/prompt-text-probe.ts +34 -5
  100. package/src/codex/quota.ts +13 -6
  101. package/src/codex/role-auto-assign.ts +2 -5
  102. package/src/codex/routing/active-account.ts +21 -0
  103. package/src/codex/routing.ts +1 -1
  104. package/src/codex/runtime.ts +196 -8
  105. package/src/codex/shim-overlay.ts +8 -4
  106. package/src/codex/shim.ts +1 -1
  107. package/src/combos/failover.ts +3 -0
  108. package/src/config/api-key-reconcile.ts +34 -0
  109. package/src/config/diagnostics.ts +16 -0
  110. package/src/config/feature-flags.ts +5 -0
  111. package/src/config/live-reconcile.ts +43 -29
  112. package/src/config/live-topology.ts +29 -0
  113. package/src/config/load-degrade.ts +2 -1
  114. package/src/config/paths.ts +3 -0
  115. package/src/config/salvage.ts +3 -1
  116. package/src/config/schema/config-schema.ts +2 -0
  117. package/src/generated/compatibility-version.json +373 -217
  118. package/src/grok/grpc-web.ts +23 -1
  119. package/src/grok/inject.ts +245 -221
  120. package/src/grok/reset-coupon-ledger.ts +293 -57
  121. package/src/grok/reset-coupons.ts +15 -6
  122. package/src/integrations/cursor-detect.ts +1 -1
  123. package/src/integrations/mutation-plan.ts +13 -7
  124. package/src/integrations/registry.ts +45 -11
  125. package/src/integrations/state.ts +19 -7
  126. package/src/integrations/target.ts +42 -5
  127. package/src/integrations/writer.ts +14 -6
  128. package/src/lib/app-owned-memory-stores.ts +4 -0
  129. package/src/lib/app-owned-memory.ts +11 -0
  130. package/src/lib/errors.ts +25 -0
  131. package/src/lib/json-byte-size.ts +31 -0
  132. package/src/lib/optional-shutdown-hooks.ts +35 -0
  133. package/src/lib/outbound-credential-registry.ts +210 -0
  134. package/src/lib/passive-read.ts +35 -0
  135. package/src/lib/redact.ts +2 -2
  136. package/src/lib/shadow-call.ts +44 -5
  137. package/src/link/codex-settings.ts +155 -0
  138. package/src/link/management-routes.ts +89 -0
  139. package/src/link/remote-probe.ts +38 -0
  140. package/src/link/ssh-argv.ts +26 -5
  141. package/src/link/ssh-runner.ts +58 -27
  142. package/src/link/status-projection.ts +22 -4
  143. package/src/link/supervisor.ts +144 -49
  144. package/src/link/tunnel-state.ts +11 -4
  145. package/src/oauth/anthropic-continuity.ts +4 -3
  146. package/src/oauth/anthropic-identity.ts +13 -2
  147. package/src/oauth/anthropic-model-quota.ts +13 -1
  148. package/src/oauth/anthropic-routing.ts +230 -23
  149. package/src/oauth/anthropic.ts +18 -4
  150. package/src/oauth/google-antigravity.ts +24 -7
  151. package/src/oauth/index.ts +25 -16
  152. package/src/oauth/kiro-account-identity.ts +106 -0
  153. package/src/oauth/kiro.ts +48 -5
  154. package/src/oauth/meta-muse-device.ts +1 -1
  155. package/src/oauth/plan.ts +29 -0
  156. package/src/oauth/store.ts +40 -3
  157. package/src/oauth/types.ts +3 -1
  158. package/src/plugins/loader.ts +11 -0
  159. package/src/providers/anthropic-billing-ownership.ts +74 -0
  160. package/src/providers/anthropic-billing-recovery.ts +31 -0
  161. package/src/providers/anthropic-billing-refusal.ts +55 -0
  162. package/src/providers/anthropic-fast.ts +1 -1
  163. package/src/providers/anthropic-reset-admission.ts +62 -0
  164. package/src/providers/anthropic-reset-grant-ledger.ts +29 -7
  165. package/src/providers/api-key-resolve.ts +17 -2
  166. package/src/providers/devin-cli-authmode-migration.ts +1 -1
  167. package/src/providers/devin-provider-merge-migration.ts +1 -1
  168. package/src/providers/github-copilot-auto.ts +169 -0
  169. package/src/providers/key-failover.ts +52 -5
  170. package/src/providers/model-presets.ts +1 -1
  171. package/src/providers/model-rename-startup.ts +5 -3
  172. package/src/providers/muse-subscription-usage.ts +1 -1
  173. package/src/providers/quota/account-cache.ts +11 -0
  174. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  175. package/src/providers/quota/report-cache.ts +14 -0
  176. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  177. package/src/providers/quota.ts +4 -1
  178. package/src/providers/registry/entries-core.ts +20 -20
  179. package/src/providers/registry/entries-extended.ts +4 -1
  180. package/src/providers/registry/model-seeds.ts +20 -3
  181. package/src/providers/request-pacing.ts +9 -0
  182. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  183. package/src/providers/xai-fast-model.ts +1 -1
  184. package/src/responses/compaction.ts +14 -0
  185. package/src/responses/parser.ts +7 -1
  186. package/src/server/auth-cors.ts +42 -0
  187. package/src/server/configured-credentials.ts +194 -0
  188. package/src/server/inbound-body-admission.ts +24 -7
  189. package/src/server/index/link-listener.ts +30 -17
  190. package/src/server/index/link-management.ts +58 -0
  191. package/src/server/index/optional-listeners.ts +5 -1
  192. package/src/server/index/serve-options.ts +21 -2
  193. package/src/server/index.ts +1 -1
  194. package/src/server/lifecycle.ts +33 -5
  195. package/src/server/management/agent-settings-routes.ts +28 -10
  196. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  197. package/src/server/management/codex-agent-role-routes.ts +19 -6
  198. package/src/server/management/codex-prompt-routes.ts +136 -5
  199. package/src/server/management/codex-role-auto-assign.ts +1 -1
  200. package/src/server/management/grok-coupon-routes.ts +333 -57
  201. package/src/server/management/link-enrollment.ts +192 -0
  202. package/src/server/management/link-route-state.ts +185 -0
  203. package/src/server/management/link-routes.ts +92 -390
  204. package/src/server/management/model-rows.ts +3 -1
  205. package/src/server/management/provider-overwrite-carry.ts +10 -0
  206. package/src/server/management/provider-routes.ts +1 -0
  207. package/src/server/management/route-registry.ts +7 -5
  208. package/src/server/management/shared.ts +3 -0
  209. package/src/server/management-api.ts +3 -1
  210. package/src/server/messages-native.ts +68 -42
  211. package/src/server/relay-eager.ts +4 -0
  212. package/src/server/relay.ts +18 -7
  213. package/src/server/request-log-failure-attribution.ts +3 -0
  214. package/src/server/request-log-filter.ts +6 -1
  215. package/src/server/request-log-terminal-status.ts +243 -0
  216. package/src/server/request-log.ts +15 -118
  217. package/src/server/responses/adapter-continuation.ts +9 -1
  218. package/src/server/responses/adapter-dispatch.ts +293 -81
  219. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  220. package/src/server/responses/codex-ws-exchange.ts +147 -6
  221. package/src/server/responses/codex-ws-wire.ts +203 -0
  222. package/src/server/responses/collaboration.ts +36 -7
  223. package/src/server/responses/compact.ts +129 -50
  224. package/src/server/responses/context-overflow.ts +13 -1
  225. package/src/server/responses/core-codex-account.ts +105 -1
  226. package/src/server/responses/core-combo-failure.ts +14 -0
  227. package/src/server/responses/core-combo.ts +7 -2
  228. package/src/server/responses/core-errors.ts +29 -1
  229. package/src/server/responses/core-opaque-recovery.ts +5 -5
  230. package/src/server/responses/core-options.ts +2 -0
  231. package/src/server/responses/core.ts +6 -6
  232. package/src/server/responses/deferred-collab-surface.ts +158 -0
  233. package/src/server/responses/deferred-send-reporter.ts +25 -0
  234. package/src/server/responses/empty-completion-guard.ts +18 -2
  235. package/src/server/responses/fetch-helpers.ts +12 -1
  236. package/src/server/responses/jev-model-invoke.ts +1 -1
  237. package/src/server/responses/passthrough-delivery.ts +4 -1
  238. package/src/server/responses/passthrough-dispatch.ts +130 -64
  239. package/src/server/responses/passthrough-execution.ts +3 -3
  240. package/src/server/responses/request-prepare.ts +6 -4
  241. package/src/server/responses/request-transport.ts +131 -13
  242. package/src/server/responses/run-turn-execution.ts +13 -5
  243. package/src/server/responses/sidecar-execution.ts +16 -2
  244. package/src/server/responses/spend-storage-error.ts +33 -0
  245. package/src/server/responses/ws-upstream.ts +9 -1
  246. package/src/server/startup-action-control.ts +22 -2
  247. package/src/server/startup-health-cache.ts +4 -0
  248. package/src/server/system-env-shell.ts +7 -2
  249. package/src/server/system-env.ts +11 -1
  250. package/src/service/cli.ts +5 -1
  251. package/src/service/desktop-startup.ts +147 -34
  252. package/src/service/desktop-supervision.d.mts +27 -1
  253. package/src/service/desktop-supervision.mjs +111 -5
  254. package/src/service/health-sentinel-launch.ts +176 -0
  255. package/src/service/health-sentinel-main.ts +447 -0
  256. package/src/service/health-sentinel-policy.ts +241 -0
  257. package/src/service/health-sentinel-probe.ts +54 -0
  258. package/src/service/health-sentinel-windows.ts +87 -0
  259. package/src/service/linux-linger.ts +20 -0
  260. package/src/types/config.ts +5 -0
  261. package/src/types/request.ts +5 -0
  262. package/src/usage/expected-prices.ts +5 -1
  263. package/src/usage/log.ts +19 -1
  264. package/gui/dist/assets/App-Ds_qL7o8.js +0 -54
  265. package/gui/dist/assets/Tray-BqHF5Trd.js +0 -1
  266. package/gui/dist/assets/index-D3FBCNiP.js +0 -94
@@ -1,3 +1,6 @@
1
+ import { fetchCopilotAutoModels, copilotPickerModels, copilotRequiresAuto } from "../../providers/github-copilot-auto";
2
+ import { resolveGithubCopilotTransport } from "../../providers/github-copilot-transport";
3
+ import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot";
1
4
  import { isAnthropicOAuthInstance } from "../../providers/anthropic-instance";
2
5
  import { effectiveProviderAlias, effectiveProviderAliasDecision } from "../../providers/default-aliases";
3
6
  import { initialModelSelectionPending } from "../../providers/initial-model-selection";
@@ -164,6 +167,7 @@ export function observedModelsAuthResolver(
164
167
  },
165
168
  };
166
169
  }
170
+ /** Project captured discovery through config hints and retention; Auto-only Copilot exposes only Auto. */
167
171
  export async function fetchProviderModelsWithAuth(
168
172
  captured: CapturedProviderGather,
169
173
  ttlMs: number,
@@ -282,6 +286,27 @@ export async function fetchProviderModelsWithAuth(
282
286
  : { apiKey: await resolveModelsAuthToken(name, prov), observed: false }
283
287
  : resolveAuth.resolve(name, prov));
284
288
  const apiKey = auth.apiKey;
289
+ if (name === "github-copilot" && apiKey) {
290
+ const transport = resolveGithubCopilotTransport({ ...prov, apiKey }, prov.authMode === "oauth"
291
+ ? resolveCopilotApiBaseUrl(auth.oauthApiBaseUrl) : undefined);
292
+ try {
293
+ const models = await fetchCopilotAutoModels(transport, AbortSignal.timeout(8_000), undefined, ttlMs);
294
+ const picker = copilotPickerModels(prov, models).map(/** Apply configured hints to the permission-filtered account catalog without changing upstream metadata. */
295
+ model => applyProviderConfigHints(name, prov, {
296
+ id: model.id, provider: name,
297
+ ...catalogHintsFromModelsApiItem(name, model as ProviderModelsApiItem),
298
+ }, contextCap, metadataModelIdCaseFold, captured.effectiveAlias));
299
+ if (!isCurrentCacheGeneration()) return observed(configured, "degraded");
300
+ markProviderDiscoveryOk(name, picker.length);
301
+ // Retain callable manual/combo selectors, but do not advertise named selection
302
+ // for an Auto-only account even when its saved configuration still contains it.
303
+ return observed(copilotRequiresAuto(prov, models)
304
+ ? picker : withConfiguredRetention(picker, { warnDrops: true }), "authoritative");
305
+ } catch {
306
+ if (isCurrentCacheGeneration()) markProviderDiscoveryFailed(name, { reason: "provider" });
307
+ return observed(configured, "degraded");
308
+ }
309
+ }
285
310
  const maySendAnthropicDiscovery = () => {
286
311
  if (!isAnthropicOAuthInstance(name) || prov.authMode !== "oauth") return true;
287
312
  if (!mayResolveModelsOAuth(name, prov)) return false;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * macOS adapter for the Codex desktop-app restart.
3
3
  *
4
- * Measured shape (devlog/_plan/260913_cross_platform_desktop_app_restart/001_platform_topology.md):
4
+ * Measured shape (devlog/_fin/260913_cross_platform_desktop_app_restart/001_platform_topology.md):
5
5
  *
6
6
  * 15901 1 /Applications/ChatGPT.app/Contents/MacOS/ChatGPT
7
7
  * 16733 15901 /Applications/ChatGPT.app/Contents/Resources/codex ... app-server ...
@@ -23,7 +23,7 @@
23
23
  * it passes `allowHandoff: false` so it can only ever take the direct path or refuse.
24
24
  * Recursion is structurally impossible rather than merely unlikely.
25
25
  *
26
- * Design: devlog/_plan/260913_cross_platform_desktop_app_restart/020_phase2_detached_self_handoff.md
26
+ * Design: devlog/_fin/260913_cross_platform_desktop_app_restart/020_phase2_detached_self_handoff.md
27
27
  */
28
28
  import { spawn } from "node:child_process";
29
29
  import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeSync } from "node:fs";
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Linux adapter for the Codex desktop-app restart.
3
3
  *
4
- * Measured shape (devlog/_plan/260913_cross_platform_desktop_app_restart/001_platform_topology.md):
4
+ * Measured shape (devlog/_fin/260913_cross_platform_desktop_app_restart/001_platform_topology.md):
5
5
  *
6
6
  * /usr/bin/chatgpt -> /usr/lib/chatgpt/codex-launcher (2-line sh script)
7
7
  * that execs /usr/lib/chatgpt/ChatGPT
@@ -22,7 +22,7 @@
22
22
  * itself. An unconditional unlink would let a late release destroy somebody else's
23
23
  * live lock, which is exactly the mutual exclusion this file exists to provide.
24
24
  *
25
- * Design: `devlog/_plan/260913_cross_platform_desktop_app_restart/020_phase2_detached_self_handoff.md` §4.1.
25
+ * Design: `devlog/_fin/260913_cross_platform_desktop_app_restart/020_phase2_detached_self_handoff.md` §4.1.
26
26
  */
27
27
  import { mkdirSync, openSync, closeSync, writeSync, readFileSync, unlinkSync, renameSync } from "node:fs";
28
28
  import { join, dirname } from "node:path";
@@ -7,7 +7,7 @@
7
7
  * PID-reuse and fail-closed reasoning from being re-derived three times, once per
8
8
  * operating system, with two of the three getting it subtly wrong.
9
9
  *
10
- * Design and audit history: `devlog/_plan/260913_cross_platform_desktop_app_restart/`.
10
+ * Design and audit history: `devlog/_fin/260913_cross_platform_desktop_app_restart/`.
11
11
  */
12
12
  import { sep } from "node:path";
13
13
 
@@ -7,7 +7,7 @@
7
7
  * AUMID. The shape is DesktopAppAdapter so the ladder, not this file, owns
8
8
  * PID-reuse re-verification and the fail-closed sequencing.
9
9
  *
10
- * Measured (devlog/_plan/260913_cross_platform_desktop_app_restart/001_platform_topology.md §3):
10
+ * Measured (devlog/_fin/260913_cross_platform_desktop_app_restart/001_platform_topology.md §3):
11
11
  * OpenAI.Codex MSIX, ChatGPT.exe, InstallLocation under WindowsApps.
12
12
  */
13
13
  import { execFileSync } from "node:child_process";
@@ -29,7 +29,7 @@
29
29
  * never reported as "nothing to do". A stale picker is a much smaller problem than a
30
30
  * wrongly killed process.
31
31
  *
32
- * Design and audit history: devlog/_plan/260913_cross_platform_desktop_app_restart/.
32
+ * Design and audit history: devlog/_fin/260913_cross_platform_desktop_app_restart/.
33
33
  */
34
34
  import {
35
35
  acquireDesktopRestartLock,
@@ -510,8 +510,9 @@ async function resolveMainAccountToken(
510
510
  }
511
511
  const refresh = dependencies.refreshToken ?? refreshNativeMainGrant;
512
512
  let refreshed: OAuthCredentials;
513
+ if (signal.aborted) throw signal.reason;
513
514
  try {
514
- refreshed = await refresh(locked.refreshToken, { signal });
515
+ refreshed = await refresh(locked.refreshToken, { signal: refreshTimeout });
515
516
  } catch (cause) {
516
517
  // A refusal already classified at the endpoint keeps that verdict. An injected refresh
517
518
  // threw an ordinary Error and has only its message, so prose still decides there --
@@ -535,16 +536,14 @@ async function resolveMainAccountToken(
535
536
  }
536
537
  throw new MainAccountTokenRefreshError(reason, { cause });
537
538
  }
538
- // The refresh may resolve after the caller went away (an implementation that does
539
- // not observe the signal, or an abort landing in the window between resolution and
540
- // commit). A cancelled request's late refresh must not rewrite auth.json on behalf
541
- // of a request that no longer exists -- the same fence the reauth twin applies
542
- // before its own commit above.
543
- if (dependencies.signal?.aborted) throw dependencies.signal.reason;
539
+ // Once the exchange starts, only its own timeout may abort it: a successful
540
+ // rotating grant retires the old refresh token. Save the only live grant before
541
+ // honoring caller cancellation, while retaining the external-writer guards.
544
542
  const result = persistRefreshedMainAuthJson(locked, refreshed);
545
543
  // A permitted in-flight success speaks only for its own profile and grant.
546
544
  clearMainRefreshGrantRejection(mainRefreshGrantKey(locked)!);
547
545
  if (dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
546
+ if (dependencies.signal?.aborted) throw dependencies.signal.reason;
548
547
  return result;
549
548
  }),
550
549
  { waitMs: 30_000, signal },
@@ -20,6 +20,7 @@ import { withNativeMainExclusiveClaim } from "./native-main-claim";
20
20
  import { scrubNativeMainAuthTempResidues } from "./native-main-auth-temp";
21
21
  import { NATIVE_STAGE_SWEEP_INTERVAL_MS } from "./native-profile-stage-store";
22
22
  import type { NativeCodexOwnership } from "../integrations/native/ownership-preflight";
23
+ import { isPassiveRead } from "../lib/passive-read";
23
24
 
24
25
  export type NativeMainStartupGateSnapshot =
25
26
  | { status: "ready"; homeId: string | null }
@@ -701,7 +702,9 @@ export async function releaseNativeMainStartupLifecycle(server: object): Promise
701
702
 
702
703
  export function isNativeMainTrafficBlocked(): boolean {
703
704
  const reason = activeServiceOwnershipBlockReason();
704
- if (reason !== null && reprobeServiceOwnership(reason)) {
705
+ // A passive read (a Remote Link Child's, #6959) reports the gate as it stands: re-inspecting
706
+ // service ownership can activate the native-main lifecycle and its recovery on this machine.
707
+ if (reason !== null && !isPassiveRead() && reprobeServiceOwnership(reason)) {
705
708
  // The host became ownable after boot (#2108): the fence lifts here rather than
706
709
  // waiting for the restart the reporter had to perform by hand.
707
710
  return activeServiceOwnershipBlockReason() !== null || snapshot.status === "blocked";
@@ -318,7 +318,7 @@ export function recoverIfNeeded(
318
318
  };
319
319
  }
320
320
  try {
321
- publishConfigWrite(expectedTargets.configPath, held, (destination, hooks) => restore(destination, record.preConfigBytes, hooks));
321
+ publishConfigWrite(expectedTargets.configPath, held, (destination, hooks) => restore(destination, record.preConfigBytes, expectingImage(hooks, record.postConfig)));
322
322
  } catch (error) {
323
323
  return {
324
324
  ok: false,
@@ -336,7 +336,7 @@ export function recoverIfNeeded(
336
336
  };
337
337
  }
338
338
  try {
339
- publishConfigWriteTarget(expectedTargets.configPath, held, expectedTargets.storePath, (destination, hooks) => restore(destination, record.preStoreBytes, hooks));
339
+ publishConfigWriteTarget(expectedTargets.configPath, held, expectedTargets.storePath, (destination, hooks) => restore(destination, record.preStoreBytes, expectingImage(hooks, record.postStore)));
340
340
  } catch (error) {
341
341
  return {
342
342
  ok: false,
@@ -363,3 +363,21 @@ function restore(path: string, bytes: string | null, hooks: AtomicWriteHooks): v
363
363
  if (bytes === null) durableDelete(path, hooks);
364
364
  else durableWrite(path, bytes, hooks);
365
365
  }
366
+
367
+ /**
368
+ * Bind a write to the image the caller classified. The rename retry hooks check
369
+ * path and inode identity only; an in-place edit by another process during a
370
+ * Windows sharing-violation retry keeps the inode, so without this re-read a
371
+ * later attempt would replace newer bytes. Checked before every attempt.
372
+ */
373
+ export function expectingImage(hooks: AtomicWriteHooks, expectedHash: string): AtomicWriteHooks {
374
+ return {
375
+ ...hooks,
376
+ validateBeforeRename: (targetPath: string) => {
377
+ hooks.validateBeforeRename?.(targetPath);
378
+ if (hashBytes(readOrNull(targetPath)) !== expectedHash) {
379
+ throw new Error(targetPath + " changed during the write; refusing to overwrite it. Files and recovery evidence were preserved.");
380
+ }
381
+ },
382
+ };
383
+ }
@@ -119,3 +119,52 @@ export function decodeTomlBasicString(literal: string): string | null {
119
119
  }
120
120
  return out;
121
121
  }
122
+
123
+ /** Shared scalar assignment matching; quoted and escaped keys retain their spelling. */
124
+ export function matchKeyHead(line: string, key: string): { prefix: string; rest: string } | null {
125
+ const m = /^\s*("(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)\s*=\s*/.exec(line);
126
+ if (!m) return null;
127
+ const token = m[1]!;
128
+ const decoded = token.startsWith('"') ? decodeTomlBasicString(token)
129
+ : token.startsWith("'") ? token.slice(1, -1) : token;
130
+ return decoded === key ? { prefix: m[0], rest: line.slice(m[0].length) } : null;
131
+ }
132
+
133
+ /**
134
+ * For each line, whether it begins outside every string and composite span, or
135
+ * `null` when a span never closes and the scope of later lines is unknown.
136
+ *
137
+ * Line matchers for assignments and table headers must only look at lines that
138
+ * start at top level: `"model_instructions_file" = "x"` written inside a
139
+ * `"""…"""` value or a multi-line array is prose, not syntax. Readers and
140
+ * editors share this so a write and the snapshot read back agree.
141
+ */
142
+ export function lexicalLineStarts(lines: readonly string[]): boolean[] | null {
143
+ const starts: boolean[] = [];
144
+ let open: string | null = null;
145
+ let depth = 0;
146
+ for (const line of lines) {
147
+ starts.push(open === null && depth === 0);
148
+ for (let i = 0; i < line.length; i += 1) {
149
+ const char = line[i]!;
150
+ if (open !== null) {
151
+ if (char === "\\" && (open === '"' || open === '"""')) { i += 1; continue; }
152
+ if (char !== open[0]) continue;
153
+ if (open.length === 1) { open = null; continue; }
154
+ let run = 0;
155
+ while (line[i + run] === char) run += 1;
156
+ // Up to two quotes may sit inside the value just before the closing three.
157
+ if (run >= 3) open = null;
158
+ i += run - 1;
159
+ } else if (char === "#") break;
160
+ else if (char === '"' || char === "'") {
161
+ open = line.startsWith(char.repeat(3), i) ? char.repeat(3) : char;
162
+ i += open.length - 1;
163
+ } else if (char === "[" || char === "{") depth += 1;
164
+ else if (char === "]" || char === "}") { depth -= 1; if (depth < 0) return null; }
165
+ }
166
+ // Single-line strings cannot cross a newline.
167
+ if (open !== null && open.length === 1) return null;
168
+ }
169
+ return open === null && depth === 0 ? starts : null;
170
+ }
@@ -0,0 +1,30 @@
1
+ import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
2
+
3
+ // CRLF normalization can halve the byte count. Every body within 64 KiB
4
+ // therefore fits this raw ceiling; title bytes stay outside the body budget.
5
+ const MAX_IMPORT_SOURCE_BYTES = 2 * 64 * 1024;
6
+
7
+ /** The prompt-text-probe descriptor pattern: nonblocking, regular, bounded. */
8
+ export function readBaseImportSource(path: string): string {
9
+ const nonBlocking = (constants as { O_NONBLOCK?: number }).O_NONBLOCK ?? 0;
10
+ const descriptor = openSync(path, constants.O_RDONLY | nonBlocking);
11
+ try {
12
+ if (!fstatSync(descriptor).isFile()) throw new Error("prompt source is not a regular file");
13
+ const parts: Buffer[] = [];
14
+ const view = Buffer.allocUnsafe(8192);
15
+ let total = 0;
16
+ for (;;) {
17
+ const count = readSync(descriptor, view, 0, Math.min(view.length, MAX_IMPORT_SOURCE_BYTES + 1 - total), total);
18
+ if (count === 0) return Buffer.concat(parts).toString("utf8");
19
+ total += count;
20
+ if (total > MAX_IMPORT_SOURCE_BYTES) {
21
+ const error = new Error("prompt source exceeds the import byte ceiling") as NodeJS.ErrnoException;
22
+ error.code = "EFBIG";
23
+ throw error;
24
+ }
25
+ parts.push(Buffer.from(view.subarray(0, count)));
26
+ }
27
+ } finally {
28
+ closeSync(descriptor);
29
+ }
30
+ }
@@ -1,5 +1,5 @@
1
1
  import { OCX_SECTION_MARKER } from "../injected-marker";
2
- import { encodeBasicString } from "./encoding";
2
+ import { encodeBasicString, lexicalLineStarts, matchKeyHead } from "./encoding";
3
3
  import { TABLE_HEADER, ANY_DEV_INSTRUCTIONS, DEV_INSTRUCTIONS_KEY } from "./toml-read";
4
4
 
5
5
  /** Line editing, not re-serialization: the user's comments and layout survive. */
@@ -37,28 +37,156 @@ function joinLines(lines: string[], eol: "\r\n" | "\n"): string {
37
37
  return eol === "\n" ? text : text.replace(/\n/g, "\r\n");
38
38
  }
39
39
 
40
- function firstTableIndex(lines: string[]): number {
41
- const idx = lines.findIndex(l => TABLE_HEADER.test(l));
40
+ function firstTableIndex(lines: string[], starts?: readonly boolean[]): number {
41
+ const idx = lines.findIndex((l, i) => (starts === undefined || starts[i] === true) && TABLE_HEADER.test(l));
42
42
  return idx === -1 ? lines.length : idx;
43
43
  }
44
44
 
45
- /** Set a root-scope boolean, inserting above the first table when absent. */
46
- export function setRootBool(content: string, key: string, value: boolean): string {
45
+ /**
46
+ * Raised when a recognized `key =` line carries a value a line editor cannot
47
+ * replace safely — a multi-line `"""…"""`/`'''…'''` span, an unterminated
48
+ * quote, or trailing junk that is neither value nor comment. Callers refuse
49
+ * BEFORE writing rather than splice over a span they cannot see the end of.
50
+ */
51
+ export class UnsupportedTomlForm extends Error {
52
+ constructor(readonly line: string) {
53
+ super(`unsupported TOML value form: ${line.trim()}`);
54
+ this.name = "UnsupportedTomlForm";
55
+ }
56
+ }
57
+
58
+ /** Where `<key> =` ends and the value begins. `prefix` keeps indent and key quoting. */
59
+ interface AssignmentHead {
60
+ prefix: string;
61
+ rest: string;
62
+ }
63
+
64
+ /**
65
+ * Lexical line scope for the editors below (see `lexicalLineStarts`). A span
66
+ * that never closes means the scope of every later line is unknown, so the
67
+ * whole edit refuses instead of guessing.
68
+ */
69
+ function scopedLineStarts(lines: readonly string[]): boolean[] {
70
+ const starts = lexicalLineStarts(lines);
71
+ if (starts === null) throw new UnsupportedTomlForm("configuration contains an unterminated string or composite value");
72
+ return starts;
73
+ }
74
+
75
+ /**
76
+ * Split `rest` (everything after `key =`) into the value token and a trailing
77
+ * `#…` comment — adjacent or whitespace-separated, both preserved.
78
+ *
79
+ * The scan is string-aware so a `#` inside a quoted value is never mistaken
80
+ * for a comment opener: `"a#b" # note` parses as value `"a#b"` + comment
81
+ * ` # note`, while `false# note` parses as `false` + `# note`. Anything the
82
+ * token scan cannot classify — a multi-line string opener, an unterminated
83
+ * quote, text after the value that is not a comment — throws
84
+ * UnsupportedTomlForm so the caller refuses rather than guesses a span.
85
+ */
86
+ function splitValueComment(head: AssignmentHead, line: string): { value: string; comment: string } {
87
+ const { rest } = head;
88
+ if (rest.startsWith('"""') || rest.startsWith("'''")) throw new UnsupportedTomlForm(line);
89
+ let value: string;
90
+ const first = rest[0];
91
+ if (first === '"') {
92
+ // Basic string: an escaped quote does not close, an unterminated one refuses.
93
+ let i = 1;
94
+ while (i < rest.length && rest[i] !== '"') i += rest[i] === "\\" ? 2 : 1;
95
+ if (i >= rest.length) throw new UnsupportedTomlForm(line);
96
+ value = rest.slice(0, i + 1);
97
+ } else if (first === "'") {
98
+ // Literal string: no escapes, the next ' closes it.
99
+ const end = rest.indexOf("'", 1);
100
+ if (end === -1) throw new UnsupportedTomlForm(line);
101
+ value = rest.slice(0, end + 1);
102
+ } else {
103
+ // Composite values may continue onto another line. This scalar editor
104
+ // refuses them instead of replacing only their opener and orphaning the tail.
105
+ if (first === "[" || first === "{") throw new UnsupportedTomlForm(line);
106
+ const m = /^[^\s#]+/.exec(rest);
107
+ if (!m) throw new UnsupportedTomlForm(line);
108
+ value = m[0];
109
+ }
110
+ const tail = rest.slice(value.length);
111
+ if (tail !== "" && !/^\s*(?:#|$)/.test(tail)) throw new UnsupportedTomlForm(line);
112
+ return { value, comment: tail };
113
+ }
114
+
115
+ /**
116
+ * The shape of `key`'s root-scope assignment, or "absent". "unsupported" means
117
+ * a `key =` line was found but its value is a form the line editor refuses —
118
+ * multiline strings and the like — so a caller can fail BEFORE touching disk
119
+ * instead of inside the transaction.
120
+ */
121
+ export function rootKeyValueForm(content: string, key: string): "absent" | "simple" | "unsupported" {
122
+ const { body } = splitBom(content);
123
+ const lines = splitLines(body);
124
+ let starts: boolean[];
125
+ try { starts = scopedLineStarts(lines); } catch { return "unsupported"; }
126
+ const limit = firstTableIndex(lines, starts);
127
+ for (let i = 0; i < limit; i += 1) {
128
+ if (!starts[i]) continue;
129
+ const head = matchKeyHead(lines[i]!, key);
130
+ if (!head) continue;
131
+ try {
132
+ splitValueComment(head, lines[i]!);
133
+ return "simple";
134
+ } catch {
135
+ return "unsupported";
136
+ }
137
+ }
138
+ return "absent";
139
+ }
140
+
141
+ /**
142
+ * Replace or remove one matched line. The line's own indentation, key quoting,
143
+ * and trailing comment are preserved; `null` removes the assignment but keeps
144
+ * a comment behind so a `# note` written beside the key survives a reset.
145
+ */
146
+ function applyLineEdit(lines: string[], i: number, key: string, replacement: string | null): void {
147
+ const head = matchKeyHead(lines[i]!, key)!;
148
+ const { comment } = splitValueComment(head, lines[i]!);
149
+ if (replacement === null) {
150
+ if (comment.trim()) lines[i] = comment.trim();
151
+ else lines.splice(i, 1);
152
+ } else {
153
+ lines[i] = `${head.prefix}${replacement}${comment}`;
154
+ }
155
+ }
156
+
157
+ type EditMode = "inplace" | "appended" | "noop";
158
+ interface LineEdit { text: string; mode: EditMode }
159
+
160
+ /**
161
+ * Set a root-scope boolean, inserting above the first table when absent.
162
+ *
163
+ * `null` REMOVES the key rather than writing a value, which is what restoring a
164
+ * documented default means: `include_permissions_instructions = true` and an
165
+ * absent key are different facts about the same file, and only the absent one
166
+ * lets a changed upstream default ever take effect again.
167
+ *
168
+ * The value slot is classified, not just skipped over: any value form the line
169
+ * editor cannot see the end of — a multi-line string span, an unterminated
170
+ * quote — throws UnsupportedTomlForm so the write is refused instead of
171
+ * appending a second assignment TOML would reject as a duplicate key. A value
172
+ * that is not a boolean is a bad fact about the same key, so it is REPLACED,
173
+ * and a trailing comment survives a write or a removal whether or not a space
174
+ * precedes the `#`.
175
+ */
176
+ function setRootBoolLines(content: string, key: string, value: boolean | null): LineEdit {
47
177
  const eol = dominantEol(content);
48
178
  const { bom, body } = splitBom(content);
49
179
  const lines = splitLines(body);
50
- const limit = firstTableIndex(lines);
51
- const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
52
- const pattern = new RegExp(`^(\\s*${escaped}\\s*=\\s*)(?:true|false)(\\s*(?:#.*)?)$`);
180
+ const starts = scopedLineStarts(lines);
181
+ const limit = firstTableIndex(lines, starts);
53
182
  for (let i = 0; i < limit; i += 1) {
54
- const m = pattern.exec(lines[i]!);
55
- if (m) {
56
- lines[i] = `${m[1]}${value}${m[2]}`;
57
- return bom + joinLines(lines, eol);
58
- }
183
+ if (!starts[i] || !matchKeyHead(lines[i]!, key)) continue;
184
+ applyLineEdit(lines, i, key, value === null ? null : String(value));
185
+ return { text: bom + joinLines(lines, eol), mode: "inplace" };
59
186
  }
187
+ if (value === null) return { text: bom + joinLines(lines, eol), mode: "noop" };
60
188
  lines.splice(limit, 0, `${key} = ${value}`);
61
- return bom + joinLines(lines, eol);
189
+ return { text: bom + joinLines(lines, eol), mode: "appended" };
62
190
  }
63
191
 
64
192
  /**
@@ -68,51 +196,56 @@ export function setRootBool(content: string, key: string, value: boolean): strin
68
196
  * rather than an empty string: `model_instructions_file = ""` is a path Codex would try
69
197
  * to read, not an absent setting.
70
198
  */
71
- export function setRootString(content: string, key: string, value: string | null): string {
199
+ function setRootStringLines(content: string, key: string, value: string | null): LineEdit {
72
200
  const eol = dominantEol(content);
73
201
  const { bom, body } = splitBom(content);
74
202
  const lines = splitLines(body);
75
- const limit = firstTableIndex(lines);
76
- const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
77
- const pattern = new RegExp(`^\\s*${escaped}\\s*=\\s*"[^"]*"\\s*(?:#.*)?$`);
203
+ const starts = scopedLineStarts(lines);
204
+ const limit = firstTableIndex(lines, starts);
78
205
  for (let i = 0; i < limit; i += 1) {
79
- if (!pattern.test(lines[i]!)) continue;
80
- if (value === null) lines.splice(i, 1);
81
- else lines[i] = `${key} = ${encodeBasicString(value)}`;
82
- return bom + joinLines(lines, eol);
206
+ if (!starts[i] || !matchKeyHead(lines[i]!, key)) continue;
207
+ applyLineEdit(lines, i, key, value === null ? null : encodeBasicString(value));
208
+ return { text: bom + joinLines(lines, eol), mode: "inplace" };
83
209
  }
84
- if (value === null) return bom + joinLines(lines, eol);
210
+ if (value === null) return { text: bom + joinLines(lines, eol), mode: "noop" };
85
211
  lines.splice(limit, 0, `${key} = ${encodeBasicString(value)}`);
86
- return bom + joinLines(lines, eol);
212
+ return { text: bom + joinLines(lines, eol), mode: "appended" };
87
213
  }
88
214
 
89
- /** Set a boolean inside `[table]`, appending the table when absent. */
90
- export function setTableBool(content: string, table: string, key: string, value: boolean): string {
215
+ /**
216
+ * Set a boolean inside `[table]`, appending the table when absent.
217
+ *
218
+ * `null` removes the key line but leaves the table header: an empty `[skills]`
219
+ * is valid TOML, and deleting the header would also orphan any comments the
220
+ * user wrote inside the table.
221
+ */
222
+ function setTableBoolLines(content: string, table: string, key: string, value: boolean | null): LineEdit {
91
223
  const eol = dominantEol(content);
92
224
  const { bom, body } = splitBom(content);
93
225
  const lines = splitLines(body);
226
+ const starts = scopedLineStarts(lines);
94
227
  const escaped = table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
95
- const start = lines.findIndex(l => new RegExp(`^\\s*\\[${escaped}\\]\\s*(?:#.*)?$`).test(l));
228
+ const header = new RegExp(`^\\s*\\[${escaped}\\]\\s*(?:#.*)?$`);
229
+ const start = lines.findIndex((l, i) => starts[i] === true && header.test(l));
96
230
  if (start === -1) {
231
+ if (value === null) return { text: bom + joinLines(lines, eol), mode: "noop" };
97
232
  const tail = lines.length > 0 && lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
98
233
  lines.splice(tail, 0, `[${table}]`, `${key} = ${value}`);
99
- return bom + joinLines(lines, eol);
234
+ return { text: bom + joinLines(lines, eol), mode: "appended" };
100
235
  }
101
- const keyEscaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
102
- const pattern = new RegExp(`^(\\s*${keyEscaped}\\s*=\\s*)(?:true|false)(\\s*(?:#.*)?)$`);
103
236
  let end = start + 1;
104
- while (end < lines.length && !TABLE_HEADER.test(lines[end]!)) end += 1;
237
+ while (end < lines.length && !(starts[end] && TABLE_HEADER.test(lines[end]!))) end += 1;
105
238
  for (let i = start + 1; i < end; i += 1) {
106
- const m = pattern.exec(lines[i]!);
107
- if (m) {
108
- lines[i] = `${m[1]}${value}${m[2]}`;
109
- return bom + joinLines(lines, eol);
110
- }
239
+ if (!starts[i] || !matchKeyHead(lines[i]!, key)) continue;
240
+ applyLineEdit(lines, i, key, value === null ? null : String(value));
241
+ return { text: bom + joinLines(lines, eol), mode: "inplace" };
111
242
  }
243
+ if (value === null) return { text: bom + joinLines(lines, eol), mode: "noop" };
112
244
  lines.splice(end, 0, `${key} = ${value}`);
113
- return bom + joinLines(lines, eol);
245
+ return { text: bom + joinLines(lines, eol), mode: "appended" };
114
246
  }
115
247
 
248
+
116
249
  /**
117
250
  * Replace, insert, or remove the generated two-line block. Canonical form is
118
251
  * marker + assignment at the top of the document; replacement is "find the
@@ -161,3 +294,102 @@ export function removeUnownedProjection(content: string): string {
161
294
  }
162
295
  return joinLines(lines, eol);
163
296
  }
297
+
298
+ /**
299
+ * Refuse, before any write, an edit that does not do exactly what it was asked.
300
+ *
301
+ * The line editors above address bare table headers and bare or quoted keys. A
302
+ * quoted, spaced, inline or dotted spelling of the same table makes them append a
303
+ * duplicate header (a file Codex cannot load) or, for restore-default, leave the
304
+ * override in place while the caller reports success. Both are caught by parsing
305
+ * the before and after images: the parsed result must equal the parsed input with
306
+ * only the intended path set or removed. When Bun cannot parse the input (its
307
+ * parser has documented gaps a valid Codex config can hit, such as i64 extremes),
308
+ * only an in-place replacement of a key the editor located is trusted; an append
309
+ * or a no-op restore cannot be verified and is refused. A target value with
310
+ * control characters is checked for presence and type only, for the same reason.
311
+ */
312
+ function checkedEdit(before: string, edit: LineEdit, path: readonly string[], value: string | boolean | null): string {
313
+ const after = edit.text;
314
+ let parsedBefore: Record<string, unknown>;
315
+ try { parsedBefore = Bun.TOML.parse(before.replace(/^\ufeff/, "")) as Record<string, unknown>; } catch {
316
+ if (edit.mode === "inplace") return after;
317
+ throw new UnsupportedTomlForm("config.toml uses syntax this editor cannot verify; edit it by hand");
318
+ }
319
+ let parsedAfter: Record<string, unknown>;
320
+ try { parsedAfter = Bun.TOML.parse(after.replace(/^\ufeff/, "")) as Record<string, unknown>; } catch {
321
+ throw new UnsupportedTomlForm("edit would leave config.toml unparseable");
322
+ }
323
+ try {
324
+ const expected = tomlClone(parsedBefore) as Record<string, unknown>;
325
+ let parent: Record<string, unknown> | null = expected;
326
+ let afterParent: unknown = parsedAfter;
327
+ for (const segment of path.slice(0, -1)) {
328
+ const next: unknown = parent?.[segment];
329
+ afterParent = isPlainTable(afterParent) ? afterParent[segment] : undefined;
330
+ if (next === undefined) {
331
+ if (value === null) { parent = null; break; }
332
+ parent![segment] = {};
333
+ parent = parent![segment] as Record<string, unknown>;
334
+ continue;
335
+ }
336
+ if (!isPlainTable(next)) throw new UnsupportedTomlForm("edit target is not a plain table");
337
+ parent = next;
338
+ }
339
+ const key = path[path.length - 1]!;
340
+ if (parent !== null) {
341
+ if (value === null) delete parent[key];
342
+ else if (typeof value === "string" && /[\u0000-\u001f\u007f]/.test(value)) {
343
+ const landed = isPlainTable(afterParent) ? afterParent[key] : undefined;
344
+ if (typeof landed !== "string") throw new UnsupportedTomlForm("edit did not land on the intended key");
345
+ parent[key] = landed;
346
+ } else parent[key] = value;
347
+ }
348
+ if (!tomlEqual(parsedAfter, expected)) throw new UnsupportedTomlForm("edit did not land on the intended key");
349
+ } catch (error) {
350
+ if (error instanceof UnsupportedTomlForm) throw error;
351
+ throw new UnsupportedTomlForm("edit could not be verified");
352
+ }
353
+ return after;
354
+ }
355
+
356
+ function isPlainTable(value: unknown): value is Record<string, unknown> {
357
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
358
+ const proto = Object.getPrototypeOf(value);
359
+ return proto === Object.prototype || proto === null;
360
+ }
361
+
362
+ /** Arrays and plain tables are copied; TOML date/time values are immutable and shared. */
363
+ function tomlClone(value: unknown): unknown {
364
+ if (Array.isArray(value)) return value.map(tomlClone);
365
+ if (isPlainTable(value)) return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, tomlClone(v)]));
366
+ return value;
367
+ }
368
+
369
+ function tomlEqual(a: unknown, b: unknown): boolean {
370
+ if (Object.is(a, b)) return true;
371
+ if (Array.isArray(a) || Array.isArray(b)) {
372
+ return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => tomlEqual(v, b[i]));
373
+ }
374
+ if (isPlainTable(a) || isPlainTable(b)) {
375
+ if (!isPlainTable(a) || !isPlainTable(b)) return false;
376
+ const ka = Object.keys(a), kb = Object.keys(b);
377
+ return ka.length === kb.length && ka.every(k => Object.hasOwn(b, k) && tomlEqual(a[k], b[k]));
378
+ }
379
+ if (a !== null && b !== null && typeof a === "object" && typeof b === "object") {
380
+ return Object.getPrototypeOf(a) === Object.getPrototypeOf(b) && String(a) === String(b);
381
+ }
382
+ return false;
383
+ }
384
+
385
+ export function setRootBool(content: string, key: string, value: boolean | null): string {
386
+ return checkedEdit(content, setRootBoolLines(content, key, value), [key], value);
387
+ }
388
+
389
+ export function setRootString(content: string, key: string, value: string | null): string {
390
+ return checkedEdit(content, setRootStringLines(content, key, value), [key], value);
391
+ }
392
+
393
+ export function setTableBool(content: string, table: string, key: string, value: boolean | null): string {
394
+ return checkedEdit(content, setTableBoolLines(content, table, key, value), [...table.split("."), key], value);
395
+ }