@bitkyc08/opencodex 2.81.0 → 2.82.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 (212) hide show
  1. package/bin/ocx.mjs +166 -73
  2. package/gui/dist/assets/App-Ds_qL7o8.js +54 -0
  3. package/gui/dist/assets/{Tray-EwAOsP0S.js → Tray-BqHF5Trd.js} +1 -1
  4. package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
  5. package/gui/dist/assets/{index-RsoaAxXY.js → index-D3FBCNiP.js} +13 -13
  6. package/gui/dist/assets/{quota-summary-D2lN8tDm.js → quota-summary-DTBC8n7-.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/anthropic.ts +1 -1
  10. package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
  11. package/src/adapters/openai-responses/passthrough.ts +9 -4
  12. package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
  13. package/src/claude/agents-inject.ts +14 -3
  14. package/src/claude/context-windows.ts +19 -17
  15. package/src/claude/desktop-3p.ts +88 -12
  16. package/src/claude/desktop-profile.ts +2 -1
  17. package/src/claude/gateway-cache.ts +18 -1
  18. package/src/claude/inbound.ts +77 -3
  19. package/src/claude/intercept/cli-catalog.ts +3 -0
  20. package/src/claude/intercept/picker-ca-persistence.ts +7 -6
  21. package/src/claude/intercept/picker-models.ts +14 -6
  22. package/src/claude/intercept/settings.ts +3 -0
  23. package/src/claude/long-context.ts +66 -0
  24. package/src/claude/model-info.ts +19 -16
  25. package/src/claude/outbound.ts +94 -9
  26. package/src/claude/subagent-model.ts +40 -11
  27. package/src/cli/agent.ts +9 -5
  28. package/src/cli/aside-profile-recovery.ts +33 -0
  29. package/src/cli/capabilities-base.ts +5 -5
  30. package/src/cli/capabilities-integrations.ts +2 -2
  31. package/src/cli/cli-path-diagnostics.ts +132 -0
  32. package/src/cli/desktop-runtime-guidance.ts +65 -0
  33. package/src/cli/dispatch.ts +5 -0
  34. package/src/cli/doctor.ts +16 -3
  35. package/src/cli/index.ts +14 -9
  36. package/src/cli/integration-aside-sync.ts +2 -1
  37. package/src/cli/integrations.ts +16 -3
  38. package/src/cli/resolve.ts +25 -2
  39. package/src/cli/root.ts +4 -2
  40. package/src/cli/status.ts +64 -12
  41. package/src/cli/update-restart-child.ts +4 -2
  42. package/src/cli/update-restart-home.ts +28 -10
  43. package/src/cli/update-restart-service-record.ts +116 -0
  44. package/src/cli/update-restart-supervision.ts +96 -0
  45. package/src/cli/update-restart.ts +59 -13
  46. package/src/cli/v2.ts +27 -20
  47. package/src/cli/version-skew-notice.ts +77 -0
  48. package/src/clients/lazycodex.ts +1 -1
  49. package/src/clients/omo-role-models.ts +32 -7
  50. package/src/codex/auth-api/account-selection.ts +107 -0
  51. package/src/codex/auth-api/routes.ts +10 -49
  52. package/src/codex/auth-context.ts +20 -1
  53. package/src/codex/autostart-health.ts +12 -3
  54. package/src/codex/catalog/build-entries.ts +18 -0
  55. package/src/codex/catalog/bundled.ts +155 -26
  56. package/src/codex/catalog/combo-member.ts +3 -2
  57. package/src/codex/catalog/effort.ts +26 -5
  58. package/src/codex/catalog/gather-capture.ts +5 -0
  59. package/src/codex/catalog/model-hints.ts +15 -8
  60. package/src/codex/catalog/model-visibility.ts +3 -1
  61. package/src/codex/catalog/operator-tiers.ts +38 -0
  62. package/src/codex/catalog/provider-models.ts +43 -35
  63. package/src/codex/catalog/retained-sync.ts +7 -5
  64. package/src/codex/catalog/routed-gather.ts +15 -1
  65. package/src/codex/catalog-auto-refresh-sources.ts +3 -4
  66. package/src/codex/catalog-auto-refresh.ts +19 -0
  67. package/src/codex/catalog.ts +1 -1
  68. package/src/codex/codex-home-owner.ts +3 -0
  69. package/src/codex/config-write-lock.ts +333 -0
  70. package/src/codex/convergence.ts +2 -1
  71. package/src/codex/features.ts +257 -200
  72. package/src/codex/inject/config-write-section.ts +34 -0
  73. package/src/codex/inject/multi-agent-v2.ts +14 -6
  74. package/src/codex/inject/remove.ts +36 -11
  75. package/src/codex/inject/restore.ts +85 -20
  76. package/src/codex/inject-coordination.ts +15 -6
  77. package/src/codex/inject.ts +62 -20
  78. package/src/codex/journal.ts +62 -17
  79. package/src/codex/main-account-cache.ts +43 -0
  80. package/src/codex/paths.ts +2 -0
  81. package/src/codex/prepared-home.ts +18 -0
  82. package/src/codex/prompt-journal.ts +25 -12
  83. package/src/codex/prompt-layers.ts +49 -26
  84. package/src/codex/prompt-lock-claim.ts +134 -0
  85. package/src/codex/prompt-lock-evidence.ts +26 -0
  86. package/src/codex/prompt-lock-io.ts +16 -0
  87. package/src/codex/prompt-lock-owner.ts +130 -0
  88. package/src/codex/prompt-lock.ts +84 -53
  89. package/src/codex/routing/failure-window.ts +146 -0
  90. package/src/codex/routing/selection.ts +4 -1
  91. package/src/codex/routing.ts +13 -17
  92. package/src/codex/runtime.ts +106 -18
  93. package/src/codex/shim.ts +45 -19
  94. package/src/combos/jev-service-exchange.ts +281 -0
  95. package/src/combos/jev.ts +34 -220
  96. package/src/config/atomic-write.ts +1 -1
  97. package/src/config/codex-ws-reuse-setting.ts +10 -0
  98. package/src/config/live-reconcile.ts +17 -0
  99. package/src/config/load-degrade.ts +8 -0
  100. package/src/config/rebase-provenance.ts +11 -0
  101. package/src/config/schema/config-schema.ts +4 -0
  102. package/src/generated/compatibility-version.json +303 -171
  103. package/src/generated/model-metadata.ts +2 -2
  104. package/src/grok/inject.ts +50 -4
  105. package/src/grok/sync.ts +2 -1
  106. package/src/images/loop.ts +29 -11
  107. package/src/integrations/aside-profiles.ts +10 -3
  108. package/src/integrations/catalog-refresh.ts +18 -2
  109. package/src/integrations/writer.ts +9 -3
  110. package/src/lib/bun-path-runtime.d.mts +32 -0
  111. package/src/lib/bun-path-runtime.mjs +95 -0
  112. package/src/lib/bun-runtime-preflight.ts +140 -0
  113. package/src/lib/desktop-cli-handoff.d.mts +20 -0
  114. package/src/lib/desktop-cli-handoff.mjs +92 -0
  115. package/src/lib/desktop-cli-record.d.mts +29 -0
  116. package/src/lib/desktop-cli-record.mjs +102 -0
  117. package/src/lib/request-execution-budget.ts +8 -0
  118. package/src/lib/synced-state-location.ts +46 -6
  119. package/src/lib/test-home-guard.ts +87 -1
  120. package/src/lib/translator-budget.ts +17 -4
  121. package/src/lib/windows-atomic-replace.ts +38 -16
  122. package/src/lib/windows-elevation.ts +6 -0
  123. package/src/lib/windows-secret-acl.ts +92 -25
  124. package/src/lib/winsw.ts +2 -3
  125. package/src/oauth/anthropic-account-refusal.ts +39 -1
  126. package/src/oauth/anthropic-routing.ts +1 -1
  127. package/src/oauth/anthropic-send-ownership.ts +3 -3
  128. package/src/oauth/chatgpt.ts +113 -41
  129. package/src/oauth/index.ts +6 -2
  130. package/src/oauth/store.ts +1 -1
  131. package/src/providers/azure-model-metadata.ts +115 -0
  132. package/src/providers/derive.ts +21 -0
  133. package/src/providers/quota/vendor-probes-key.ts +127 -17
  134. package/src/providers/quota.ts +1 -1
  135. package/src/responses/reasoning-envelope.ts +29 -1
  136. package/src/responses/reasoning-replay-cache.ts +17 -1
  137. package/src/responses/spill-store.ts +15 -2
  138. package/src/responses/state/metrics.ts +8 -0
  139. package/src/responses/state/spill-failure.ts +12 -0
  140. package/src/responses/state/spill-queue.ts +12 -5
  141. package/src/responses/state.ts +18 -10
  142. package/src/responses/task-input.ts +17 -0
  143. package/src/server/background-lifecycle.ts +27 -0
  144. package/src/server/chat-completions.ts +1 -0
  145. package/src/server/chat-native.ts +28 -12
  146. package/src/server/claude-messages.ts +43 -6
  147. package/src/server/inbound-body-admission.ts +1 -0
  148. package/src/server/index/claude-intercept-lifecycle.ts +3 -1
  149. package/src/server/index/serve-options.ts +10 -2
  150. package/src/server/index/spend-ledger-lifecycle.ts +2 -1
  151. package/src/server/index/startup-warnings.ts +19 -0
  152. package/src/server/index.ts +1 -0
  153. package/src/server/management/agent-settings-routes.ts +93 -32
  154. package/src/server/management/codex-agent-role-routes.ts +4 -2
  155. package/src/server/management/codex-prompt-routes.ts +1 -0
  156. package/src/server/management/codex-role-auto-assign.ts +13 -2
  157. package/src/server/management/config-routes.ts +59 -8
  158. package/src/server/management/context.ts +2 -2
  159. package/src/server/management/shared.ts +3 -3
  160. package/src/server/messages-native-oauth.ts +9 -8
  161. package/src/server/messages-native.ts +49 -13
  162. package/src/server/messages-response-headers.ts +14 -0
  163. package/src/server/request-log-conversation.ts +48 -5
  164. package/src/server/request-log.ts +31 -2
  165. package/src/server/responses/adapter-continuation.ts +2 -2
  166. package/src/server/responses/adapter-dispatch.ts +34 -13
  167. package/src/server/responses/codex-ws-exchange.ts +17 -2
  168. package/src/server/responses/codex-ws-pool.ts +100 -13
  169. package/src/server/responses/codex-ws-wire.ts +9 -0
  170. package/src/server/responses/combo-requested-effort.ts +3 -1
  171. package/src/server/responses/core-codex-account.ts +22 -1
  172. package/src/server/responses/core-combo-native.ts +20 -3
  173. package/src/server/responses/core-combo.ts +22 -14
  174. package/src/server/responses/core-lifetime.ts +3 -36
  175. package/src/server/responses/core-options.ts +5 -0
  176. package/src/server/responses/core-replay.ts +73 -0
  177. package/src/server/responses/core.ts +3 -3
  178. package/src/server/responses/fetch-helpers.ts +26 -3
  179. package/src/server/responses/passthrough-delivery.ts +24 -2
  180. package/src/server/responses/passthrough-dispatch.ts +19 -2
  181. package/src/server/responses/request-prepare.ts +4 -0
  182. package/src/server/responses/request-send-budget.ts +112 -49
  183. package/src/server/responses/response-effects.ts +1 -0
  184. package/src/server/responses/run-turn-execution.ts +2 -0
  185. package/src/server/responses/sidecar-execution.ts +25 -16
  186. package/src/server/responses/sidecar-send-budget.ts +58 -0
  187. package/src/server/responses/ws-upstream.ts +19 -6
  188. package/src/server/startup-health-cache.ts +2 -1
  189. package/src/server/workflow-refusal.ts +3 -0
  190. package/src/service/cli.ts +20 -4
  191. package/src/service/desktop-command-guard.ts +32 -0
  192. package/src/service/desktop-startup.ts +66 -40
  193. package/src/service/desktop-supervision.d.mts +26 -0
  194. package/src/service/desktop-supervision.mjs +140 -0
  195. package/src/service/managing-cli.ts +1 -0
  196. package/src/service/orchestration.ts +46 -20
  197. package/src/service/repair.ts +25 -9
  198. package/src/service/state.ts +4 -3
  199. package/src/service/systemd.ts +17 -8
  200. package/src/service/windows-ops.ts +61 -22
  201. package/src/storage/policy-job.ts +5 -1
  202. package/src/storage/policy-worker.ts +3 -1
  203. package/src/storage/policy.ts +9 -0
  204. package/src/types/config.ts +21 -0
  205. package/src/types/request.ts +5 -0
  206. package/src/types/wire.ts +1 -0
  207. package/src/update/index.ts +57 -9
  208. package/src/update/restart-ownership.ts +10 -3
  209. package/src/update/runtime-ownership.d.mts +6 -0
  210. package/src/update/runtime-ownership.mjs +23 -7
  211. package/src/web-search/loop.ts +5 -2
  212. package/gui/dist/assets/App-7yhoys8a.js +0 -54
@@ -0,0 +1,140 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { lstatSync, mkdirSync, readdirSync, rmdirSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import type { DurableBunRuntime } from "./bun-runtime";
6
+
7
+ export type RuntimePreflightReason = "spawn" | "timeout" | "create" | "remove" | "protocol";
8
+ export const RUNTIME_PREFLIGHT_TIMEOUT_MS = 5_000;
9
+ export const RUNTIME_PREFLIGHT_GUIDANCE = "The selected Bun runtime could not create and remove a directory in the OpenCodex config directory. "
10
+ + "Windows application policy may deny writes from this executable location. "
11
+ + "Set OPENCODEX_BUN_PATH to a trusted Bun executable allowed by your policy before launching ocx, "
12
+ + "or reinstall opencodex with npm install -g @bitkyc08/opencodex, then retry. No alternate runtime was selected.";
13
+
14
+ export class RuntimePreflightError extends Error {
15
+ readonly code = "OCX_RUNTIME_PREFLIGHT_FAILED";
16
+ constructor(readonly reason: RuntimePreflightReason) {
17
+ super(`${RUNTIME_PREFLIGHT_GUIDANCE} (preflight: ${reason})`);
18
+ this.name = "RuntimePreflightError";
19
+ }
20
+ }
21
+
22
+ type DirectoryIdentity = { dev: number; ino: number; isDirectory(): boolean; isSymbolicLink(): boolean };
23
+ export interface RuntimePreflightFs {
24
+ lstatSync: (path: string) => DirectoryIdentity;
25
+ mkdirSync: (path: string, mode: number) => unknown;
26
+ rmdirSync: (path: string) => void;
27
+ readdirSync: (path: string) => readonly string[];
28
+ }
29
+ export interface RuntimePreflightOptions {
30
+ platform?: NodeJS.Platform;
31
+ rootWasAbsent?: boolean;
32
+ spawnSync?: typeof spawnSync;
33
+ fs?: RuntimePreflightFs;
34
+ nonce?: () => string;
35
+ }
36
+ /** Caller seams keep one selection through admission, rendering and state publication. */
37
+ export interface RuntimePreflightDeps {
38
+ selectRuntime?: () => DurableBunRuntime;
39
+ assertRuntimeWritable?: typeof assertSelectedRuntimeWritable;
40
+ configDir?: () => string;
41
+ platform?: NodeJS.Platform;
42
+ }
43
+
44
+ const probeFs: RuntimePreflightFs = { lstatSync, mkdirSync, rmdirSync, readdirSync };
45
+
46
+ function sameDirectory(fs: RuntimePreflightFs, path: string, owned: DirectoryIdentity): boolean {
47
+ const current = fs.lstatSync(path);
48
+ return current.isDirectory() && !current.isSymbolicLink()
49
+ && current.dev === owned.dev && current.ino === owned.ino;
50
+ }
51
+
52
+ function removeOwnedEmptyDirectory(fs: RuntimePreflightFs, path: string, owned: DirectoryIdentity): void {
53
+ try {
54
+ if (sameDirectory(fs, path, owned) && fs.readdirSync(path).length === 0
55
+ && sameDirectory(fs, path, owned)) fs.rmdirSync(path);
56
+ } catch { /* Never delete contents or a replacement; cleanup does not mask refusal. */ }
57
+ }
58
+
59
+ function prepareRoot(fs: RuntimePreflightFs, root: string, rootWasAbsent?: boolean): void {
60
+ try {
61
+ if (rootWasAbsent === undefined) {
62
+ try { fs.lstatSync(root); rootWasAbsent = false; }
63
+ catch (error) {
64
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
65
+ rootWasAbsent = true;
66
+ }
67
+ }
68
+ if (rootWasAbsent) {
69
+ try {
70
+ fs.mkdirSync(root, 0o700); // Exclusive, non-recursive: a missing parent refuses.
71
+ } catch (error) {
72
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
73
+ // Use a concurrently created root, subject to the directory check below.
74
+ }
75
+ }
76
+ const current = fs.lstatSync(root);
77
+ if (!current.isDirectory() || current.isSymbolicLink()) throw new RuntimePreflightError("create");
78
+ } catch {
79
+ throw new RuntimePreflightError("create");
80
+ }
81
+ }
82
+
83
+ function probeInProcess(fs: RuntimePreflightFs, path: string): void {
84
+ let owned: DirectoryIdentity | undefined;
85
+ try {
86
+ try { fs.mkdirSync(path, 0o700); owned = fs.lstatSync(path); }
87
+ catch { throw new RuntimePreflightError("create"); }
88
+ try { fs.rmdirSync(path); }
89
+ catch { throw new RuntimePreflightError("remove"); }
90
+ } finally {
91
+ if (owned) removeOwnedEmptyDirectory(fs, path, owned);
92
+ }
93
+ }
94
+
95
+ function probeChild(runtime: DurableBunRuntime, path: string, nonce: string, spawn: typeof spawnSync): void {
96
+ // The child emits only this invocation's nonce and a fixed result, never paths/errors.
97
+ const script = `const fs = require("node:fs"); const path = ${JSON.stringify(path)};
98
+ let result = "create", owned;
99
+ try { fs.mkdirSync(path, 0o700); owned = fs.lstatSync(path); result = "remove";
100
+ fs.rmdirSync(path); result = "ok";
101
+ } catch { /* result already names the failed step; details never leave the child */ } finally {
102
+ if (owned) { try { const now = fs.lstatSync(path);
103
+ if (now.isDirectory() && !now.isSymbolicLink() && now.dev === owned.dev && now.ino === owned.ino
104
+ && fs.readdirSync(path).length === 0) fs.rmdirSync(path);
105
+ } catch { /* leave any probe entry that cannot be proven ours */ } }
106
+ }
107
+ process.stdout.write(${JSON.stringify(nonce)} + ":" + result);
108
+ process.exitCode = result === "ok" ? 0 : 1;`;
109
+ let child: ReturnType<typeof spawnSync>;
110
+ try {
111
+ child = spawn(runtime.path, ["-e", script], {
112
+ shell: false, windowsHide: true, timeout: RUNTIME_PREFLIGHT_TIMEOUT_MS,
113
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1024,
114
+ });
115
+ } catch { throw new RuntimePreflightError("spawn"); }
116
+ if (child.error) {
117
+ throw new RuntimePreflightError((child.error as NodeJS.ErrnoException).code === "ETIMEDOUT" ? "timeout" : "spawn");
118
+ }
119
+ const ack = String(child.stdout ?? "");
120
+ if (ack === `${nonce}:create` && child.status === 1) throw new RuntimePreflightError("create");
121
+ if (ack === `${nonce}:remove` && child.status === 1) throw new RuntimePreflightError("remove");
122
+ if (ack !== `${nonce}:ok` || child.status !== 0) throw new RuntimePreflightError("protocol");
123
+ }
124
+
125
+ /** Windows admission of ONLY the already selected runtime. No discovery or memoization. */
126
+ export function assertSelectedRuntimeWritable(
127
+ runtime: DurableBunRuntime,
128
+ configDir: string,
129
+ deps: RuntimePreflightOptions = {},
130
+ ): void {
131
+ if ((deps.platform ?? process.platform) !== "win32") return;
132
+ if (runtime.source === "standalone" && runtime.path !== process.execPath) throw new RuntimePreflightError("protocol");
133
+ const fs = deps.fs ?? probeFs;
134
+ // The config root is durable: never delete it by pathname, even on refusal.
135
+ prepareRoot(fs, configDir, deps.rootWasAbsent);
136
+ const nonce = (deps.nonce ?? randomUUID)();
137
+ const path = join(configDir, `.ocx-runtime-probe-${nonce}`);
138
+ if (runtime.source === "standalone") probeInProcess(fs, path);
139
+ else probeChild(runtime, path, nonce, deps.spawnSync ?? spawnSync);
140
+ }
@@ -0,0 +1,20 @@
1
+ import type { spawn } from "node:child_process";
2
+ import type { accessSync, lstatSync, statSync } from "node:fs";
3
+ import type { DesktopCliRecordRead, DesktopCliRecordIssue } from "./desktop-cli-record.mjs";
4
+ export type DesktopHandoffIssue = DesktopCliRecordIssue | "target-invalid" | "target-self" | "target-unusable" | "spawn-failed";
5
+ export type DesktopCliHandoffPlan =
6
+ | { kind: "continue"; reason: "excluded" | "missing" | "disabled" | "target-missing" | "windows-path-only" }
7
+ | { kind: "error"; issue: DesktopHandoffIssue }
8
+ | { kind: "handoff"; target: string };
9
+ export type DesktopCliHandoffExit =
10
+ | { kind: "continue" }
11
+ | { kind: "error"; issue: "spawn-failed" }
12
+ | { kind: "exit"; code: number; signal: NodeJS.Signals | null };
13
+ export function desktopHandoffExcluded(argv: string[], env?: NodeJS.ProcessEnv): boolean;
14
+ export function planDesktopCliHandoff(input?: {
15
+ argv?: string[]; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform;
16
+ recordRead?: DesktopCliRecordRead; selfPaths?: (string | undefined)[];
17
+ }, deps?: { stat?: typeof statSync; access?: typeof accessSync; realpath?: (path: string) => string }): DesktopCliHandoffPlan;
18
+ export function runDesktopCliHandoff(plan: Extract<DesktopCliHandoffPlan, { kind: "handoff" }>, launch: {
19
+ argv: string[]; env: NodeJS.ProcessEnv; proof: string; context: string;
20
+ }, deps?: { spawn?: typeof spawn; lstat?: typeof lstatSync; parent?: Pick<NodeJS.Process, "on" | "removeListener">; platform?: NodeJS.Platform }): Promise<DesktopCliHandoffExit>;
@@ -0,0 +1,92 @@
1
+ import { spawn } from "node:child_process";
2
+ import { accessSync, constants, lstatSync, realpathSync, statSync } from "node:fs";
3
+ import { posix, win32 } from "node:path";
4
+ import { readDesktopCliRecord } from "./desktop-cli-record.mjs";
5
+ import { isCodexCliUpdateInspectionArgv } from "../update/codex-cli-update-launch-policy.mjs";
6
+
7
+ const PROOF_PREFIX = "--ocx-internal-launch-proof=";
8
+
9
+ export function desktopHandoffExcluded(argv, env = process.env) {
10
+ const args = argv.filter(arg => !arg.startsWith(PROOF_PREFIX));
11
+ return env.OCX_NO_DESKTOP_HANDOFF === "1"
12
+ || ["update", "uninstall", "remove"].includes(args[0])
13
+ || (args[0] ?? "").startsWith("__")
14
+ || isCodexCliUpdateInspectionArgv(["node", "ocx.mjs", ...args]);
15
+ }
16
+
17
+ export function planDesktopCliHandoff(input = {}, deps = {}) {
18
+ const platform = input.platform ?? process.platform;
19
+ if (platform === "win32") return { kind: "continue", reason: "windows-path-only" };
20
+ const argv = input.argv ?? process.argv.slice(2);
21
+ const env = input.env ?? process.env;
22
+ if (desktopHandoffExcluded(argv, env)) return { kind: "continue", reason: "excluded" };
23
+ const read = input.recordRead ?? readDesktopCliRecord({ env, platform: input.platform });
24
+ if (read.state === "missing" || read.state === "disabled") {
25
+ return { kind: "continue", reason: read.state };
26
+ }
27
+ if (read.state !== "ready") return { kind: "error", issue: read.issue };
28
+ const api = platform === "win32" ? win32 : posix;
29
+ const target = read.record.cliExecutable;
30
+ if (!api.isAbsolute(target)) return { kind: "error", issue: "target-invalid" };
31
+ const stat = deps.stat ?? statSync;
32
+ const access = deps.access ?? accessSync;
33
+ const realpath = deps.realpath ?? realpathSync;
34
+ try {
35
+ if (!stat(target).isFile()) return { kind: "error", issue: "target-invalid" };
36
+ access(target, platform === "win32" ? constants.F_OK : constants.X_OK);
37
+ const physical = realpath(target);
38
+ const key = path => platform === "win32" ? path.toLowerCase() : path;
39
+ for (const self of input.selfPaths ?? [process.argv[1], process.execPath]) {
40
+ if (!self) continue;
41
+ let own;
42
+ try { own = realpath(self); }
43
+ catch (error) { if (error?.code === "ENOENT") continue; throw error; }
44
+ if (key(own) === key(physical)) return { kind: "error", issue: "target-self" };
45
+ }
46
+ return { kind: "handoff", target: physical };
47
+ } catch (error) {
48
+ return error?.code === "ENOENT"
49
+ ? { kind: "continue", reason: "target-missing" }
50
+ : { kind: "error", issue: "target-unusable" };
51
+ }
52
+ }
53
+
54
+ export function runDesktopCliHandoff(plan, launch, deps = {}) {
55
+ const spawnChild = deps.spawn ?? spawn;
56
+ const parent = deps.parent ?? process;
57
+ const platform = deps.platform ?? process.platform;
58
+ const lstat = deps.lstat ?? lstatSync;
59
+ const spawnFailure = error => {
60
+ if (error?.code === "ENOENT") {
61
+ try { lstat(plan.target); }
62
+ catch (checkError) { if (checkError?.code === "ENOENT") return { kind: "continue" }; }
63
+ }
64
+ return { kind: "error", issue: "spawn-failed" };
65
+ };
66
+ const argv = launch.argv.filter(arg => !arg.startsWith(PROOF_PREFIX));
67
+ const env = { ...launch.env, OCX_NO_DESKTOP_HANDOFF: "1", OCX_NODE_LAUNCH_CONTEXT: launch.context };
68
+ delete env.OCX_BUN_RUNTIME_SOURCE;
69
+ delete env.OCX_BUN_RUNTIME_PATH;
70
+ return new Promise(resolveExit => {
71
+ let child;
72
+ try {
73
+ child = spawnChild(plan.target, [`${PROOF_PREFIX}${launch.proof}`, ...argv], {
74
+ stdio: "inherit", shell: false, windowsHide: true, env,
75
+ });
76
+ } catch (error) {
77
+ resolveExit(spawnFailure(error));
78
+ return;
79
+ }
80
+ const forwarded = platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"];
81
+ const handlers = forwarded.map(signal => {
82
+ const handler = () => { try { child.kill(signal); } catch { /* child already exited */ } };
83
+ parent.on(signal, handler);
84
+ return [signal, handler];
85
+ });
86
+ const clear = () => { for (const [signal, handler] of handlers) parent.removeListener(signal, handler); };
87
+ let done = false;
88
+ const finish = result => { if (done) return; done = true; clear(); resolveExit(result); };
89
+ child.once("error", error => finish(spawnFailure(error)));
90
+ child.once("exit", (code, signal) => finish({ kind: "exit", code: code ?? 1, signal }));
91
+ });
92
+ }
@@ -0,0 +1,29 @@
1
+ import type { Stats } from "node:fs";
2
+
3
+ export const DESKTOP_CLI_RECORD_MAX_BYTES: number;
4
+ export type DesktopCliRecord = Readonly<{
5
+ platform: "darwin" | "win32" | "linux";
6
+ kind: "macos-app" | "windows-install" | "linux-deb";
7
+ cliExecutable: string;
8
+ }>;
9
+ export type DesktopCliRecordIssue = "record-invalid" | "record-too-large" | "record-pending" | "record-unreadable" | "record-unsafe";
10
+ export type DesktopCliRecordRead =
11
+ | { state: "missing"; path: string }
12
+ | { state: "disabled"; path: string; cleanupPending: boolean }
13
+ | { state: "ready"; path: string; record: DesktopCliRecord }
14
+ | { state: "invalid" | "unreadable"; path: string; issue: DesktopCliRecordIssue };
15
+ export type DesktopCliRecordOptions = {
16
+ platform?: NodeJS.Platform;
17
+ env?: NodeJS.ProcessEnv;
18
+ home?: string;
19
+ recordPath?: string;
20
+ };
21
+ export function desktopCliRecordPath(options?: DesktopCliRecordOptions): string;
22
+ export type DesktopCliRecordDeps = {
23
+ lstat?: (path: string) => Stats;
24
+ fstat?: (fd: number) => Stats;
25
+ open?: (path: string, flags: number) => number;
26
+ euid?: number;
27
+ checkAcl?: (directory: string, path: string) => boolean;
28
+ };
29
+ export function readDesktopCliRecord(options?: DesktopCliRecordOptions, deps?: DesktopCliRecordDeps): DesktopCliRecordRead;
@@ -0,0 +1,102 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, posix, resolve, win32 } from "node:path";
5
+
6
+ export const DESKTOP_CLI_RECORD_MAX_BYTES = 64 * 1024;
7
+
8
+ export function desktopCliRecordPath(options = {}) {
9
+ const platform = options.platform ?? process.platform;
10
+ const env = options.env ?? process.env;
11
+ const home = options.home ?? (platform === "win32" ? env.USERPROFILE || homedir() : homedir());
12
+ return (platform === "win32" ? win32 : posix).join(home, ".opencodex-desktop", "cli.json");
13
+ }
14
+
15
+ // Keep these lexical rules aligned with Rust's absolute_for in cli_command_record.rs.
16
+ function validTargetPath(path, platform) {
17
+ if ([...path].length > 4096 || /[\x00\r\n]/.test(path)) return false;
18
+ const absolute = platform === "win32"
19
+ ? /^[A-Za-z]:[/\\]/.test(path) || path.startsWith("\\\\") || path.startsWith("//")
20
+ : path.startsWith("/");
21
+ return absolute && !path.split(platform === "win32" ? /[/\\]/ : /\//)
22
+ .some(part => part === "." || part === "..");
23
+ }
24
+
25
+ function noMacAcl(directory, path) {
26
+ const result = spawnSync("/bin/ls", ["-lde", directory, path], {
27
+ encoding: "utf8", timeout: 1000, shell: false, windowsHide: true,
28
+ env: { ...process.env, LC_ALL: "C" },
29
+ });
30
+ if (result.error || result.status !== 0 || result.stderr) return false;
31
+ // Each path has one metadata line; ACL entries add numbered lines and a "+" marker.
32
+ const lines = result.stdout.trimEnd().split("\n");
33
+ return lines.length === 2 && lines.every(line => /^[d-][rwxStTs-]{9}[.@]?\s/.test(line));
34
+ }
35
+
36
+ export function readDesktopCliRecord(options = {}, deps = {}) {
37
+ const platform = options.platform ?? process.platform;
38
+ const path = options.recordPath ?? desktopCliRecordPath(options);
39
+ const directory = dirname(resolve(path));
40
+ const lstat = deps.lstat ?? lstatSync;
41
+ const fstat = deps.fstat ?? fstatSync;
42
+ const open = deps.open ?? openSync;
43
+ const unsafe = () => ({ state: "invalid", path, issue: "record-unsafe" });
44
+ const posixHost = platform !== "win32";
45
+ let fd;
46
+ let checkingSafety = true;
47
+ let absenceAllowed = true;
48
+ try {
49
+ const dirInfo = lstat(directory);
50
+ if (dirInfo.isSymbolicLink() || !dirInfo.isDirectory()) return unsafe();
51
+ const uid = posixHost ? deps.euid ?? process.geteuid?.() : undefined;
52
+ if (posixHost && (uid === undefined || dirInfo.uid !== uid || (dirInfo.mode & 0o077) !== 0)) return unsafe();
53
+ const fileInfo = lstat(path);
54
+ absenceAllowed = false;
55
+ if (fileInfo.isSymbolicLink() || !fileInfo.isFile()
56
+ || (posixHost && (fileInfo.uid !== uid || (fileInfo.mode & 0o077) !== 0))) return unsafe();
57
+ const flags = constants.O_RDONLY | (posixHost ? constants.O_NOFOLLOW | constants.O_NONBLOCK : 0);
58
+ fd = open(path, flags);
59
+ const info = fstat(fd);
60
+ if (!info.isFile() || ["dev", "ino", "uid", "mode"].some(key => info[key] !== fileInfo[key])) return unsafe();
61
+ if (platform === "darwin" && !(deps.checkAcl ?? noMacAcl)(directory, resolve(path))) return unsafe();
62
+ checkingSafety = false;
63
+ if (info.size > DESKTOP_CLI_RECORD_MAX_BYTES) {
64
+ return { state: "invalid", path, issue: "record-too-large" };
65
+ }
66
+ const bytes = Buffer.alloc(DESKTOP_CLI_RECORD_MAX_BYTES + 1);
67
+ let used = 0;
68
+ while (used < bytes.length) {
69
+ const n = readSync(fd, bytes, used, bytes.length - used, null);
70
+ if (n === 0) break;
71
+ used += n;
72
+ }
73
+ if (used > DESKTOP_CLI_RECORD_MAX_BYTES) {
74
+ return { state: "invalid", path, issue: "record-too-large" };
75
+ }
76
+ let value;
77
+ try { value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, used))); }
78
+ catch { return { state: "invalid", path, issue: "record-invalid" }; }
79
+ if (!value || typeof value !== "object" || Array.isArray(value)
80
+ || value.version !== 1 || typeof value.enabled !== "boolean") {
81
+ return { state: "invalid", path, issue: "record-invalid" };
82
+ }
83
+ if (!value.enabled) return { state: "disabled", path, cleanupPending: value.pending != null };
84
+ if (value.pending != null) return { state: "invalid", path, issue: "record-pending" };
85
+ const bundle = value.bundle;
86
+ const kind = { darwin: "macos-app", win32: "windows-install", linux: "linux-deb" }[platform];
87
+ if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)
88
+ || !kind || bundle.platform !== platform || bundle.kind !== kind
89
+ || typeof bundle.cliExecutable !== "string" || !validTargetPath(bundle.cliExecutable, platform)) {
90
+ return { state: "invalid", path, issue: "record-invalid" };
91
+ }
92
+ return { state: "ready", path, record: {
93
+ platform, kind, cliExecutable: bundle.cliExecutable,
94
+ } };
95
+ } catch (error) {
96
+ return error?.code === "ENOENT" && absenceAllowed
97
+ ? { state: "missing", path }
98
+ : checkingSafety ? unsafe() : { state: "unreadable", path, issue: "record-unreadable" };
99
+ } finally {
100
+ if (fd !== undefined) closeSync(fd);
101
+ }
102
+ }
@@ -194,6 +194,8 @@ export interface RequestExecutionBudget extends TransientSendBudget {
194
194
  readonly alternateTargetSends: number;
195
195
  readonly targetTransitions: number;
196
196
  readonly lastTargetKey: string | undefined;
197
+ /** Seed a fresh derived scope with the actual endpoint of its already-paid initial send. */
198
+ bindPrepaidTarget?(targetKey: string): void;
197
199
  /**
198
200
  * Spend one operator-granted replacement for an AMBIGUOUS failure of this logical request,
199
201
  * up to `limit`. False once the request has none left.
@@ -390,6 +392,7 @@ function createAmbiguousResendGrant(): Pick<SharedSendLedger, "claimAmbiguousRes
390
392
  };
391
393
  }
392
394
 
395
+ /** Keep target-local transition state over the shared ledger and its single-use bookings. */
393
396
  function createRequestExecutionBudgetWithLedger(
394
397
  policy: RequestExecutionBudgetPolicy,
395
398
  logicalRequestId: string | undefined,
@@ -441,6 +444,11 @@ function createRequestExecutionBudgetWithLedger(
441
444
  get alternateTargetSends() { return alternateTargetSends; },
442
445
  get targetTransitions() { return targetTransitions; },
443
446
  get lastTargetKey() { return lastTargetKey; },
447
+ /** Seed only a fresh scope; never reset recovery history on an existing target. */
448
+ bindPrepaidTarget(targetKey: string): void {
449
+ // Only a fresh scope may bind: this is initial identity, never a recovery rebase.
450
+ if (lastTargetKey === undefined) lastTargetKey = targetKey;
451
+ },
444
452
  remainingBaseSends(cap: number): number {
445
453
  const capped = Number.isFinite(cap) ? Math.trunc(cap) : 0;
446
454
  return Math.max(0, Math.min(capped, policy.baseSendAllowance - counter.spent));
@@ -1,4 +1,4 @@
1
- import { lstatSync, realpathSync } from "node:fs";
1
+ import { lstatSync, readdirSync, realpathSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  // POSIX path operations on purpose: every path this module classifies is a macOS path. The
4
4
  // host's own path module would build backslash or drive-letter paths on Windows and make the
@@ -15,7 +15,11 @@ const { join, resolve } = posix;
15
15
  * synced folder turns ordinary requests into intermittent 502s (#6314). This only answers
16
16
  * "is it likely synced"; the guard itself stays strict.
17
17
  */
18
- export type SyncedStateLocation = "icloud-drive" | "file-provider" | "icloud-desktop-documents";
18
+ export type SyncedStateLocation =
19
+ | "icloud-drive"
20
+ | "file-provider"
21
+ | "icloud-desktop-documents"
22
+ | "google-drive-desktop-documents";
19
23
 
20
24
  export interface SyncedStateLocationProbe {
21
25
  readonly platform?: NodeJS.Platform;
@@ -24,23 +28,34 @@ export interface SyncedStateLocationProbe {
24
28
  readonly realpath?: (path: string) => string;
25
29
  /** Whether a directory entry exists at the path, without following it. */
26
30
  readonly entryExists?: (path: string) => boolean;
31
+ /** Entry names in a directory. An unreadable directory is empty, not an error. */
32
+ readonly listEntries?: (dir: string) => readonly string[];
27
33
  }
28
34
 
29
35
  const defaultEntryExists = (path: string): boolean => {
30
36
  try { lstatSync(path); return true; } catch { return false; }
31
37
  };
32
38
 
39
+ const defaultListEntries = (dir: string): readonly string[] => {
40
+ try { return readdirSync(dir); } catch { return []; }
41
+ };
42
+
33
43
  /**
34
44
  * Advisory only. Apple publishes no API a CLI can ask, so this reads the observed layout:
35
45
  * iCloud Drive lives under ~/Library/Mobile Documents, File Provider clients (OneDrive, Dropbox,
36
46
  * Google Drive) under ~/Library/CloudStorage, and with "Desktop & Documents Folders" turned on
37
- * iCloud Drive holds a Desktop/Documents entry of its own. A false positive costs one warning
38
- * line; nothing is refused on the strength of it.
47
+ * iCloud Drive holds a Desktop/Documents entry of its own. Google Drive for desktop can also
48
+ * sync the native Desktop and Documents folders without moving them under CloudStorage (#6314).
49
+ * The cheap signals for that are a DriveFS directory or a `GoogleDrive-*` CloudStorage folder.
50
+ * They do not read which folders the app was told to sync, so Drive being installed can warn
51
+ * even when Documents is not selected. A false positive costs one warning line; nothing is
52
+ * refused on the strength of it.
39
53
  */
40
54
  export function syncedStateLocation(dir: string, probe: SyncedStateLocationProbe = {}): SyncedStateLocation | undefined {
41
55
  if ((probe.platform ?? process.platform) !== "darwin") return undefined;
42
56
  const realpath = probe.realpath ?? ((path: string) => realpathSync.native(path));
43
57
  const entryExists = probe.entryExists ?? defaultEntryExists;
58
+ const listEntries = probe.listEntries ?? defaultListEntries;
44
59
  const canonical = (path: string): string => {
45
60
  try { return realpath(path); } catch { return resolve(path); }
46
61
  };
@@ -56,17 +71,42 @@ export function syncedStateLocation(dir: string, probe: SyncedStateLocationProbe
56
71
  if (within(mobileDocuments)) return "icloud-drive";
57
72
  if (within(join(home, "Library", "CloudStorage"))) return "file-provider";
58
73
  for (const folder of ["Desktop", "Documents"]) {
59
- if (within(join(home, folder)) && entryExists(join(mobileDocuments, "com~apple~CloudDocs", folder))) {
60
- return "icloud-desktop-documents";
74
+ if (!within(join(home, folder))) continue;
75
+ if (entryExists(join(mobileDocuments, "com~apple~CloudDocs", folder))) return "icloud-desktop-documents";
76
+ if (googleDriveAppearsToSyncDesktopDocuments(home, entryExists, listEntries)) {
77
+ return "google-drive-desktop-documents";
61
78
  }
62
79
  }
63
80
  return undefined;
64
81
  }
65
82
 
83
+ /**
84
+ * Presence only. Drive for desktop keeps its config under DriveFS and, on current macOS,
85
+ * a `GoogleDrive-*` directory in CloudStorage. Either one means the app is in use. Missing
86
+ * or unreadable markers are "not detected", never a startup failure.
87
+ */
88
+ function googleDriveAppearsToSyncDesktopDocuments(
89
+ home: string,
90
+ entryExists: (path: string) => boolean,
91
+ listEntries: (dir: string) => readonly string[],
92
+ ): boolean {
93
+ const driveFs = join(home, "Library", "Application Support", "Google", "DriveFS");
94
+ try {
95
+ if (entryExists(driveFs)) return true;
96
+ } catch { /* unreadable marker */ }
97
+ try {
98
+ return listEntries(join(home, "Library", "CloudStorage"))
99
+ .some((name) => name.toLowerCase().startsWith("googledrive-"));
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
66
105
  const LOCATION_LABEL: Record<SyncedStateLocation, string> = {
67
106
  "icloud-drive": "inside iCloud Drive",
68
107
  "file-provider": "inside a cloud-storage (File Provider) folder",
69
108
  "icloud-desktop-documents": "in Desktop or Documents, which iCloud Drive appears to sync",
109
+ "google-drive-desktop-documents": "in Desktop or Documents, which Google Drive appears to sync",
70
110
  };
71
111
 
72
112
  /** The startup warning. Names the location kind, never the path. */
@@ -20,7 +20,7 @@
20
20
  * how this incident happened.
21
21
  */
22
22
  import { homedir } from "node:os";
23
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
23
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
24
24
  import { realpathSync } from "node:fs";
25
25
  import { fileURLToPath } from "node:url";
26
26
 
@@ -31,6 +31,8 @@ const GUARD_ENV = "OCX_TEST_HOME_GUARD";
31
31
  * loads, so the true home is only knowable from this hand-off.
32
32
  */
33
33
  const REAL_HOME_ENV = "OCX_REAL_HOME";
34
+ /** Set by `scripts/test.ts` to the developer's own Claude config directory before it sandboxes one. */
35
+ const REAL_CLAUDE_CONFIG_DIR_ENV = "OCX_REAL_CLAUDE_CONFIG_DIR";
34
36
 
35
37
  /**
36
38
  * Resolve symlinks so two spellings of one location compare equal — macOS hands out
@@ -62,6 +64,25 @@ function canonicalize(path: string): string {
62
64
  const REAL_HOME = process.env[REAL_HOME_ENV]?.trim() || homedir();
63
65
  const PROTECTED_HOME = canonicalize(join(REAL_HOME, ".opencodex"));
64
66
  const PROTECTED_CODEX_HOME = canonicalize(join(REAL_HOME, ".codex"));
67
+ /**
68
+ * The developer's Claude config directory: the default `<real home>/.claude`, plus their own
69
+ * `CLAUDE_CONFIG_DIR` when they set one. `scripts/test.ts` hands the original over as
70
+ * `OCX_REAL_CLAUDE_CONFIG_DIR` because its child already starts with a sandboxed
71
+ * `CLAUDE_CONFIG_DIR`; a bare run imports this module before the preload rewrites the
72
+ * environment, so `CLAUDE_CONFIG_DIR` still holds the developer's value here.
73
+ *
74
+ * Claude agent sync prunes generated `agents/ocx-*.md`, and a bare `bun test` once did that
75
+ * to a live directory: Bun's `os.homedir()` ignores a HOME rewritten after startup (#6775).
76
+ */
77
+ const REAL_CLAUDE_CONFIG_DIRS = [...new Set([
78
+ join(REAL_HOME, ".claude"),
79
+ process.env[REAL_CLAUDE_CONFIG_DIR_ENV]?.trim() || process.env.CLAUDE_CONFIG_DIR?.trim() || join(REAL_HOME, ".claude"),
80
+ ])];
81
+ const PROTECTED_CLAUDE_TREES = REAL_CLAUDE_CONFIG_DIRS.map(path => ({
82
+ path: canonicalize(path),
83
+ lexical: resolve(path),
84
+ label: "the real Claude config directory",
85
+ }));
65
86
  /**
66
87
  * `~/Library/LaunchAgents` needs its own entry because HOME isolation does not reach it:
67
88
  * `os.homedir()` reads the password database, not `$HOME`, so a macOS test that rewrites
@@ -83,6 +104,11 @@ export function protectedCodexHomeForTests(): string {
83
104
  return PROTECTED_CODEX_HOME;
84
105
  }
85
106
 
107
+ /** The Claude config directories this process protects. Exported for the guard's own tests. */
108
+ export function protectedClaudeConfigDirsForTests(): readonly string[] {
109
+ return PROTECTED_CLAUDE_TREES.map(tree => tree.path);
110
+ }
111
+
86
112
  export function isTestHomeGuardArmed(): boolean {
87
113
  return process.env[GUARD_ENV] === "1";
88
114
  }
@@ -155,6 +181,54 @@ export function assertNotRealCodexHomeUnderTest(dir: string): void {
155
181
  );
156
182
  }
157
183
 
184
+ /**
185
+ * Throw when an armed test process is about to write or prune inside the real Claude config
186
+ * directory. Unlike the OpenCodex and Codex homes this refuses descendants too, and callers pass
187
+ * every directory they are about to touch (the config root AND its `agents`/`cache` child), so a
188
+ * sandbox child that is a link into the real directory is judged by where it resolves.
189
+ *
190
+ * The protected roots are resolved at check time rather than at import, so a custom directory
191
+ * created after this module loaded still compares by its current identity. On macOS and Windows,
192
+ * whose default filesystems ignore case, paths compare case-insensitively: `.CLAUDE` is the same
193
+ * directory there. Comparing too broadly only refuses more, which is the safe direction here.
194
+ *
195
+ * Call FIRST, outside any best-effort catch, so a refusal is visible and nothing is touched.
196
+ */
197
+ export function assertNotRealClaudeConfigUnderTest(...paths: string[]): void {
198
+ if (!isTestHomeGuardArmed()) return;
199
+ const roots = currentClaudeConfigRoots();
200
+ for (const path of paths) {
201
+ for (const candidate of [canonicalize(path), resolve(path)]) {
202
+ for (const root of roots) {
203
+ if (!isSameOrInsideIgnoringPlatformCase(root, candidate)) continue;
204
+ throw new Error(
205
+ `refusing to write the real Claude config directory (${root}) from a test process. `
206
+ + "Set CLAUDE_CONFIG_DIR to a temp directory, or pass an explicit config directory "
207
+ + "(for a server, startServer's managementApi.claudeAgentConfigDir).",
208
+ );
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ function currentClaudeConfigRoots(): string[] {
215
+ return REAL_CLAUDE_CONFIG_DIRS.flatMap(path => [canonicalize(path), resolve(path)]);
216
+ }
217
+
218
+ function foldPlatformCase(path: string): string {
219
+ return process.platform === "darwin" || process.platform === "win32" ? path.toLowerCase() : path;
220
+ }
221
+
222
+ function isSameOrInsideIgnoringPlatformCase(root: string, candidate: string): boolean {
223
+ const parent = foldPlatformCase(root);
224
+ const child = foldPlatformCase(candidate);
225
+ if (parent === child) return true;
226
+ const rel = relative(parent, child);
227
+ // Only a leading ".." COMPONENT leaves the root, split on this platform's separator: a child
228
+ // named "..fixture", or "..\\fixture" on POSIX where a backslash is a filename character, is inside.
229
+ return rel !== "" && !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`);
230
+ }
231
+
158
232
  /**
159
233
  * The trees a removal must never reach, and the reason each one is named.
160
234
  *
@@ -172,6 +246,7 @@ const PROTECTED_TREES: ReadonlyArray<{ path: string; lexical: string; label: str
172
246
  lexical: resolve(join(REAL_HOME, "Library", "LaunchAgents")),
173
247
  label: "the real LaunchAgents directory",
174
248
  },
249
+ ...PROTECTED_CLAUDE_TREES,
175
250
  ];
176
251
  const PROTECTED_REAL_HOME = canonicalize(REAL_HOME);
177
252
  const LEXICAL_REAL_HOME = resolve(REAL_HOME);
@@ -258,6 +333,17 @@ export function protectedRemovalReason(target: string): string | null {
258
333
  if (isInside(candidate, protectedPath)) return `an ancestor of ${tree.label} (${protectedPath})`;
259
334
  }
260
335
  }
336
+ // The Claude directory is judged like its writer guard: roots resolved now, case folded
337
+ // where the filesystem folds it, so an alias created after import is still refused.
338
+ for (const root of currentClaudeConfigRoots()) {
339
+ if (isSameOrInsideIgnoringPlatformCase(root, candidate)) {
340
+ // Same narrow lift as the trees above: content of the running checkout when that
341
+ // checkout itself lives inside the directory. Never the root itself.
342
+ if (foldPlatformCase(root) !== foldPlatformCase(candidate) && isOwnCheckoutContent(root, candidate)) continue;
343
+ return `the real Claude config directory or a path inside it (${root})`;
344
+ }
345
+ if (isSameOrInsideIgnoringPlatformCase(candidate, root)) return `an ancestor of the real Claude config directory (${root})`;
346
+ }
261
347
  }
262
348
  return null;
263
349
  }