@gajae-code/coding-agent 0.11.6 → 0.11.8

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 (259) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/dist/types/capability/index.d.ts +10 -20
  3. package/dist/types/capability/types.d.ts +3 -0
  4. package/dist/types/cli/args.d.ts +0 -2
  5. package/dist/types/commands/daemon.d.ts +2 -2
  6. package/dist/types/commands/sdk.d.ts +20 -2
  7. package/dist/types/config/keybindings.d.ts +34 -4
  8. package/dist/types/config/model-profile-activation.d.ts +4 -3
  9. package/dist/types/config/model-profile-contract.d.ts +38 -0
  10. package/dist/types/config/settings-schema.d.ts +70 -7
  11. package/dist/types/edit/modes/patch.d.ts +13 -0
  12. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  13. package/dist/types/eval/py/executor.d.ts +1 -1
  14. package/dist/types/eval/py/runner-artifact.d.ts +8 -0
  15. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  16. package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
  17. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +6 -5
  18. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
  19. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +43 -5
  20. package/dist/types/main.d.ts +32 -1
  21. package/dist/types/modes/components/btw-panel.d.ts +1 -0
  22. package/dist/types/modes/components/model-selector.d.ts +2 -0
  23. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  24. package/dist/types/modes/components/tool-status-header.d.ts +4 -2
  25. package/dist/types/modes/components/welcome.d.ts +2 -0
  26. package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
  27. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  28. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  29. package/dist/types/modes/interactive-mode.d.ts +7 -2
  30. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  31. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  32. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  33. package/dist/types/modes/types.d.ts +4 -0
  34. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  35. package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
  36. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  37. package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
  38. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
  39. package/dist/types/sdk/broker/broker.d.ts +2 -0
  40. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  41. package/dist/types/sdk/bus/chat-daemon-control.d.ts +6 -1
  42. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  43. package/dist/types/sdk/bus/config.d.ts +2 -0
  44. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  45. package/dist/types/sdk/bus/index.d.ts +26 -1
  46. package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
  47. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  48. package/dist/types/sdk/bus/notification-service.d.ts +34 -0
  49. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
  50. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +18 -3
  51. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  52. package/dist/types/sdk/bus/telegram-daemon.d.ts +189 -4
  53. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  54. package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
  55. package/dist/types/sdk/client/discovery.d.ts +2 -0
  56. package/dist/types/sdk/client/liveness.d.ts +7 -0
  57. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  58. package/dist/types/sdk/host/host.d.ts +1 -1
  59. package/dist/types/sdk/host/query/handlers.d.ts +9 -0
  60. package/dist/types/sdk/index.d.ts +2 -0
  61. package/dist/types/sdk/prompt-status.d.ts +88 -0
  62. package/dist/types/sdk/session-directory.d.ts +1 -1
  63. package/dist/types/sdk/startup-capability.d.ts +3 -0
  64. package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
  65. package/dist/types/sdk/transport/index.d.ts +12 -0
  66. package/dist/types/sdk/transport/relay.d.ts +43 -0
  67. package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
  68. package/dist/types/sdk/transport/socket.d.ts +7 -0
  69. package/dist/types/sdk/transport/stdio.d.ts +3 -0
  70. package/dist/types/session/agent-session.d.ts +30 -5
  71. package/dist/types/session/btw-contract.d.ts +16 -0
  72. package/dist/types/session/internal/managed-session-scope.d.ts +32 -9
  73. package/dist/types/session/internal/managed-session-storage.d.ts +27 -10
  74. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  75. package/dist/types/session/session-manager.d.ts +25 -4
  76. package/dist/types/session/session-storage.d.ts +18 -0
  77. package/dist/types/setup/credential-import.d.ts +5 -1
  78. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  79. package/dist/types/ssh/utils.d.ts +1 -0
  80. package/dist/types/task/discovery.d.ts +3 -1
  81. package/dist/types/task/index.d.ts +3 -1
  82. package/dist/types/task/receipt.d.ts +2 -0
  83. package/dist/types/task/types.d.ts +94 -0
  84. package/dist/types/tools/ask.d.ts +454 -174
  85. package/dist/types/tools/fetch.d.ts +5 -8
  86. package/dist/types/tools/image-gen.d.ts +63 -1
  87. package/dist/types/tools/index.d.ts +4 -0
  88. package/dist/types/tools/skill-discovery.d.ts +7 -0
  89. package/dist/types/tools/sqlite-reader.d.ts +7 -0
  90. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  91. package/dist/types/utils/shell-snapshot.d.ts +12 -0
  92. package/dist/types/web/insane/bridge.d.ts +1 -0
  93. package/dist/types/web/insane/url-guard.d.ts +17 -0
  94. package/package.json +8 -8
  95. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  96. package/scripts/dogfood-repository-binding.ts +209 -0
  97. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  98. package/scripts/run-test-manifest.ts +4 -4
  99. package/src/capability/index.ts +50 -42
  100. package/src/capability/types.ts +3 -0
  101. package/src/cli/args.ts +2 -92
  102. package/src/cli/notify-cli.ts +5 -38
  103. package/src/cli/setup-cli.ts +10 -4
  104. package/src/cli.ts +52 -9
  105. package/src/commands/daemon.ts +22 -5
  106. package/src/commands/sdk.ts +44 -11
  107. package/src/config/keybindings.ts +158 -25
  108. package/src/config/model-profile-activation.ts +7 -18
  109. package/src/config/model-profile-contract.ts +161 -0
  110. package/src/config/model-profiles.ts +18 -0
  111. package/src/config/model-registry.ts +11 -1
  112. package/src/config/settings-schema.ts +73 -7
  113. package/src/coordinator-mcp/model-preset.ts +21 -74
  114. package/src/defaults/gjc/skills/deep-interview/SKILL.md +2 -2
  115. package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
  116. package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
  117. package/src/discovery/ssh.ts +12 -1
  118. package/src/edit/index.ts +3 -6
  119. package/src/edit/modes/patch.ts +38 -1
  120. package/src/edit/modes/replace.ts +42 -0
  121. package/src/edit/path-mutation-lock.ts +112 -0
  122. package/src/eval/py/executor.ts +30 -11
  123. package/src/eval/py/kernel.ts +1 -21
  124. package/src/eval/py/prelude.py +2 -2
  125. package/src/eval/py/runner-artifact.ts +84 -0
  126. package/src/eval/py/tool-bridge.ts +34 -18
  127. package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
  128. package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
  129. package/src/gjc-runtime/launch-tmux.ts +1 -1
  130. package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
  131. package/src/gjc-runtime/ralplan-runtime.ts +75 -3
  132. package/src/gjc-runtime/repository-binding.ts +267 -0
  133. package/src/gjc-runtime/session-state-sidecar.ts +25 -9
  134. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
  135. package/src/gjc-runtime/tmux-sessions.ts +45 -6
  136. package/src/gjc-runtime/ultragoal-guard.ts +93 -15
  137. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
  138. package/src/gjc-runtime/ultragoal-runtime.ts +456 -31
  139. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  140. package/src/gjc-runtime/workflow-manifest.ts +23 -0
  141. package/src/internal-urls/docs-index.generated.ts +12 -10
  142. package/src/internal-urls/local-protocol.ts +64 -21
  143. package/src/internal-urls/mcp-protocol.ts +73 -11
  144. package/src/main.ts +111 -37
  145. package/src/modes/DESIGN.md +5 -0
  146. package/src/modes/acp/acp-agent.ts +50 -16
  147. package/src/modes/action-registry.ts +1 -1
  148. package/src/modes/components/btw-panel.ts +82 -16
  149. package/src/modes/components/model-selector.ts +73 -11
  150. package/src/modes/components/plan-preview-overlay.ts +1 -1
  151. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  152. package/src/modes/components/tool-status-header.ts +25 -4
  153. package/src/modes/components/welcome.ts +22 -18
  154. package/src/modes/controllers/btw-controller.ts +119 -36
  155. package/src/modes/controllers/command-controller.ts +97 -54
  156. package/src/modes/controllers/event-controller.ts +10 -0
  157. package/src/modes/controllers/input-controller.ts +38 -2
  158. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  159. package/src/modes/controllers/selector-controller.ts +149 -63
  160. package/src/modes/interactive-mode.ts +57 -34
  161. package/src/modes/prompt-action-autocomplete.ts +32 -13
  162. package/src/modes/prompt-suggestion-controller.ts +96 -0
  163. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  164. package/src/modes/types.ts +4 -0
  165. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  166. package/src/modes/utils/ui-helpers.ts +5 -3
  167. package/src/prompts/agents/architect.md +0 -1
  168. package/src/prompts/agents/critic.md +0 -1
  169. package/src/prompts/system/btw-user.md +3 -8
  170. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  171. package/src/prompts/system/system-prompt.md +6 -4
  172. package/src/prompts/tools/cron.md +3 -1
  173. package/src/prompts/tools/skill-discovery.md +1 -0
  174. package/src/runtime-mcp/client.ts +41 -55
  175. package/src/runtime-mcp/content-limits.ts +71 -0
  176. package/src/runtime-mcp/json-rpc.ts +23 -9
  177. package/src/runtime-mcp/manager.ts +51 -53
  178. package/src/runtime-mcp/oauth-flow.ts +0 -72
  179. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  180. package/src/runtime-mcp/transports/http.ts +63 -22
  181. package/src/sdk/broker/broker.ts +34 -20
  182. package/src/sdk/broker/lifecycle.ts +185 -36
  183. package/src/sdk/bus/chat-daemon-control.ts +19 -5
  184. package/src/sdk/bus/config-commands.ts +2 -2
  185. package/src/sdk/bus/config.ts +9 -1
  186. package/src/sdk/bus/conversation-store.ts +40 -6
  187. package/src/sdk/bus/index.ts +244 -70
  188. package/src/sdk/bus/lifecycle-commands.ts +63 -10
  189. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  190. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  191. package/src/sdk/bus/notification-orchestration.ts +12 -0
  192. package/src/sdk/bus/notification-service.ts +68 -20
  193. package/src/sdk/bus/prompt-reconciliation.ts +239 -0
  194. package/src/sdk/bus/recent-activity.ts +90 -26
  195. package/src/sdk/bus/telegram-daemon-contract.ts +18 -3
  196. package/src/sdk/bus/telegram-daemon-control.ts +147 -35
  197. package/src/sdk/bus/telegram-daemon.ts +2463 -450
  198. package/src/sdk/bus/telegram-reference.ts +20 -2
  199. package/src/sdk/bus/topic-registry.ts +451 -12
  200. package/src/sdk/cli/session-cli.ts +15 -2
  201. package/src/sdk/client/discovery.ts +19 -3
  202. package/src/sdk/client/liveness.ts +49 -0
  203. package/src/sdk/host/control/dispatch.ts +1 -1
  204. package/src/sdk/host/control/operations.ts +1 -1
  205. package/src/sdk/host/host.ts +2 -6
  206. package/src/sdk/host/query/handlers.ts +70 -5
  207. package/src/sdk/index.ts +12 -0
  208. package/src/sdk/prompt-status.ts +97 -0
  209. package/src/sdk/protocol/operation-inventory.generated.json +58 -0
  210. package/src/sdk/protocol/operation-registry.ts +18 -4
  211. package/src/sdk/session-directory.ts +5 -1
  212. package/src/sdk/session.ts +31 -5
  213. package/src/sdk/startup-capability.ts +9 -1
  214. package/src/sdk/transport/auth-preface.ts +82 -0
  215. package/src/sdk/transport/index.ts +18 -0
  216. package/src/sdk/transport/relay.ts +252 -0
  217. package/src/sdk/transport/serve-cli.ts +105 -0
  218. package/src/sdk/transport/socket.ts +197 -0
  219. package/src/sdk/transport/stdio.ts +17 -0
  220. package/src/session/agent-session.ts +292 -154
  221. package/src/session/blob-store.ts +25 -7
  222. package/src/session/btw-contract.ts +59 -0
  223. package/src/session/internal/managed-session-scope.ts +1163 -137
  224. package/src/session/internal/managed-session-storage.ts +397 -234
  225. package/src/session/internal/native-publish-outcome.ts +266 -0
  226. package/src/session/session-manager.ts +620 -403
  227. package/src/session/session-storage.ts +128 -11
  228. package/src/setup/credential-import.ts +28 -3
  229. package/src/setup/provider-onboarding.ts +30 -1
  230. package/src/setup/provider-presets.json +2 -1
  231. package/src/slash-commands/builtin-registry.ts +1 -1
  232. package/src/ssh/utils.ts +14 -0
  233. package/src/task/discovery.ts +8 -2
  234. package/src/task/gjc-command.ts +2 -2
  235. package/src/task/index.ts +121 -15
  236. package/src/task/receipt.ts +3 -0
  237. package/src/task/types.ts +34 -3
  238. package/src/tools/ask.ts +199 -89
  239. package/src/tools/ast-edit.ts +2 -2
  240. package/src/tools/bash-pty-selection.ts +2 -2
  241. package/src/tools/browser/screenshot-format.ts +6 -1
  242. package/src/tools/cron.ts +1 -1
  243. package/src/tools/fetch.ts +13 -131
  244. package/src/tools/image-gen.ts +476 -23
  245. package/src/tools/index.ts +4 -0
  246. package/src/tools/read.ts +2 -0
  247. package/src/tools/render-utils.ts +7 -1
  248. package/src/tools/skill-discovery.ts +39 -3
  249. package/src/tools/sqlite-reader.ts +63 -5
  250. package/src/utils/edit-mode.ts +2 -2
  251. package/src/utils/pasted-image-path.ts +15 -6
  252. package/src/utils/prompt-suggestion.ts +265 -0
  253. package/src/utils/shell-snapshot.ts +173 -63
  254. package/src/utils/sixel.ts +3 -3
  255. package/src/web/insane/bridge.ts +5 -1
  256. package/src/web/insane/url-guard.ts +140 -27
  257. package/src/web/scrapers/docs-rs.ts +30 -5
  258. package/src/web/scrapers/types.ts +19 -49
  259. package/src/web/scrapers/utils.ts +12 -26
@@ -12,10 +12,18 @@ import {
12
12
  openRecoveryFsRoot,
13
13
  type RecoveryFsRoot,
14
14
  renameNoReplacePath,
15
+ repairOwnerOnlyPathSecurityExpected,
15
16
  snapshotDirectoryTree,
16
17
  verifyOwnerOnlyFdSecurity,
17
18
  verifyOwnerOnlyPathSecurity,
19
+ verifyOwnerOnlyPathSecurityExpected,
18
20
  } from "@gajae-code/natives";
21
+ import {
22
+ classifyNativePublishOutcome,
23
+ formatNativePublishDiagnostic,
24
+ mayCleanCurrentStaging,
25
+ type NativePublishOutcome,
26
+ } from "./native-publish-outcome";
19
27
 
20
28
  export const MANAGED_ARTIFACT_MAX_DEPTH = 32;
21
29
  export const MANAGED_ARTIFACT_MAX_FILES = 10_000;
@@ -26,6 +34,84 @@ const LOCK_HEARTBEAT_MS = 10_000;
26
34
  const LOCK_WAIT_MS = 5_000;
27
35
 
28
36
  const LOCK_STALE_RECHECK_MS = 100;
37
+ export class ManagedPublishError extends Error {
38
+ readonly classification:
39
+ | "destination_conflict"
40
+ | "atomic_unavailable"
41
+ | "invalid_request"
42
+ | "durability_not_provable"
43
+ | "identity_mismatch"
44
+ | "io_error"
45
+ | "durability_failed";
46
+ readonly diagnostic: string;
47
+
48
+ constructor(classification: ManagedPublishError["classification"], outcome: NativePublishOutcome) {
49
+ super(classification);
50
+ this.name = "ManagedPublishError";
51
+ this.classification = classification;
52
+ this.diagnostic = formatNativePublishDiagnostic(outcome);
53
+ }
54
+ }
55
+
56
+ function publishFailure(outcome: NativePublishOutcome): ManagedPublishError {
57
+ const classification =
58
+ outcome.reason === "destination_exists"
59
+ ? "destination_conflict"
60
+ : outcome.reason === "atomic_unavailable"
61
+ ? "atomic_unavailable"
62
+ : outcome.reason === "invalid_request"
63
+ ? "invalid_request"
64
+ : outcome.reason === "durability_not_provable"
65
+ ? "durability_not_provable"
66
+ : outcome.reason === "identity_violation"
67
+ ? "identity_mismatch"
68
+ : outcome.reason === "io_failure"
69
+ ? "io_error"
70
+ : "durability_failed";
71
+ return new ManagedPublishError(classification, outcome);
72
+ }
73
+
74
+ /** A same-filesystem rename updates the moved root's ctime but no other tree identity. */
75
+ function sameDirectoryTreeSnapshotAfterRename(
76
+ left: NativeDirectoryTreeSnapshot,
77
+ right: NativeDirectoryTreeSnapshot,
78
+ ): boolean {
79
+ return (
80
+ left.rootDev === right.rootDev &&
81
+ left.rootIno === right.rootIno &&
82
+ left.entries.length === right.entries.length &&
83
+ left.entries.every((entry, index) => {
84
+ const other = right.entries[index];
85
+ return (
86
+ other !== undefined &&
87
+ entry.relativePath === other.relativePath &&
88
+ entry.kind === other.kind &&
89
+ entry.dev === other.dev &&
90
+ entry.ino === other.ino &&
91
+ entry.size === other.size &&
92
+ entry.mtimeNs === other.mtimeNs &&
93
+ (entry.relativePath === "" || entry.ctimeNs === other.ctimeNs) &&
94
+ entry.sha256 === other.sha256
95
+ );
96
+ })
97
+ );
98
+ }
99
+
100
+ class ManagedTreeMoveOutcomeError extends Error {
101
+ constructor(
102
+ message: string,
103
+ readonly stagingCleanupSafe: boolean,
104
+ ) {
105
+ super(message);
106
+ }
107
+ }
108
+
109
+ /** Cleanup is safe only when the native move reports that it did not commit. */
110
+ export function mayCleanManagedTreeStaging(error: unknown): boolean {
111
+ return !(error instanceof ManagedTreeMoveOutcomeError) || error.stagingCleanupSafe;
112
+ }
113
+
114
+ export type ManagedSessionSecurityPolicy = "default" | "windows-existing-verify-first";
29
115
 
30
116
  export type ManagedStorageFailure =
31
117
  | "migration_busy"
@@ -35,6 +121,8 @@ export type ManagedStorageFailure =
35
121
  | "source_changed"
36
122
  | "unsafe_artifacts"
37
123
  | "durability_failed"
124
+ | "atomic_unavailable"
125
+ | "durability_not_provable"
38
126
  | "migration_retired"
39
127
  | "managed_storage_unsupported";
40
128
 
@@ -93,6 +181,9 @@ export function validateNativeSecurityResult(
93
181
  ) {
94
182
  throw new Error("Malformed ACL security failure evidence");
95
183
  }
184
+ if (operation === "verify" && result.operation !== "query") {
185
+ throw new Error("Verify failure unexpectedly reports ACL mutation");
186
+ }
96
187
  } else if (result.operation !== undefined || result.attribute !== undefined) {
97
188
  throw new Error("Unexpected ACL fields on owner-only security failure");
98
189
  }
@@ -179,6 +270,40 @@ export interface ManagedDirectoryRoot {
179
270
  readonly ino: bigint;
180
271
  }
181
272
 
273
+ /**
274
+ * Establish the first managed directory beneath a potentially shared ancestor.
275
+ * The ancestor is used only to create the managed chain; every component from
276
+ * `configuredRoot` downward is independently type, symlink, owner, mode, and
277
+ * ACL checked. This deliberately does not impose owner-only policy on `/tmp`.
278
+ */
279
+ export function prepareManagedDirectoryRoot(
280
+ configuredRoot: string,
281
+ policy: ManagedSessionSecurityPolicy = "default",
282
+ ): ManagedDirectoryRoot {
283
+ const target = path.resolve(configuredRoot);
284
+ const missing: string[] = [];
285
+ let existing = target;
286
+ for (;;) {
287
+ try {
288
+ assertSafeDirectory(existing);
289
+ break;
290
+ } catch (error) {
291
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
292
+ const parent = path.dirname(existing);
293
+ if (parent === existing) throw new Error(`Managed root is unavailable: ${configuredRoot}`);
294
+ missing.unshift(path.basename(existing));
295
+ existing = parent;
296
+ }
297
+ }
298
+ let current = fs.realpathSync.native(existing);
299
+ for (const component of missing) {
300
+ current = path.join(current, component);
301
+ const created = ensureDirectoryComponent(current);
302
+ secureManagedDirectory(current, created, policy);
303
+ }
304
+ if (missing.length === 0) secureManagedDirectory(current, false, policy);
305
+ return managedDirectoryRoot(current);
306
+ }
182
307
  export function managedDirectoryRoot(configuredRoot: string): ManagedDirectoryRoot {
183
308
  const canonicalPath = fs.realpathSync.native(configuredRoot);
184
309
  const stat = fs.lstatSync(canonicalPath, { bigint: true });
@@ -216,14 +341,16 @@ export function retainManagedDirectoryAuthority(
216
341
  }
217
342
  }
218
343
 
219
- function ensureDirectoryComponent(pathname: string): void {
344
+ function ensureDirectoryComponent(pathname: string): boolean {
345
+ let created = false;
220
346
  try {
221
347
  fs.mkdirSync(pathname, { mode: 0o700 });
348
+ created = true;
222
349
  } catch (error) {
223
350
  if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
224
351
  }
225
352
  assertSafeDirectory(pathname);
226
- secure(pathname, "directory");
353
+ return created;
227
354
  }
228
355
 
229
356
  export function assertManagedDirectoryRoot(root: ManagedDirectoryRoot): void {
@@ -263,10 +390,66 @@ function secure(pathname: string, kind: "directory" | "file"): void {
263
390
  if (!verified.ok) throw securityError(pathname, verified);
264
391
  }
265
392
 
393
+ function windowsExistingVerifyFirst(policy: ManagedSessionSecurityPolicy): boolean {
394
+ return process.platform === "win32" && policy === "windows-existing-verify-first";
395
+ }
396
+
397
+ function assertManagedPathIdentity(pathname: string, kind: "directory" | "file", expected: fs.BigIntStats): void {
398
+ const current = fs.lstatSync(pathname, { bigint: true });
399
+ if (current.isSymbolicLink()) throw new Error("reparse_point");
400
+ const expectedKind = kind === "directory" ? current.isDirectory() : current.isFile();
401
+ if (!expectedKind) throw new Error(kind === "directory" ? "not_directory" : "not_file");
402
+ if (current.dev !== expected.dev || current.ino !== expected.ino) throw new Error("identity_mismatch");
403
+ }
404
+
405
+ function verifyExistingManagedPathSecurity(
406
+ pathname: string,
407
+ kind: "directory" | "file",
408
+ expected: fs.BigIntStats,
409
+ ): void {
410
+ const verified = validateNativeSecurityResult(
411
+ verifyOwnerOnlyPathSecurityExpected(pathname, kind, expected.dev, expected.ino),
412
+ "verify",
413
+ kind,
414
+ );
415
+ assertManagedPathIdentity(pathname, kind, expected);
416
+ if (!verified.ok) throw securityError(pathname, verified);
417
+ }
418
+
419
+ function secureExistingManagedDirectory(pathname: string, kind: "directory" | "file"): void {
420
+ const named = fs.lstatSync(pathname, { bigint: true });
421
+ const safeKind = kind === "directory" ? named.isDirectory() : named.isFile();
422
+ if (!safeKind || named.isSymbolicLink()) throw new Error(`Unsafe managed ${kind}: ${pathname}`);
423
+ const verified = validateNativeSecurityResult(
424
+ verifyOwnerOnlyPathSecurityExpected(pathname, kind, named.dev, named.ino),
425
+ "verify",
426
+ kind,
427
+ );
428
+ assertManagedPathIdentity(pathname, kind, named);
429
+ if (verified.ok) return;
430
+ if (verified.code !== "acl_verify_failed") throw securityError(pathname, verified);
431
+ const repaired = validateNativeSecurityResult(
432
+ repairOwnerOnlyPathSecurityExpected(pathname, kind, named.dev, named.ino),
433
+ "verify",
434
+ kind,
435
+ );
436
+ if (!repaired.ok) throw securityError(pathname, repaired);
437
+ assertManagedPathIdentity(pathname, kind, named);
438
+ }
439
+
440
+ function secureManagedDirectory(pathname: string, created: boolean, policy: ManagedSessionSecurityPolicy): void {
441
+ if (!created && windowsExistingVerifyFirst(policy)) {
442
+ secureExistingManagedDirectory(pathname, "directory");
443
+ return;
444
+ }
445
+ secure(pathname, "directory");
446
+ }
447
+
266
448
  /** Internal retained-root capability for one managed descendant subtree. */
267
449
  export class ManagedSessionDescendantStore {
268
450
  readonly #root: ManagedDirectoryRoot;
269
451
  readonly #baseDir: string;
452
+ readonly #policy: ManagedSessionSecurityPolicy;
270
453
  readonly #authority: RecoveryFsRoot | undefined;
271
454
  readonly #authorityBaseDir: string;
272
455
  readonly #subtreeRoot: ManagedDirectoryRoot;
@@ -275,32 +458,31 @@ export class ManagedSessionDescendantStore {
275
458
  root: ManagedDirectoryRoot,
276
459
  baseDir: string,
277
460
  retained?: { authority: RecoveryFsRoot; authorityBaseDir: string },
461
+ policy?: ManagedSessionSecurityPolicy,
278
462
  ) {
279
- assertManagedDirectoryRoot(root);
280
463
  managedRelativePath(root, baseDir);
464
+
281
465
  this.#root = root;
282
466
  this.#baseDir = path.resolve(baseDir);
467
+ this.#policy = policy ?? "default";
283
468
  this.#authorityBaseDir = retained?.authorityBaseDir ?? this.#baseDir;
284
469
  if (retained) {
285
470
  const relative = path.relative(retained.authorityBaseDir, this.#baseDir).split(path.sep).join("/");
286
471
  const captured = retained.authority.snapshotManagedTree(relative);
287
472
  if (!captured.ok || !captured.snapshot)
288
473
  throw new Error(captured.code ?? "Managed subtree identity unavailable");
289
- const subtreeStat = fs.lstatSync(this.#baseDir, { bigint: true });
290
- if (
291
- captured.snapshot.rootDev !== subtreeStat.dev.toString() ||
292
- captured.snapshot.rootIno !== subtreeStat.ino.toString()
293
- )
294
- throw new Error("Managed descendant root identity changed");
295
474
  this.#subtreeRoot = Object.freeze({
296
475
  canonicalPath: this.#baseDir,
297
- dev: subtreeStat.dev,
298
- ino: subtreeStat.ino,
476
+ dev: BigInt(captured.snapshot.rootDev),
477
+ ino: BigInt(captured.snapshot.rootIno),
299
478
  });
300
479
  this.#authority = retained.authority;
480
+ this.#assertBound();
481
+
301
482
  return;
302
483
  }
303
- ensureManagedDirectory(this.#baseDir, root);
484
+ assertManagedDirectoryRoot(root);
485
+ ensureManagedDirectory(this.#baseDir, root, this.#policy);
304
486
  const subtreeStat = fs.lstatSync(this.#baseDir, { bigint: true });
305
487
  this.#subtreeRoot = Object.freeze({ canonicalPath: this.#baseDir, dev: subtreeStat.dev, ino: subtreeStat.ino });
306
488
  if (process.platform === "linux") {
@@ -332,19 +514,28 @@ export class ManagedSessionDescendantStore {
332
514
  return this.#subtreeRoot;
333
515
  }
334
516
 
517
+ get securityPolicy(): ManagedSessionSecurityPolicy {
518
+ return this.#policy;
519
+ }
520
+
335
521
  deriveSubtree(relativePath: string): ManagedSessionDescendantStore {
336
522
  const child = this.ensureDirectory(relativePath);
337
523
  const resolved = this.#resolve(relativePath);
338
- if (!this.#authority) return new ManagedSessionDescendantStore(this.#root, resolved);
524
+ if (!this.#authority) return new ManagedSessionDescendantStore(this.#root, resolved, undefined, this.#policy);
339
525
  const retainedChild = this.#authority.retainManagedDirectory(
340
526
  this.#relative(resolved),
341
527
  child.dev.toString(),
342
528
  child.ino.toString(),
343
529
  );
344
- return new ManagedSessionDescendantStore(this.#root, resolved, {
345
- authority: retainedChild,
346
- authorityBaseDir: resolved,
347
- });
530
+ return new ManagedSessionDescendantStore(
531
+ this.#root,
532
+ resolved,
533
+ {
534
+ authority: retainedChild,
535
+ authorityBaseDir: resolved,
536
+ },
537
+ this.#policy,
538
+ );
348
539
  }
349
540
 
350
541
  retainAuthority(): RecoveryFsRoot | undefined {
@@ -371,12 +562,19 @@ export class ManagedSessionDescendantStore {
371
562
  return;
372
563
  }
373
564
  } else {
374
- const verified = validateNativeSecurityResult(
375
- verifyOwnerOnlyPathSecurity(this.#baseDir, "directory"),
376
- "verify",
377
- "directory",
378
- );
379
- if (!verified.ok) throw securityError(this.#baseDir, verified);
565
+ const named = fs.lstatSync(this.#baseDir, { bigint: true });
566
+ if (!named.isDirectory() || named.isSymbolicLink())
567
+ throw new Error(`Unsafe managed directory: ${this.#baseDir}`);
568
+ if (windowsExistingVerifyFirst(this.#policy))
569
+ verifyExistingManagedPathSecurity(this.#baseDir, "directory", named);
570
+ else {
571
+ const verified = validateNativeSecurityResult(
572
+ verifyOwnerOnlyPathSecurity(this.#baseDir, "directory"),
573
+ "verify",
574
+ "directory",
575
+ );
576
+ if (!verified.ok) throw securityError(this.#baseDir, verified);
577
+ }
380
578
  }
381
579
  this.#assertBound();
382
580
  }
@@ -421,7 +619,7 @@ export class ManagedSessionDescendantStore {
421
619
  ino: BigInt(ensured.identity.ino),
422
620
  });
423
621
  }
424
- ensureManagedDirectory(this.#resolve(relativePath), this.#root);
622
+ ensureManagedDirectory(this.#resolve(relativePath), this.#root, this.#policy);
425
623
  const named = fs.lstatSync(this.#resolve(relativePath), { bigint: true });
426
624
  return Object.freeze({ canonicalPath: this.#resolve(relativePath), dev: named.dev, ino: named.ino });
427
625
  }
@@ -434,13 +632,13 @@ export class ManagedSessionDescendantStore {
434
632
  this.#assertBound();
435
633
  return;
436
634
  }
437
- await publishManagedFileNoReplace(resolved, bytes, undefined, this.#root);
635
+ await publishManagedFileNoReplace(resolved, bytes, undefined, this.#root, this.#policy);
438
636
  }
439
637
 
440
638
  publishNoReplaceSync(relativePath: string, bytes: Uint8Array): void {
441
639
  const resolved = this.#resolve(relativePath);
442
640
  if (!this.#authority) {
443
- publishManagedFileNoReplaceSync(resolved, bytes, this.#root);
641
+ publishManagedFileNoReplaceSync(resolved, bytes, this.#root, this.#policy);
444
642
  this.#assertBound();
445
643
  return;
446
644
  }
@@ -453,7 +651,7 @@ export class ManagedSessionDescendantStore {
453
651
  this.#assertBound();
454
652
  const resolved = this.#resolve(relativePath);
455
653
  if (!this.#authority) {
456
- await replaceManagedFile(resolved, bytes, this.#subtreeRoot);
654
+ await replaceManagedFile(resolved, bytes, this.#subtreeRoot, this.#policy);
457
655
  this.#assertBound();
458
656
  return;
459
657
  }
@@ -466,7 +664,7 @@ export class ManagedSessionDescendantStore {
466
664
  this.#assertBound();
467
665
  const resolved = this.#resolve(relativePath);
468
666
  if (!this.#authority) {
469
- replaceManagedFileSync(resolved, bytes, this.#subtreeRoot);
667
+ replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy);
470
668
  this.#assertBound();
471
669
  return;
472
670
  }
@@ -477,7 +675,7 @@ export class ManagedSessionDescendantStore {
477
675
  appendSync(relativePath: string, bytes: Uint8Array): void {
478
676
  this.#assertBound();
479
677
  const resolved = this.#resolve(relativePath);
480
- const existing = this.readExpected(relativePath);
678
+ let existing = this.readExpected(relativePath);
481
679
  if (!existing) throw new Error("managed_append_missing");
482
680
  if (this.#authority) {
483
681
  const appended = this.#authority.appendManaged(
@@ -494,20 +692,49 @@ export class ManagedSessionDescendantStore {
494
692
  this.#assertBound();
495
693
  return;
496
694
  }
695
+ // On Darwin, the first write-append open can change only ctime (e.g. com.apple.provenance)
696
+ // while leaving dev/ino/size/mtime/content intact. Allow one bounded refresh+retry for that
697
+ // transition only; any other identity change or a second ctime transition stays fail-closed.
698
+ // See https://github.com/Yeachan-Heo/gajae-code/issues/2944
497
699
  let fd: number | undefined;
700
+ let darwinCtimeRefreshConsumed = false;
498
701
  try {
499
- fd = fs.openSync(resolved, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW);
500
- const before = identity(fs.fstatSync(fd, { bigint: true }));
501
- if (!sameIdentity(before, existing.identity)) throw new Error("identity_mismatch");
502
- secureFileDescriptor(resolved, fd, "verify");
503
- let offset = 0;
504
- while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
505
- fs.fsyncSync(fd);
506
- secureFileDescriptor(resolved, fd, "verify");
507
- const after = identity(fs.fstatSync(fd, { bigint: true }));
508
- const named = identity(fs.lstatSync(resolved, { bigint: true }));
509
- if (!sameIdentity(after, named) || after.dev !== before.dev || after.ino !== before.ino)
510
- throw new Error("identity_mismatch");
702
+ for (;;) {
703
+ fd = fs.openSync(resolved, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW);
704
+ const before = identity(fs.fstatSync(fd, { bigint: true }));
705
+ if (!sameIdentity(before, existing.identity)) {
706
+ const allowDarwinCtimeRefresh =
707
+ process.platform === "darwin" &&
708
+ !darwinCtimeRefreshConsumed &&
709
+ isCtimeOnlyIdentityMismatch(before, existing.identity);
710
+ if (!allowDarwinCtimeRefresh) throw new Error("identity_mismatch");
711
+ fs.closeSync(fd);
712
+ fd = undefined;
713
+ darwinCtimeRefreshConsumed = true;
714
+ const original = existing;
715
+ const refreshed = this.readExpected(relativePath);
716
+ if (
717
+ !refreshed ||
718
+ !sameStableIdentityIgnoringCtime(refreshed.identity, original.identity) ||
719
+ refreshed.identity.sha256 !== original.identity.sha256 ||
720
+ !refreshed.bytes.equals(original.bytes)
721
+ ) {
722
+ throw new Error("identity_mismatch");
723
+ }
724
+ existing = refreshed;
725
+ continue;
726
+ }
727
+ secureFileDescriptor(resolved, fd, "verify");
728
+ let offset = 0;
729
+ while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
730
+ fs.fsyncSync(fd);
731
+ secureFileDescriptor(resolved, fd, "verify");
732
+ const after = identity(fs.fstatSync(fd, { bigint: true }));
733
+ const named = identity(fs.lstatSync(resolved, { bigint: true }));
734
+ if (!sameIdentity(after, named) || after.dev !== before.dev || after.ino !== before.ino)
735
+ throw new Error("identity_mismatch");
736
+ break;
737
+ }
511
738
  } finally {
512
739
  if (fd !== undefined) fs.closeSync(fd);
513
740
  }
@@ -528,6 +755,7 @@ export class ManagedSessionDescendantStore {
528
755
  if (!created.ok) throw new Error(created.code ?? "managed_publish_failed");
529
756
  if (!created.identity) throw new Error("managed_publish_identity_unavailable");
530
757
  let captured: ReturnType<RecoveryFsRoot["readManaged"]> | undefined;
758
+ let published: unknown;
531
759
  try {
532
760
  captured = this.#authority.readManaged(temporary);
533
761
  if (!captured.ok || !captured.identity || !captured.data)
@@ -553,7 +781,7 @@ export class ManagedSessionDescendantStore {
553
781
  digest,
554
782
  );
555
783
  if (!synced.ok) throw new Error(synced.code ?? "managed_publish_fsync_failed");
556
- const published = this.#authority.renameManagedFileNoReplace(
784
+ published = this.#authority.renameManagedFileNoReplace(
557
785
  temporary,
558
786
  relative,
559
787
  captured.identity.dev,
@@ -563,35 +791,14 @@ export class ManagedSessionDescendantStore {
563
791
  captured.identity.ctimeNs,
564
792
  digest,
565
793
  );
566
- if (!published.ok)
567
- throw new Error(
568
- published.code === "already_exists"
569
- ? "destination_conflict"
570
- : (published.code ?? "managed_publish_failed"),
571
- );
794
+ const outcome = classifyNativePublishOutcome(published, "retained_file");
795
+ if (!outcome.ok) throw publishFailure(outcome);
572
796
  } catch (error) {
573
- if (captured?.ok && captured.identity && captured.data) {
574
- const digest = captured.identity.sha256 ?? createHash("sha256").update(captured.data).digest("hex");
575
- const installed = this.#authority.readManaged(relative);
576
- if (
577
- installed.ok &&
578
- installed.identity &&
579
- installed.data &&
580
- installed.identity.dev === captured.identity.dev &&
581
- installed.identity.ino === captured.identity.ino &&
582
- (installed.identity.sha256 ?? createHash("sha256").update(installed.data).digest("hex")) === digest
583
- ) {
584
- const removed = this.#authority.removeManaged(
585
- relative,
586
- installed.identity.dev,
587
- installed.identity.ino,
588
- installed.identity.size,
589
- installed.identity.mtimeNs,
590
- installed.identity.ctimeNs,
591
- digest,
592
- );
593
- if (!removed.ok) throw new Error(removed.code ?? "managed_publish_reconcile_failed");
594
- }
797
+ // A committed or unknown native outcome is evidence, not authorization to
798
+ // probe or remove the destination. Only a validated pre-mutation result
799
+ // may clean this operation's still-named staging object.
800
+ const outcome = captured ? classifyNativePublishOutcome(published, "retained_file") : undefined;
801
+ if (captured?.ok && captured.identity && captured.data && outcome && mayCleanCurrentStaging(outcome)) {
595
802
  const staged = this.#authority.readManaged(temporary);
596
803
  if (
597
804
  staged.ok &&
@@ -609,7 +816,8 @@ export class ManagedSessionDescendantStore {
609
816
  staged.identity.ctimeNs,
610
817
  staged.identity.sha256 ?? createHash("sha256").update(staged.data).digest("hex"),
611
818
  );
612
- if (!removed.ok) throw new Error(removed.code ?? "managed_publish_reconcile_failed");
819
+ if (!removed.ok && removed.code !== "cleanup_pending")
820
+ throw new Error(removed.code ?? "managed_publish_reconcile_failed");
613
821
  }
614
822
  }
615
823
  throw error;
@@ -682,7 +890,17 @@ export class ManagedSessionDescendantStore {
682
890
  sha256: expected.identity.sha256,
683
891
  quarantineName: `.gjc-remove-${process.pid}-${randomUUID()}`,
684
892
  });
685
- if (!removed.ok) throw new Error(removed.code ?? "managed_remove_failed");
893
+ if (
894
+ !removed.ok &&
895
+ !(
896
+ removed.code === "cleanup_pending" &&
897
+ (removed.detachedPath ??
898
+ removed.retainedSuccessorPath ??
899
+ removed.retainedPlaceholderPath ??
900
+ removed.retainedUnknownPath) !== undefined
901
+ )
902
+ )
903
+ throw new Error(removed.code ?? "managed_remove_failed");
686
904
  this.#assertBound();
687
905
  return;
688
906
  }
@@ -695,7 +913,8 @@ export class ManagedSessionDescendantStore {
695
913
  expected.identity.ctimeNs.toString(),
696
914
  expected.identity.sha256,
697
915
  );
698
- if (!removed.ok) throw new Error(removed.code ?? "managed_remove_failed");
916
+ if (!removed.ok && !(removed.code === "cleanup_pending" && removed.recoveryPath !== undefined))
917
+ throw new Error(removed.code ?? "managed_remove_failed");
699
918
  this.#assertBound();
700
919
  }
701
920
  /** Read and remove one managed descendant through retained authority. */
@@ -719,7 +938,8 @@ export class ManagedSessionDescendantStore {
719
938
  existing.identity.ctimeNs,
720
939
  existing.identity.sha256 ?? createHash("sha256").update(existing.data).digest("hex"),
721
940
  );
722
- if (!removed.ok) throw new Error(removed.code ?? "managed_consume_failed");
941
+ if (!removed.ok && !(removed.code === "cleanup_pending" && removed.recoveryPath !== undefined))
942
+ throw new Error(removed.code ?? "managed_consume_failed");
723
943
  this.#assertBound();
724
944
  return existing.data;
725
945
  }
@@ -805,92 +1025,31 @@ export class ManagedSessionDescendantStore {
805
1025
  sourceRelativePath: string,
806
1026
  destinationRelativePath: string,
807
1027
  expected: NativeDirectoryTreeSnapshot,
808
- ): void {
1028
+ ): NativeDirectoryTreeSnapshot {
809
1029
  this.#assertBound();
810
- if (!this.#authority) {
811
- const source = this.#resolve(sourceRelativePath);
812
- const destination = this.#resolve(destinationRelativePath);
813
- if (fs.existsSync(destination)) throw new Error("already_exists");
814
- let destinationSnapshot: NativeDirectoryTreeSnapshot | undefined;
815
- try {
816
- this.ensureDirectory(destinationRelativePath);
817
- destinationSnapshot = this.captureTree(destinationRelativePath);
818
- for (const entry of expected.entries) {
819
- if (entry.relativePath === "") continue;
820
- const target = path.posix.join(destinationRelativePath.replaceAll(path.sep, "/"), entry.relativePath);
821
- if (entry.kind === "directory") {
822
- this.ensureDirectory(target);
823
- destinationSnapshot = this.captureTree(destinationRelativePath);
824
- continue;
825
- }
826
- const sourceEntry = this.readExpected(
827
- path.posix.join(sourceRelativePath.replaceAll(path.sep, "/"), entry.relativePath),
828
- );
829
- if (
830
- !sourceEntry ||
831
- sourceEntry.identity.dev.toString() !== entry.dev ||
832
- sourceEntry.identity.ino.toString() !== entry.ino ||
833
- sourceEntry.identity.size.toString() !== entry.size ||
834
- sourceEntry.identity.mtimeNs.toString() !== entry.mtimeNs ||
835
- sourceEntry.identity.sha256 !== entry.sha256
836
- )
837
- throw new Error("artifact_source_changed");
838
- this.publishNoReplaceSync(target, sourceEntry.bytes);
839
- destinationSnapshot = this.captureTree(destinationRelativePath);
840
- }
841
- destinationSnapshot = this.captureTree(destinationRelativePath);
842
- const comparable = (tree: NativeDirectoryTreeSnapshot) =>
843
- tree.entries.map(entry => ({
844
- relativePath: entry.relativePath,
845
- kind: entry.kind,
846
- size: entry.size,
847
- sha256: entry.sha256,
848
- }));
849
- if (JSON.stringify(comparable(destinationSnapshot)) !== JSON.stringify(comparable(expected)))
850
- throw new Error("artifact_destination_mismatch");
851
- const terminalSource = snapshotDirectoryTree(source);
852
- if (
853
- !terminalSource.ok ||
854
- !terminalSource.snapshot ||
855
- JSON.stringify(terminalSource.snapshot) !== JSON.stringify(expected)
1030
+ const moved = this.#authority
1031
+ ? this.#authority.renameManagedTreeNoReplace(
1032
+ this.#relative(this.#resolve(sourceRelativePath)),
1033
+ this.#relative(this.#resolve(destinationRelativePath)),
1034
+ expected,
856
1035
  )
857
- throw new Error("artifact_source_changed");
858
- const retired = exactRemoveDirectoryTree(source, expected);
859
- if (!retired.ok) throw new Error(retired.code ?? "managed_remove_failed");
860
- } catch (error) {
861
- if (destinationSnapshot) {
862
- const removed = exactRemoveDirectoryTree(destination, destinationSnapshot);
863
- if (!removed.ok) throw new Error(removed.code ?? "managed_publish_reconcile_failed");
864
- }
865
- throw error;
866
- }
867
- this.#assertBound();
868
- return;
869
- }
870
- const moved = this.#authority.renameManagedTreeNoReplace(
871
- this.#relative(this.#resolve(sourceRelativePath)),
872
- this.#relative(this.#resolve(destinationRelativePath)),
873
- expected,
874
- );
875
- if (!moved.ok) {
876
- try {
877
- const installed = this.captureTree(destinationRelativePath);
878
- const comparable = (tree: NativeDirectoryTreeSnapshot) =>
879
- tree.entries.map(entry => ({
880
- relativePath: entry.relativePath,
881
- kind: entry.kind,
882
- size: entry.size,
883
- sha256: entry.sha256,
884
- }));
885
- if (JSON.stringify(comparable(installed)) !== JSON.stringify(comparable(expected)))
886
- throw new Error("managed_publish_reconcile_failed");
887
- this.removeTreeExpected(destinationRelativePath, installed);
888
- } catch (error) {
889
- if (!(error instanceof Error) || error.message !== "not_found") throw error;
890
- }
891
- throw new Error(moved.code ?? "managed_move_failed");
892
- }
1036
+ : renameNoReplacePath(this.#resolve(sourceRelativePath), this.#resolve(destinationRelativePath));
1037
+ const outcome = classifyNativePublishOutcome(moved, this.#authority ? "retained_tree" : "direct_rename");
1038
+ if (!outcome.ok)
1039
+ throw new ManagedTreeMoveOutcomeError(publishFailure(outcome).message, mayCleanCurrentStaging(outcome));
1040
+ const movedSnapshot = this.#authority
1041
+ ? this.#authority.snapshotManagedTree(this.#relative(this.#resolve(destinationRelativePath)))
1042
+ : snapshotDirectoryTree(this.#resolve(destinationRelativePath));
1043
+ if (
1044
+ !movedSnapshot.ok ||
1045
+ !movedSnapshot.snapshot ||
1046
+ !sameDirectoryTreeSnapshotAfterRename(movedSnapshot.snapshot, expected)
1047
+ )
1048
+ throw new ManagedTreeMoveOutcomeError("artifact_destination_mismatch", false);
1049
+ if (!this.#authority) fsyncDirectory(this.#baseDir);
1050
+
893
1051
  this.#assertBound();
1052
+ return movedSnapshot.snapshot;
894
1053
  }
895
1054
 
896
1055
  removeTreeExpected(relativePath: string, expected: NativeDirectoryTreeSnapshot): void {
@@ -1018,6 +1177,24 @@ function sameIdentity(left: ManagedFileSnapshot["identity"], right: ManagedFileS
1018
1177
  );
1019
1178
  }
1020
1179
 
1180
+ /** Content/path identity that remains meaningful across a Darwin write-open ctime-only transition. */
1181
+ function sameStableIdentityIgnoringCtime(
1182
+ left: ManagedFileSnapshot["identity"],
1183
+ right: ManagedFileSnapshot["identity"],
1184
+ ): boolean {
1185
+ return (
1186
+ left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs
1187
+ );
1188
+ }
1189
+
1190
+ /** True when observed identity matches expected on every field except ctimeNs. */
1191
+ function isCtimeOnlyIdentityMismatch(
1192
+ observed: ManagedFileSnapshot["identity"],
1193
+ expected: ManagedFileSnapshot["identity"],
1194
+ ): boolean {
1195
+ return sameStableIdentityIgnoringCtime(observed, expected) && observed.ctimeNs !== expected.ctimeNs;
1196
+ }
1197
+
1021
1198
  function parseLock(pathname: string): LockRecord | undefined {
1022
1199
  try {
1023
1200
  const stat = fs.lstatSync(pathname);
@@ -1060,25 +1237,43 @@ function sameFileIdentity(left: fs.BigIntStats, right: fs.BigIntStats): boolean
1060
1237
  }
1061
1238
 
1062
1239
  /** Create a managed directory and fail closed unless its owner-only mode/ACL verifies. */
1063
- export function ensureManagedDirectory(pathname: string, root?: ManagedDirectoryRoot): void {
1240
+ export function ensureManagedDirectory(
1241
+ pathname: string,
1242
+ root?: ManagedDirectoryRoot,
1243
+ policy: ManagedSessionSecurityPolicy = "default",
1244
+ ): void {
1064
1245
  if (!root) {
1246
+ let existed = false;
1247
+ try {
1248
+ const named = fs.lstatSync(pathname);
1249
+ existed = true;
1250
+ if (!named.isDirectory() || named.isSymbolicLink()) throw new Error(`Unsafe managed directory: ${pathname}`);
1251
+ } catch (error) {
1252
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
1253
+ }
1065
1254
  fs.mkdirSync(pathname, { recursive: true, mode: 0o700 });
1066
1255
  assertSafeDirectory(pathname);
1067
- secure(pathname, "directory");
1256
+ if (existed && windowsExistingVerifyFirst(policy)) secureExistingManagedDirectory(pathname, "directory");
1257
+ else secure(pathname, "directory");
1068
1258
  return;
1069
1259
  }
1070
1260
  ensureManagedRoot(root);
1261
+ const components = managedRelativePath(root, pathname);
1262
+ if (components.length === 0) {
1263
+ if (windowsExistingVerifyFirst(policy)) secureExistingManagedDirectory(root.canonicalPath, "directory");
1264
+ return;
1265
+ }
1071
1266
  let current = root.canonicalPath;
1072
- for (const component of managedRelativePath(root, pathname)) {
1267
+ if (windowsExistingVerifyFirst(policy)) secureExistingManagedDirectory(current, "directory");
1268
+ else secure(current, "directory");
1269
+ for (const component of components) {
1073
1270
  // Re-inspect the captured root or already-secured descendant before every
1074
1271
  // descent; a replaced component cannot grant authority to the next one.
1075
1272
  if (current === root.canonicalPath) assertManagedDirectoryRoot(root);
1076
- else {
1077
- assertSafeDirectory(current);
1078
- secure(current, "directory");
1079
- }
1273
+ else assertSafeDirectory(current);
1080
1274
  current = path.join(current, component);
1081
- ensureDirectoryComponent(current);
1275
+ const created = ensureDirectoryComponent(current);
1276
+ secureManagedDirectory(current, created, policy);
1082
1277
  }
1083
1278
  }
1084
1279
 
@@ -1123,15 +1318,17 @@ export async function publishManagedFileNoReplace(
1123
1318
  bytes: Uint8Array,
1124
1319
  assertOwned?: () => void,
1125
1320
  root?: ManagedDirectoryRoot,
1321
+ policy: ManagedSessionSecurityPolicy = "default",
1126
1322
  ): Promise<void> {
1127
1323
  const parent = path.dirname(destination);
1128
- ensureManagedDirectory(parent, root);
1324
+ ensureManagedDirectory(parent, root, policy);
1129
1325
  const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.staging`);
1130
1326
  let fd: number | undefined;
1131
1327
  let stagingIdentity: { dev: bigint; ino: bigint } | undefined;
1132
- let stagingSnapshot: ManagedFileSnapshot | undefined;
1133
- let committed = false;
1134
1328
  let failure: unknown;
1329
+ let outcome: NativePublishOutcome | undefined;
1330
+ let renameAttempted = false;
1331
+
1135
1332
  try {
1136
1333
  assertOwned?.();
1137
1334
  fd = fs.openSync(
@@ -1146,17 +1343,13 @@ export async function publishManagedFileNoReplace(
1146
1343
  secureFileDescriptor(staging, fd, "verify");
1147
1344
  const staged = fs.fstatSync(fd, { bigint: true });
1148
1345
  stagingIdentity = { dev: staged.dev, ino: staged.ino };
1149
- stagingSnapshot = {
1150
- bytes: Buffer.from(bytes),
1151
- identity: identity(staged, createHash("sha256").update(bytes).digest("hex")),
1152
- };
1153
1346
  assertOwned?.();
1154
1347
 
1155
- const published = renameNoReplacePath(staging, destination);
1156
- if (!published.ok) {
1157
- if (published.code === "quarantine_collision") throw new Error("destination_conflict");
1158
- throw new Error("durability_failed");
1159
- }
1348
+ renameAttempted = true;
1349
+ outcome = classifyNativePublishOutcome(renameNoReplacePath(staging, destination));
1350
+
1351
+ if (!outcome.ok) throw publishFailure(outcome);
1352
+
1160
1353
  const named = fs.lstatSync(destination, { bigint: true });
1161
1354
  if (
1162
1355
  !named.isFile() ||
@@ -1171,25 +1364,12 @@ export async function publishManagedFileNoReplace(
1171
1364
  fd = undefined;
1172
1365
 
1173
1366
  fsyncDirectory(parent);
1174
- committed = true;
1175
1367
  } catch (error) {
1176
1368
  failure = error;
1177
1369
  } finally {
1178
1370
  if (fd !== undefined) fs.closeSync(fd);
1179
- if (!committed && stagingSnapshot) {
1180
- try {
1181
- const installed = captureManagedFileNoFollow(destination);
1182
- if (
1183
- installed.identity.dev === stagingSnapshot.identity.dev &&
1184
- installed.identity.ino === stagingSnapshot.identity.ino &&
1185
- installed.identity.sha256 === stagingSnapshot.identity.sha256
1186
- )
1187
- await unlinkManagedFileVerified(destination, installed.identity);
1188
- } catch (error) {
1189
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") failure ??= error;
1190
- }
1191
- }
1192
- if (stagingIdentity) {
1371
+
1372
+ if (stagingIdentity && (!renameAttempted || (outcome && mayCleanCurrentStaging(outcome)))) {
1193
1373
  await fsp
1194
1374
  .lstat(staging, { bigint: true })
1195
1375
  .then(stat => {
@@ -1208,15 +1388,16 @@ export function publishManagedFileNoReplaceSync(
1208
1388
  destination: string,
1209
1389
  bytes: Uint8Array,
1210
1390
  root?: ManagedDirectoryRoot,
1391
+ policy: ManagedSessionSecurityPolicy = "default",
1211
1392
  ): void {
1212
1393
  const parent = path.dirname(destination);
1213
- ensureManagedDirectory(parent, root);
1394
+ ensureManagedDirectory(parent, root, policy);
1214
1395
  const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.staging`);
1215
1396
  let fd: number | undefined;
1216
1397
  let stagingIdentity: { dev: bigint; ino: bigint } | undefined;
1217
- let stagingSnapshot: ManagedFileSnapshot | undefined;
1218
- let committed = false;
1219
1398
  let failure: unknown;
1399
+ let outcome: NativePublishOutcome | undefined;
1400
+
1220
1401
  try {
1221
1402
  fd = fs.openSync(
1222
1403
  staging,
@@ -1230,16 +1411,10 @@ export function publishManagedFileNoReplaceSync(
1230
1411
  secureFileDescriptor(staging, fd, "verify");
1231
1412
  const staged = fs.fstatSync(fd, { bigint: true });
1232
1413
  stagingIdentity = { dev: staged.dev, ino: staged.ino };
1233
- stagingSnapshot = {
1234
- bytes: Buffer.from(bytes),
1235
- identity: identity(staged, createHash("sha256").update(bytes).digest("hex")),
1236
- };
1237
1414
 
1238
- const published = renameNoReplacePath(staging, destination);
1239
- if (!published.ok) {
1240
- if (published.code === "quarantine_collision") throw new Error("destination_conflict");
1241
- throw new Error("durability_failed");
1242
- }
1415
+ outcome = classifyNativePublishOutcome(renameNoReplacePath(staging, destination));
1416
+ if (!outcome.ok) throw publishFailure(outcome);
1417
+
1243
1418
  const named = fs.lstatSync(destination, { bigint: true });
1244
1419
  if (!named.isFile() || named.isSymbolicLink() || named.dev !== staged.dev || named.ino !== staged.ino) {
1245
1420
  throw new Error("destination_identity_changed");
@@ -1248,35 +1423,12 @@ export function publishManagedFileNoReplaceSync(
1248
1423
  fs.closeSync(fd);
1249
1424
  fd = undefined;
1250
1425
  fsyncDirectory(parent);
1251
- committed = true;
1252
1426
  } catch (error) {
1253
1427
  failure = error;
1254
1428
  } finally {
1255
1429
  if (fd !== undefined) fs.closeSync(fd);
1256
1430
  }
1257
- if (stagingIdentity) {
1258
- if (!committed && stagingSnapshot) {
1259
- try {
1260
- const installed = captureManagedFileNoFollow(destination);
1261
- if (
1262
- installed.identity.dev === stagingSnapshot.identity.dev &&
1263
- installed.identity.ino === stagingSnapshot.identity.ino &&
1264
- installed.identity.sha256 === stagingSnapshot.identity.sha256
1265
- ) {
1266
- const removed = exactUnlink(destination, {
1267
- dev: installed.identity.dev,
1268
- ino: installed.identity.ino,
1269
- size: BigInt(installed.identity.size),
1270
- mtimeNs: installed.identity.mtimeNs,
1271
- sha256: installed.identity.sha256,
1272
- quarantineName: `.gjc-remove-${process.pid}-${randomUUID()}`,
1273
- });
1274
- if (!removed.ok) failure ??= new Error(removed.code ?? "managed_publish_reconcile_failed");
1275
- }
1276
- } catch (error) {
1277
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") failure ??= error;
1278
- }
1279
- }
1431
+ if (stagingIdentity && outcome && mayCleanCurrentStaging(outcome)) {
1280
1432
  try {
1281
1433
  const named = fs.lstatSync(staging, { bigint: true });
1282
1434
  if (named.dev !== stagingIdentity.dev || named.ino !== stagingIdentity.ino) {
@@ -1291,9 +1443,14 @@ export function publishManagedFileNoReplaceSync(
1291
1443
  }
1292
1444
 
1293
1445
  /** Replace one managed regular file while retaining the secured staging fd through publication. */
1294
- export function replaceManagedFileSync(destination: string, bytes: Uint8Array, root: ManagedDirectoryRoot): void {
1446
+ export function replaceManagedFileSync(
1447
+ destination: string,
1448
+ bytes: Uint8Array,
1449
+ root: ManagedDirectoryRoot,
1450
+ policy: ManagedSessionSecurityPolicy = "default",
1451
+ ): void {
1295
1452
  const parent = path.dirname(destination);
1296
- ensureManagedDirectory(parent, root);
1453
+ ensureManagedDirectory(parent, root, policy);
1297
1454
  const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.replacement`);
1298
1455
  let fd: number | undefined;
1299
1456
  let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
@@ -1338,8 +1495,9 @@ export async function replaceManagedFile(
1338
1495
  destination: string,
1339
1496
  bytes: Uint8Array,
1340
1497
  root: ManagedDirectoryRoot,
1498
+ policy: ManagedSessionSecurityPolicy = "default",
1341
1499
  ): Promise<void> {
1342
- replaceManagedFileSync(destination, bytes, root);
1500
+ replaceManagedFileSync(destination, bytes, root, policy);
1343
1501
  }
1344
1502
 
1345
1503
  /** Copy the exact bytes captured from one no-follow source descriptor. */
@@ -1348,11 +1506,12 @@ export async function copyManagedFileNoReplace(
1348
1506
  destination: string,
1349
1507
  snapshot = captureManagedFileNoFollow(source),
1350
1508
  root?: ManagedDirectoryRoot,
1509
+ policy: ManagedSessionSecurityPolicy = "default",
1351
1510
  ): Promise<void> {
1352
1511
  const named = captureManagedFileNoFollow(source);
1353
1512
  if (!sameIdentity(snapshot.identity, named.identity) || !snapshot.bytes.equals(named.bytes))
1354
1513
  throw new Error("source_changed");
1355
- await publishManagedFileNoReplace(destination, snapshot.bytes, undefined, root);
1514
+ await publishManagedFileNoReplace(destination, snapshot.bytes, undefined, root, policy);
1356
1515
  const destinationSnapshot = captureManagedFileNoFollow(destination);
1357
1516
  if (!destinationSnapshot.bytes.equals(snapshot.bytes)) throw new Error("durability_failed");
1358
1517
  }
@@ -1362,8 +1521,9 @@ export async function acquireManagedLock(
1362
1521
  locksDirectory: string,
1363
1522
  name: string,
1364
1523
  root?: ManagedDirectoryRoot,
1524
+ policy: ManagedSessionSecurityPolicy = "default",
1365
1525
  ): Promise<ManagedStorageLock> {
1366
- ensureManagedDirectory(locksDirectory, root);
1526
+ ensureManagedDirectory(locksDirectory, root, policy);
1367
1527
  const lockPath = path.join(locksDirectory, `${name}.lock`);
1368
1528
  const deadline = Date.now() + LOCK_WAIT_MS;
1369
1529
  let staleObservedAt: number | undefined;
@@ -1513,7 +1673,10 @@ export function fsyncManagedArtifactTree(root: string): NativeDirectoryTreeSnaps
1513
1673
  const stat = fs.lstatSync(pathname);
1514
1674
  if (stat.isSymbolicLink()) throw new Error("unsafe_artifacts");
1515
1675
  if (stat.isFile()) {
1516
- const fd = fs.openSync(pathname, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
1676
+ const fd = fs.openSync(
1677
+ pathname,
1678
+ (process.platform === "win32" ? fs.constants.O_WRONLY : fs.constants.O_RDONLY) | fs.constants.O_NOFOLLOW,
1679
+ );
1517
1680
  try {
1518
1681
  fs.fsyncSync(fd);
1519
1682
  } finally {