@gajae-code/coding-agent 0.11.3 → 0.11.5

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 (213) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/types/cli/notify-cli.d.ts +5 -1
  3. package/dist/types/cli/worktree-cli.d.ts +8 -25
  4. package/dist/types/commands/gc.d.ts +4 -0
  5. package/dist/types/commands/worktree.d.ts +7 -32
  6. package/dist/types/config/settings-schema.d.ts +20 -0
  7. package/dist/types/export/html/template.generated.d.ts +1 -1
  8. package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
  9. package/dist/types/gjc-runtime/gc-runtime.d.ts +9 -0
  10. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
  11. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
  12. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
  13. package/dist/types/gjc-runtime/state-argv.d.ts +38 -0
  14. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
  15. package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
  16. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  17. package/dist/types/harness-control-plane/owner.d.ts +3 -1
  18. package/dist/types/internal-urls/local-protocol.d.ts +18 -0
  19. package/dist/types/main.d.ts +2 -2
  20. package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
  21. package/dist/types/modes/components/custom-editor.d.ts +8 -3
  22. package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
  23. package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
  24. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  25. package/dist/types/modes/components/tool-transcript-format.d.ts +77 -0
  26. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
  27. package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
  28. package/dist/types/modes/controllers/input-controller.d.ts +8 -2
  29. package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
  30. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
  31. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
  32. package/dist/types/plan-mode/approved-plan.d.ts +1 -0
  33. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  34. package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
  35. package/dist/types/sdk/broker/broker.d.ts +1 -0
  36. package/dist/types/sdk/broker/discovery.d.ts +10 -0
  37. package/dist/types/sdk/broker/ensure.d.ts +24 -1
  38. package/dist/types/sdk/broker/internal.d.ts +3 -0
  39. package/dist/types/sdk/broker/session-index.d.ts +12 -0
  40. package/dist/types/sdk/broker/state-version.d.ts +2 -1
  41. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
  42. package/dist/types/sdk/bus/chat-daemon-control.d.ts +39 -4
  43. package/dist/types/sdk/bus/config-commands.d.ts +8 -0
  44. package/dist/types/sdk/bus/config.d.ts +22 -0
  45. package/dist/types/sdk/bus/index.d.ts +162 -2
  46. package/dist/types/sdk/bus/notification-service.d.ts +71 -8
  47. package/dist/types/sdk/bus/session-control.d.ts +11 -8
  48. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
  49. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -3
  50. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
  51. package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
  52. package/dist/types/sdk/session.d.ts +16 -1
  53. package/dist/types/secrets/index.d.ts +4 -2
  54. package/dist/types/secrets/obfuscator.d.ts +1 -1
  55. package/dist/types/session/agent-session.d.ts +31 -2
  56. package/dist/types/session/artifacts.d.ts +30 -8
  57. package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
  58. package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
  59. package/dist/types/session/session-manager.d.ts +88 -18
  60. package/dist/types/session/session-storage.d.ts +27 -0
  61. package/dist/types/session/streaming-output.d.ts +4 -0
  62. package/dist/types/task/executor.d.ts +9 -0
  63. package/dist/types/tool-discovery/tool-index.d.ts +2 -0
  64. package/dist/types/tools/ask.d.ts +87 -12
  65. package/dist/types/tools/bash-skill-urls.d.ts +2 -2
  66. package/dist/types/tools/browser/screenshot-format.d.ts +8 -0
  67. package/dist/types/tools/browser/tab-protocol.d.ts +11 -0
  68. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -1
  69. package/dist/types/tools/browser/tab-worker-path-resolver.d.ts +2 -0
  70. package/dist/types/tools/browser/tab-worker-smoke.d.ts +16 -0
  71. package/dist/types/tools/index.d.ts +4 -0
  72. package/dist/types/tools/path-utils.d.ts +9 -14
  73. package/dist/types/tools/render-utils.d.ts +1 -8
  74. package/dist/types/tools/resource-gc.d.ts +14 -0
  75. package/dist/types/utils/image-loading.d.ts +23 -0
  76. package/dist/types/utils/image-resize.d.ts +2 -0
  77. package/dist/types/utils/pasted-image-loading.d.ts +39 -0
  78. package/dist/types/utils/pasted-image-path.d.ts +21 -19
  79. package/dist/types/web/scrapers/html-to-markdown.d.ts +5 -0
  80. package/dist/types/web/scrapers/types.d.ts +1 -5
  81. package/package.json +25 -9
  82. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  83. package/scripts/run-threat-model-check.ts +60 -0
  84. package/src/cli/notify-cli.ts +40 -18
  85. package/src/cli/worktree-cli.ts +9 -287
  86. package/src/cli.ts +10 -9
  87. package/src/commands/gc.ts +11 -1
  88. package/src/commands/sdk.ts +10 -4
  89. package/src/commands/worktree.ts +10 -53
  90. package/src/config/model-profiles.ts +15 -15
  91. package/src/config/settings-schema.ts +17 -0
  92. package/src/daemon/runtime.ts +2 -2
  93. package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
  94. package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
  95. package/src/defaults/gjc/skills/team/SKILL.md +7 -9
  96. package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
  97. package/src/eval/js/shared/runtime.ts +1 -1
  98. package/src/eval/py/index.ts +2 -2
  99. package/src/export/html/template.generated.ts +1 -1
  100. package/src/extensibility/plugins/marketplace/manager.ts +94 -10
  101. package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
  102. package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
  103. package/src/gjc-runtime/deep-interview-state.ts +127 -3
  104. package/src/gjc-runtime/gc-render.ts +19 -1
  105. package/src/gjc-runtime/gc-runtime.ts +68 -3
  106. package/src/gjc-runtime/launch-tmux.ts +61 -6
  107. package/src/gjc-runtime/managed-owner-admission.ts +352 -0
  108. package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
  109. package/src/gjc-runtime/state-argv.ts +292 -0
  110. package/src/gjc-runtime/state-runtime.ts +80 -222
  111. package/src/gjc-runtime/team-runtime.ts +1 -1
  112. package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
  113. package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
  114. package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
  115. package/src/harness-control-plane/owner.ts +59 -3
  116. package/src/hindsight/client.ts +65 -2
  117. package/src/internal-urls/agent-protocol.ts +76 -5
  118. package/src/internal-urls/docs-index.generated.ts +7 -6
  119. package/src/internal-urls/local-protocol.ts +416 -18
  120. package/src/lsp/index.ts +4 -88
  121. package/src/main.ts +37 -13
  122. package/src/modes/acp/acp-agent.ts +8 -0
  123. package/src/modes/components/ansi-display-validator.ts +54 -0
  124. package/src/modes/components/custom-editor.ts +103 -32
  125. package/src/modes/components/custom-provider-wizard.ts +26 -1
  126. package/src/modes/components/notifications-settings-editor.ts +26 -2
  127. package/src/modes/components/session-observer-overlay.ts +40 -37
  128. package/src/modes/components/settings-selector.ts +3 -1
  129. package/src/modes/components/tool-transcript-format.ts +393 -0
  130. package/src/modes/components/transcript-viewer-overlay.ts +63 -1
  131. package/src/modes/controllers/goal-mode-controller.ts +20 -1
  132. package/src/modes/controllers/input-controller.ts +98 -37
  133. package/src/modes/controllers/plan-mode-controller.ts +2 -0
  134. package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
  135. package/src/modes/controllers/selector-controller.ts +71 -9
  136. package/src/modes/interactive-mode.ts +14 -2
  137. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
  138. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
  139. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
  140. package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
  141. package/src/plan-mode/approved-plan.ts +3 -1
  142. package/src/prompts/agents/architect.md +1 -0
  143. package/src/prompts/agents/critic.md +1 -0
  144. package/src/prompts/system/custom-system-prompt.md +1 -1
  145. package/src/prompts/system/system-prompt.md +2 -1
  146. package/src/prompts/tools/lsp.md +3 -3
  147. package/src/runtime-mcp/manager.ts +16 -1
  148. package/src/runtime-mcp/oauth-discovery.ts +116 -26
  149. package/src/sdk/broker/broker.ts +139 -32
  150. package/src/sdk/broker/discovery.ts +125 -0
  151. package/src/sdk/broker/ensure.ts +103 -19
  152. package/src/sdk/broker/internal.ts +10 -0
  153. package/src/sdk/broker/session-index.ts +309 -38
  154. package/src/sdk/broker/state-version.ts +5 -2
  155. package/src/sdk/bus/chat-daemon-cli.ts +72 -10
  156. package/src/sdk/bus/chat-daemon-control.ts +526 -130
  157. package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
  158. package/src/sdk/bus/config-commands.ts +22 -0
  159. package/src/sdk/bus/config.ts +37 -0
  160. package/src/sdk/bus/index.ts +384 -92
  161. package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
  162. package/src/sdk/bus/notification-service.ts +284 -62
  163. package/src/sdk/bus/recent-activity.ts +14 -5
  164. package/src/sdk/bus/session-control.ts +150 -44
  165. package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
  166. package/src/sdk/bus/telegram-daemon-contract.ts +6 -3
  167. package/src/sdk/bus/telegram-daemon-control.ts +294 -76
  168. package/src/sdk/bus/telegram-daemon.ts +1842 -281
  169. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  170. package/src/sdk/session.ts +155 -27
  171. package/src/secrets/index.ts +53 -10
  172. package/src/secrets/obfuscator.ts +21 -18
  173. package/src/secrets/regex.ts +2 -0
  174. package/src/session/agent-session.ts +495 -253
  175. package/src/session/artifacts.ts +188 -23
  176. package/src/session/context-estimation.ts +7 -2
  177. package/src/session/internal/managed-session-scope.ts +215 -39
  178. package/src/session/internal/managed-session-storage.ts +1152 -46
  179. package/src/session/session-manager.ts +1055 -148
  180. package/src/session/session-storage.ts +109 -11
  181. package/src/session/streaming-output.ts +6 -0
  182. package/src/task/executor.ts +71 -26
  183. package/src/task/index.ts +63 -20
  184. package/src/tool-discovery/tool-index.ts +18 -0
  185. package/src/tools/ask.ts +329 -57
  186. package/src/tools/bash-allowed-prefixes.ts +79 -29
  187. package/src/tools/bash-skill-urls.ts +14 -3
  188. package/src/tools/bash.ts +2 -2
  189. package/src/tools/browser/launch.ts +3 -1
  190. package/src/tools/browser/readable.ts +1 -1
  191. package/src/tools/browser/screenshot-format.ts +34 -0
  192. package/src/tools/browser/tab-protocol.ts +3 -0
  193. package/src/tools/browser/tab-supervisor.ts +125 -122
  194. package/src/tools/browser/tab-worker-entry.ts +33 -14
  195. package/src/tools/browser/tab-worker-path-resolver.ts +67 -0
  196. package/src/tools/browser/tab-worker-smoke.ts +87 -0
  197. package/src/tools/browser/tab-worker.ts +7 -5
  198. package/src/tools/index.ts +4 -0
  199. package/src/tools/path-utils.ts +42 -35
  200. package/src/tools/plan-mode-guard.ts +1 -0
  201. package/src/tools/read.ts +7 -1
  202. package/src/tools/render-utils.ts +1 -27
  203. package/src/tools/resource-gc.ts +123 -34
  204. package/src/tools/sqlite-reader.ts +3 -2
  205. package/src/tools/subagent.ts +2 -0
  206. package/src/utils/image-loading.ts +85 -34
  207. package/src/utils/image-resize.ts +16 -9
  208. package/src/utils/pasted-image-loading.ts +514 -0
  209. package/src/utils/pasted-image-path.ts +223 -98
  210. package/src/web/scrapers/html-to-markdown.ts +68 -0
  211. package/src/web/scrapers/readthedocs.ts +1 -1
  212. package/src/web/scrapers/types.ts +1 -68
  213. package/src/web/search/providers/openai-compatible.ts +76 -23
@@ -1,8 +1,21 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as fsp from "node:fs/promises";
4
4
  import * as path from "node:path";
5
- import { applyOwnerOnlyPathSecurity, renameNoReplacePath, verifyOwnerOnlyPathSecurity } from "@gajae-code/natives";
5
+ import {
6
+ applyOwnerOnlyFdSecurity,
7
+ applyOwnerOnlyPathSecurity,
8
+ exactRemoveDirectoryTree,
9
+ exactUnlink,
10
+ type NativeDirectoryTreeSnapshot,
11
+ type NativeOwnerOnlySecurityResult,
12
+ openRecoveryFsRoot,
13
+ type RecoveryFsRoot,
14
+ renameNoReplacePath,
15
+ snapshotDirectoryTree,
16
+ verifyOwnerOnlyFdSecurity,
17
+ verifyOwnerOnlyPathSecurity,
18
+ } from "@gajae-code/natives";
6
19
 
7
20
  export const MANAGED_ARTIFACT_MAX_DEPTH = 32;
8
21
  export const MANAGED_ARTIFACT_MAX_FILES = 10_000;
@@ -34,10 +47,121 @@ export interface ManagedStorageLock {
34
47
 
35
48
  export interface ManagedFileSnapshot {
36
49
  bytes: Buffer;
37
- identity: { dev: bigint; ino: bigint; size: number; mtimeNs: bigint };
50
+ identity: { dev: bigint; ino: bigint; size: number; mtimeNs: bigint; ctimeNs: bigint; sha256: string };
38
51
  }
39
52
 
40
- type NativeSecurity = { ok: true } | { ok: false; code: string };
53
+ const ACL_FAILURE_CODES = new Set(["acl_denied", "acl_io_error", "acl_present", "acl_malformed", "acl_unknown"]);
54
+ const ACL_CLEAR_EVIDENCE = new Set(["cleared", "already_absent", "unsupported", "not_run"]);
55
+ const GENERAL_FAILURE_CODES = new Set([
56
+ "acl_unavailable",
57
+ "acl_apply_failed",
58
+ "acl_verify_failed",
59
+ "not_found",
60
+ "not_directory",
61
+ "network_unsupported",
62
+ "reparse_point",
63
+ "identity_unavailable",
64
+ "identity_mismatch",
65
+ "owner_mismatch",
66
+ "mode_mismatch",
67
+ "io_error",
68
+ ]);
69
+
70
+ function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
71
+ return Object.keys(value).every(key => allowed.includes(key));
72
+ }
73
+ const ACL_QUERY_EVIDENCE = new Set(["absent", "unsupported"]);
74
+
75
+ export function validateNativeSecurityResult(
76
+ value: unknown,
77
+ operation: "apply" | "verify",
78
+ kind: "directory" | "file",
79
+ ): NativeOwnerOnlySecurityResult {
80
+ if (!value || typeof value !== "object" || typeof (value as { ok?: unknown }).ok !== "boolean") {
81
+ throw new Error("Malformed owner-only security result");
82
+ }
83
+ const result = value as Record<string, unknown>;
84
+ if (result.ok === false) {
85
+ if (typeof result.code !== "string") throw new Error("Malformed owner-only security failure");
86
+ if (!ACL_FAILURE_CODES.has(result.code) && !GENERAL_FAILURE_CODES.has(result.code)) {
87
+ throw new Error("Unknown owner-only security failure code");
88
+ }
89
+ if (ACL_FAILURE_CODES.has(result.code)) {
90
+ if (
91
+ (result.operation !== "clear" && result.operation !== "query") ||
92
+ (result.attribute !== "access" && result.attribute !== "default")
93
+ ) {
94
+ throw new Error("Malformed ACL security failure evidence");
95
+ }
96
+ } else if (result.operation !== undefined || result.attribute !== undefined) {
97
+ throw new Error("Unexpected ACL fields on owner-only security failure");
98
+ }
99
+ if (!hasOnlyKeys(result, ["ok", "code", "operation", "attribute"])) {
100
+ throw new Error("Unexpected owner-only security failure fields");
101
+ }
102
+ return value as NativeOwnerOnlySecurityResult;
103
+ }
104
+ if (process.platform !== "linux") {
105
+ if (!hasOnlyKeys(result, ["ok"])) throw new Error("Malformed non-Linux security success");
106
+ return value as NativeOwnerOnlySecurityResult;
107
+ }
108
+ if (result.platform !== "linux" || result.kind !== kind || result.protocol !== operation) {
109
+ throw new Error("Malformed Linux security success envelope");
110
+ }
111
+ if (result.code !== undefined || result.operation !== undefined || result.attribute !== undefined) {
112
+ throw new Error("Unexpected failure fields on Linux security success");
113
+ }
114
+ if (!hasOnlyKeys(result, ["ok", "platform", "kind", "protocol", "aclEvidence"])) {
115
+ throw new Error("Unexpected Linux security success fields");
116
+ }
117
+ const evidence = result.aclEvidence;
118
+ if (!evidence || typeof evidence !== "object") throw new Error("Missing Linux ACL evidence");
119
+ const record = evidence as Record<string, unknown>;
120
+ const validateAttribute = (candidate: unknown): void => {
121
+ if (!candidate || typeof candidate !== "object") throw new Error("Malformed Linux ACL attribute evidence");
122
+ const attribute = candidate as Record<string, unknown>;
123
+ if (!hasOnlyKeys(attribute, ["clear", "query"])) throw new Error("Unexpected Linux ACL evidence fields");
124
+ if (!ACL_CLEAR_EVIDENCE.has(String(attribute.clear)) || !ACL_QUERY_EVIDENCE.has(String(attribute.query))) {
125
+ throw new Error("Unknown Linux ACL attribute evidence");
126
+ }
127
+ if (operation === "verify" && attribute.clear !== "not_run") {
128
+ throw new Error("Verify result unexpectedly reports ACL mutation");
129
+ }
130
+ if (operation === "apply" && attribute.clear === "not_run") {
131
+ throw new Error("Apply result omitted ACL mutation evidence");
132
+ }
133
+ };
134
+ validateAttribute(record.access);
135
+ if (kind === "directory") validateAttribute(record.default);
136
+ else if (record.default !== undefined) throw new Error("File result unexpectedly carries default ACL evidence");
137
+ if (!hasOnlyKeys(record, kind === "directory" ? ["access", "default"] : ["access"])) {
138
+ throw new Error("Unexpected Linux ACL evidence attributes");
139
+ }
140
+ return value as NativeOwnerOnlySecurityResult;
141
+ }
142
+
143
+ type NativeSecurity = NativeOwnerOnlySecurityResult;
144
+ type RetainedManagedReplacer = {
145
+ replaceManaged(
146
+ relativePath: string,
147
+ bytes: Uint8Array,
148
+ expectedDev: string,
149
+ expectedIno: string,
150
+ expectedSize: string,
151
+ expectedMtimeNs: string,
152
+ expectedCtimeNs: string,
153
+ expectedSha256: string,
154
+ ): { ok: boolean; code?: string };
155
+ removeManaged(
156
+ relativePath: string,
157
+ expectedDev: string,
158
+ expectedIno: string,
159
+ expectedSize: string,
160
+ expectedMtimeNs: string,
161
+ expectedCtimeNs: string,
162
+ expectedSha256: string,
163
+ ): { ok: boolean; code?: string };
164
+ };
41
165
  type LockRecord = {
42
166
  attemptId: string;
43
167
  pid: number;
@@ -48,6 +172,80 @@ type LockRecord = {
48
172
  leaseExpiresAt: number;
49
173
  };
50
174
 
175
+ /** Captured configured-root authority for managed paths only. */
176
+ export interface ManagedDirectoryRoot {
177
+ readonly canonicalPath: string;
178
+ readonly dev: bigint;
179
+ readonly ino: bigint;
180
+ }
181
+
182
+ export function managedDirectoryRoot(configuredRoot: string): ManagedDirectoryRoot {
183
+ const canonicalPath = fs.realpathSync.native(configuredRoot);
184
+ const stat = fs.lstatSync(canonicalPath, { bigint: true });
185
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Unsafe managed root: ${configuredRoot}`);
186
+ return Object.freeze({ canonicalPath, dev: stat.dev, ino: stat.ino });
187
+ }
188
+
189
+ export function retainManagedDirectoryAuthority(
190
+ root: ManagedDirectoryRoot,
191
+ directory: string,
192
+ expected?: { dev: bigint; ino: bigint },
193
+ ): RecoveryFsRoot | undefined {
194
+ assertManagedDirectoryRoot(root);
195
+ managedRelativePath(root, directory);
196
+ const resolved = path.resolve(directory);
197
+ if (process.platform !== "linux") return undefined;
198
+ const named = fs.lstatSync(resolved, { bigint: true });
199
+ if (!named.isDirectory() || named.isSymbolicLink()) throw new Error("Managed directory authority is unavailable");
200
+ if (expected && (named.dev !== expected.dev || named.ino !== expected.ino))
201
+ throw new Error("Managed directory identity changed before retention");
202
+ const rootAuthority = openRecoveryFsRoot(root.canonicalPath);
203
+ try {
204
+ const retainedRoot = rootAuthority.identity();
205
+ if (
206
+ !retainedRoot.ok ||
207
+ !retainedRoot.identity ||
208
+ retainedRoot.identity.dev !== root.dev.toString() ||
209
+ retainedRoot.identity.ino !== root.ino.toString()
210
+ )
211
+ throw new Error("Managed root authority changed");
212
+ const relative = path.relative(root.canonicalPath, resolved).split(path.sep).join("/");
213
+ return rootAuthority.retainManagedDirectory(relative, named.dev.toString(), named.ino.toString());
214
+ } finally {
215
+ rootAuthority.close();
216
+ }
217
+ }
218
+
219
+ function ensureDirectoryComponent(pathname: string): void {
220
+ try {
221
+ fs.mkdirSync(pathname, { mode: 0o700 });
222
+ } catch (error) {
223
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
224
+ }
225
+ assertSafeDirectory(pathname);
226
+ secure(pathname, "directory");
227
+ }
228
+
229
+ export function assertManagedDirectoryRoot(root: ManagedDirectoryRoot): void {
230
+ const named = fs.lstatSync(root.canonicalPath, { bigint: true });
231
+ if (!named.isDirectory() || named.isSymbolicLink() || named.dev !== root.dev || named.ino !== root.ino) {
232
+ throw new Error(`Managed root authority changed: ${root.canonicalPath}`);
233
+ }
234
+ }
235
+
236
+ /** Verify the configured root without rewriting its ownership, mode, or ACLs. */
237
+ function ensureManagedRoot(root: ManagedDirectoryRoot): void {
238
+ assertManagedDirectoryRoot(root);
239
+ }
240
+
241
+ function managedRelativePath(root: ManagedDirectoryRoot, pathname: string): readonly string[] {
242
+ const relative = path.relative(root.canonicalPath, path.resolve(pathname));
243
+ if (relative === "") return [];
244
+ if (path.isAbsolute(relative) || relative.split(path.sep).includes(".."))
245
+ throw new Error(`Managed path escapes configured root: ${pathname}`);
246
+ return relative.split(path.sep);
247
+ }
248
+
51
249
  const PROCESS_START_ID = randomUUID();
52
250
 
53
251
  function securityError(pathname: string, result: NativeSecurity): Error {
@@ -59,12 +257,719 @@ function securityError(pathname: string, result: NativeSecurity): Error {
59
257
  }
60
258
 
61
259
  function secure(pathname: string, kind: "directory" | "file"): void {
62
- const applied = applyOwnerOnlyPathSecurity(pathname, kind) as NativeSecurity;
260
+ const applied = validateNativeSecurityResult(applyOwnerOnlyPathSecurity(pathname, kind), "apply", kind);
63
261
  if (!applied.ok) throw securityError(pathname, applied);
64
- const verified = verifyOwnerOnlyPathSecurity(pathname, kind) as NativeSecurity;
262
+ const verified = validateNativeSecurityResult(verifyOwnerOnlyPathSecurity(pathname, kind), "verify", kind);
65
263
  if (!verified.ok) throw securityError(pathname, verified);
66
264
  }
67
265
 
266
+ /** Internal retained-root capability for one managed descendant subtree. */
267
+ export class ManagedSessionDescendantStore {
268
+ readonly #root: ManagedDirectoryRoot;
269
+ readonly #baseDir: string;
270
+ readonly #authority: RecoveryFsRoot | undefined;
271
+ readonly #authorityBaseDir: string;
272
+ readonly #subtreeRoot: ManagedDirectoryRoot;
273
+
274
+ constructor(
275
+ root: ManagedDirectoryRoot,
276
+ baseDir: string,
277
+ retained?: { authority: RecoveryFsRoot; authorityBaseDir: string },
278
+ ) {
279
+ assertManagedDirectoryRoot(root);
280
+ managedRelativePath(root, baseDir);
281
+ this.#root = root;
282
+ this.#baseDir = path.resolve(baseDir);
283
+ this.#authorityBaseDir = retained?.authorityBaseDir ?? this.#baseDir;
284
+ if (retained) {
285
+ const relative = path.relative(retained.authorityBaseDir, this.#baseDir).split(path.sep).join("/");
286
+ const captured = retained.authority.snapshotManagedTree(relative);
287
+ if (!captured.ok || !captured.snapshot)
288
+ 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
+ this.#subtreeRoot = Object.freeze({
296
+ canonicalPath: this.#baseDir,
297
+ dev: subtreeStat.dev,
298
+ ino: subtreeStat.ino,
299
+ });
300
+ this.#authority = retained.authority;
301
+ return;
302
+ }
303
+ ensureManagedDirectory(this.#baseDir, root);
304
+ const subtreeStat = fs.lstatSync(this.#baseDir, { bigint: true });
305
+ this.#subtreeRoot = Object.freeze({ canonicalPath: this.#baseDir, dev: subtreeStat.dev, ino: subtreeStat.ino });
306
+ if (process.platform === "linux") {
307
+ const before = fs.lstatSync(this.#baseDir, { bigint: true });
308
+ const authority = openRecoveryFsRoot(this.#baseDir);
309
+ const retained = authority.identity();
310
+ if (
311
+ !retained.ok ||
312
+ !retained.identity ||
313
+ retained.identity.dev !== before.dev.toString() ||
314
+ retained.identity.ino !== before.ino.toString()
315
+ ) {
316
+ authority.close();
317
+ throw new Error("Managed descendant root identity changed");
318
+ }
319
+ this.#authority = authority;
320
+ }
321
+ }
322
+
323
+ get dir(): string {
324
+ return this.#baseDir;
325
+ }
326
+
327
+ get rootAuthority(): ManagedDirectoryRoot {
328
+ return this.#root;
329
+ }
330
+
331
+ get subtreeRootAuthority(): ManagedDirectoryRoot {
332
+ return this.#subtreeRoot;
333
+ }
334
+
335
+ deriveSubtree(relativePath: string): ManagedSessionDescendantStore {
336
+ const child = this.ensureDirectory(relativePath);
337
+ const resolved = this.#resolve(relativePath);
338
+ if (!this.#authority) return new ManagedSessionDescendantStore(this.#root, resolved);
339
+ const retainedChild = this.#authority.retainManagedDirectory(
340
+ this.#relative(resolved),
341
+ child.dev.toString(),
342
+ child.ino.toString(),
343
+ );
344
+ return new ManagedSessionDescendantStore(this.#root, resolved, {
345
+ authority: retainedChild,
346
+ authorityBaseDir: resolved,
347
+ });
348
+ }
349
+
350
+ retainAuthority(): RecoveryFsRoot | undefined {
351
+ if (!this.#authority) return undefined;
352
+ return this.#authority.retainManagedDirectory(
353
+ "",
354
+ this.#subtreeRoot.dev.toString(),
355
+ this.#subtreeRoot.ino.toString(),
356
+ );
357
+ }
358
+
359
+ assertBound(): void {
360
+ this.#assertBound();
361
+ }
362
+
363
+ verifyRootSecurity(): void {
364
+ this.#assertBound();
365
+ if (this.#authority) {
366
+ if (this.#authorityBaseDir === this.#baseDir) {
367
+ const verified = this.#authority.verifyOwnerOnlyDirectory();
368
+ if (!verified.ok) throw new Error(verified.code ?? "acl_verify_failed");
369
+ } else {
370
+ this.#assertBound();
371
+ return;
372
+ }
373
+ } else {
374
+ const verified = validateNativeSecurityResult(
375
+ verifyOwnerOnlyPathSecurity(this.#baseDir, "directory"),
376
+ "verify",
377
+ "directory",
378
+ );
379
+ if (!verified.ok) throw securityError(this.#baseDir, verified);
380
+ }
381
+ this.#assertBound();
382
+ }
383
+ #assertBound(): void {
384
+ if (!this.#authority) return;
385
+ const named = fs.lstatSync(this.#baseDir, { bigint: true });
386
+ const retained =
387
+ this.#authorityBaseDir === this.#baseDir
388
+ ? this.#authority.identity()
389
+ : (() => {
390
+ const relative = path.relative(this.#authorityBaseDir, this.#baseDir).split(path.sep).join("/");
391
+ const captured = this.#authority?.snapshotManagedTree(relative);
392
+ return captured?.ok && captured.snapshot
393
+ ? { ok: true, identity: { dev: captured.snapshot.rootDev, ino: captured.snapshot.rootIno } }
394
+ : { ok: false, identity: undefined };
395
+ })();
396
+ if (
397
+ !retained.ok ||
398
+ !retained.identity ||
399
+ !named.isDirectory() ||
400
+ named.isSymbolicLink() ||
401
+ retained.identity.dev !== named.dev.toString() ||
402
+ retained.identity.dev !== this.#subtreeRoot.dev.toString() ||
403
+ retained.identity.ino !== this.#subtreeRoot.ino.toString() ||
404
+ retained.identity.ino !== named.ino.toString()
405
+ ) {
406
+ throw new Error("Managed descendant root binding changed");
407
+ }
408
+ }
409
+
410
+ ensureDirectory(relativePath = ""): ManagedDirectoryRoot {
411
+ this.#assertBound();
412
+ if (this.#authority) {
413
+ const relative = this.#relative(this.#resolve(relativePath));
414
+ if (relative === "") return this.#subtreeRoot;
415
+ const ensured = this.#authority.ensureManagedDirectory(relative);
416
+ if (!ensured.ok || !ensured.identity) throw new Error(ensured.code ?? "managed_directory_create_failed");
417
+ this.#assertBound();
418
+ return Object.freeze({
419
+ canonicalPath: this.#resolve(relativePath),
420
+ dev: BigInt(ensured.identity.dev),
421
+ ino: BigInt(ensured.identity.ino),
422
+ });
423
+ }
424
+ ensureManagedDirectory(this.#resolve(relativePath), this.#root);
425
+ const named = fs.lstatSync(this.#resolve(relativePath), { bigint: true });
426
+ return Object.freeze({ canonicalPath: this.#resolve(relativePath), dev: named.dev, ino: named.ino });
427
+ }
428
+
429
+ async publishNoReplace(relativePath: string, bytes: Uint8Array): Promise<void> {
430
+ const resolved = this.#resolve(relativePath);
431
+ if (this.#authority) {
432
+ this.#assertBound();
433
+ this.#publishRetainedNoReplace(this.#relative(resolved), bytes);
434
+ this.#assertBound();
435
+ return;
436
+ }
437
+ await publishManagedFileNoReplace(resolved, bytes, undefined, this.#root);
438
+ }
439
+
440
+ publishNoReplaceSync(relativePath: string, bytes: Uint8Array): void {
441
+ const resolved = this.#resolve(relativePath);
442
+ if (!this.#authority) {
443
+ publishManagedFileNoReplaceSync(resolved, bytes, this.#root);
444
+ this.#assertBound();
445
+ return;
446
+ }
447
+ this.#assertBound();
448
+ this.#publishRetainedNoReplace(this.#relative(resolved), bytes);
449
+ this.#assertBound();
450
+ }
451
+
452
+ async replace(relativePath: string, bytes: Uint8Array): Promise<void> {
453
+ this.#assertBound();
454
+ const resolved = this.#resolve(relativePath);
455
+ if (!this.#authority) {
456
+ await replaceManagedFile(resolved, bytes, this.#subtreeRoot);
457
+ this.#assertBound();
458
+ return;
459
+ }
460
+ const relative = this.#relative(resolved);
461
+ this.#replaceRetained(relative, bytes);
462
+ this.#assertBound();
463
+ }
464
+
465
+ replaceSync(relativePath: string, bytes: Uint8Array): void {
466
+ this.#assertBound();
467
+ const resolved = this.#resolve(relativePath);
468
+ if (!this.#authority) {
469
+ replaceManagedFileSync(resolved, bytes, this.#subtreeRoot);
470
+ this.#assertBound();
471
+ return;
472
+ }
473
+ this.#replaceRetained(this.#relative(resolved), bytes);
474
+ this.#assertBound();
475
+ }
476
+
477
+ appendSync(relativePath: string, bytes: Uint8Array): void {
478
+ this.#assertBound();
479
+ const resolved = this.#resolve(relativePath);
480
+ const existing = this.readExpected(relativePath);
481
+ if (!existing) throw new Error("managed_append_missing");
482
+ if (this.#authority) {
483
+ const appended = this.#authority.appendManaged(
484
+ this.#relative(resolved),
485
+ bytes,
486
+ existing.identity.dev.toString(),
487
+ existing.identity.ino.toString(),
488
+ existing.identity.size.toString(),
489
+ existing.identity.mtimeNs.toString(),
490
+ existing.identity.ctimeNs.toString(),
491
+ existing.identity.sha256,
492
+ );
493
+ if (!appended.ok) throw new Error(appended.code ?? "managed_append_failed");
494
+ this.#assertBound();
495
+ return;
496
+ }
497
+ let fd: number | undefined;
498
+ 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");
511
+ } finally {
512
+ if (fd !== undefined) fs.closeSync(fd);
513
+ }
514
+ this.#assertBound();
515
+ }
516
+
517
+ #relative(resolved: string): string {
518
+ return path.relative(this.#authorityBaseDir, resolved).split(path.sep).join("/");
519
+ }
520
+
521
+ #publishRetainedNoReplace(relative: string, bytes: Uint8Array): void {
522
+ if (!this.#authority) throw new Error("Managed descendant authority is unavailable");
523
+ const separator = relative.lastIndexOf("/");
524
+ const parent = separator < 0 ? "" : relative.slice(0, separator);
525
+ const temporaryName = `.gjc-publish-${process.pid}-${randomUUID()}`;
526
+ const temporary = parent ? `${parent}/${temporaryName}` : temporaryName;
527
+ const created = this.#authority.createManaged(temporary, bytes);
528
+ if (!created.ok) throw new Error(created.code ?? "managed_publish_failed");
529
+ if (!created.identity) throw new Error("managed_publish_identity_unavailable");
530
+ let captured: ReturnType<RecoveryFsRoot["readManaged"]> | undefined;
531
+ try {
532
+ captured = this.#authority.readManaged(temporary);
533
+ if (!captured.ok || !captured.identity || !captured.data)
534
+ throw new Error(captured.code ?? "managed_publish_identity_unavailable");
535
+ const expectedDigest = createHash("sha256").update(bytes).digest("hex");
536
+ const digest = captured.identity.sha256 ?? createHash("sha256").update(captured.data).digest("hex");
537
+ if (
538
+ captured.identity.dev !== created.identity.dev ||
539
+ captured.identity.ino !== created.identity.ino ||
540
+ captured.identity.size !== created.identity.size ||
541
+ captured.identity.mtimeNs !== created.identity.mtimeNs ||
542
+ captured.identity.ctimeNs !== created.identity.ctimeNs ||
543
+ digest !== expectedDigest
544
+ )
545
+ throw new Error("managed_publish_identity_mismatch");
546
+ const synced = this.#authority.fsyncExpected(
547
+ temporary,
548
+ false,
549
+ captured.identity.dev,
550
+ captured.identity.ino,
551
+ captured.identity.size,
552
+ captured.identity.mtimeNs,
553
+ digest,
554
+ );
555
+ if (!synced.ok) throw new Error(synced.code ?? "managed_publish_fsync_failed");
556
+ const published = this.#authority.renameManagedFileNoReplace(
557
+ temporary,
558
+ relative,
559
+ captured.identity.dev,
560
+ captured.identity.ino,
561
+ captured.identity.size,
562
+ captured.identity.mtimeNs,
563
+ captured.identity.ctimeNs,
564
+ digest,
565
+ );
566
+ if (!published.ok)
567
+ throw new Error(
568
+ published.code === "already_exists"
569
+ ? "destination_conflict"
570
+ : (published.code ?? "managed_publish_failed"),
571
+ );
572
+ } 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
+ }
595
+ const staged = this.#authority.readManaged(temporary);
596
+ if (
597
+ staged.ok &&
598
+ staged.identity &&
599
+ staged.data &&
600
+ staged.identity.dev === captured.identity.dev &&
601
+ staged.identity.ino === captured.identity.ino
602
+ ) {
603
+ const removed = this.#authority.removeManaged(
604
+ temporary,
605
+ staged.identity.dev,
606
+ staged.identity.ino,
607
+ staged.identity.size,
608
+ staged.identity.mtimeNs,
609
+ staged.identity.ctimeNs,
610
+ staged.identity.sha256 ?? createHash("sha256").update(staged.data).digest("hex"),
611
+ );
612
+ if (!removed.ok) throw new Error(removed.code ?? "managed_publish_reconcile_failed");
613
+ }
614
+ }
615
+ throw error;
616
+ }
617
+ }
618
+
619
+ #replaceRetained(relative: string, bytes: Uint8Array): void {
620
+ if (!this.#authority) throw new Error("Managed descendant authority is unavailable");
621
+ this.#assertBound();
622
+ const existing = this.#authority.readManaged(relative);
623
+ if (!existing.ok) {
624
+ if (existing.code !== "not_found") throw new Error(existing.code ?? "managed_replace_failed");
625
+ this.#publishRetainedNoReplace(relative, bytes);
626
+ return;
627
+ }
628
+ if (!existing.identity || !existing.data) throw new Error("Managed descendant identity is unavailable");
629
+ const replaced = (this.#authority as RecoveryFsRoot & RetainedManagedReplacer).replaceManaged(
630
+ relative,
631
+ bytes,
632
+ existing.identity.dev,
633
+ existing.identity.ino,
634
+ existing.identity.size,
635
+ existing.identity.mtimeNs,
636
+ existing.identity.ctimeNs,
637
+ existing.identity.sha256 ?? createHash("sha256").update(existing.data).digest("hex"),
638
+ );
639
+ if (!replaced.ok) throw new Error(replaced.code ?? "managed_replace_failed");
640
+ }
641
+
642
+ /** Read an exact managed file without exposing its pathname as authority. */
643
+ readExpected(relativePath: string): ManagedFileSnapshot | null {
644
+ this.#assertBound();
645
+ const relative = this.#relative(this.#resolve(relativePath));
646
+ if (!this.#authority) {
647
+ try {
648
+ return captureManagedFileNoFollow(this.#resolve(relativePath));
649
+ } catch (error) {
650
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
651
+ throw error;
652
+ }
653
+ }
654
+ const read = this.#authority.readManaged(relative);
655
+ if (!read.ok) {
656
+ if (read.code === "not_found") return null;
657
+ throw new Error(read.code ?? "managed_read_failed");
658
+ }
659
+ if (!read.data || !read.identity) throw new Error("Managed descendant identity is unavailable");
660
+ return {
661
+ bytes: Buffer.from(read.data),
662
+ identity: {
663
+ dev: BigInt(read.identity.dev),
664
+ ino: BigInt(read.identity.ino),
665
+ size: Number(read.identity.size),
666
+ mtimeNs: BigInt(read.identity.mtimeNs),
667
+ ctimeNs: BigInt(read.identity.ctimeNs),
668
+ sha256: read.identity.sha256 ?? createHash("sha256").update(read.data).digest("hex"),
669
+ },
670
+ };
671
+ }
672
+
673
+ /** Remove an exact captured file without reopening its pathname as authority. */
674
+ removeExpected(relativePath: string, expected: ManagedFileSnapshot): void {
675
+ this.#assertBound();
676
+ if (!this.#authority) {
677
+ const removed = exactUnlink(this.#resolve(relativePath), {
678
+ dev: expected.identity.dev,
679
+ ino: expected.identity.ino,
680
+ size: BigInt(expected.identity.size),
681
+ mtimeNs: expected.identity.mtimeNs,
682
+ sha256: expected.identity.sha256,
683
+ quarantineName: `.gjc-remove-${process.pid}-${randomUUID()}`,
684
+ });
685
+ if (!removed.ok) throw new Error(removed.code ?? "managed_remove_failed");
686
+ this.#assertBound();
687
+ return;
688
+ }
689
+ const removed = (this.#authority as RecoveryFsRoot & RetainedManagedReplacer).removeManaged(
690
+ this.#relative(this.#resolve(relativePath)),
691
+ expected.identity.dev.toString(),
692
+ expected.identity.ino.toString(),
693
+ expected.identity.size.toString(),
694
+ expected.identity.mtimeNs.toString(),
695
+ expected.identity.ctimeNs.toString(),
696
+ expected.identity.sha256,
697
+ );
698
+ if (!removed.ok) throw new Error(removed.code ?? "managed_remove_failed");
699
+ this.#assertBound();
700
+ }
701
+ /** Read and remove one managed descendant through retained authority. */
702
+ async consume(relativePath: string): Promise<Uint8Array | null> {
703
+ this.#assertBound();
704
+ const resolved = this.#resolve(relativePath);
705
+ if (this.#authority) {
706
+ const relative = this.#relative(resolved);
707
+ const existing = this.#authority.readManaged(relative);
708
+ if (!existing.ok) {
709
+ if (existing.code === "not_found") return null;
710
+ throw new Error(existing.code ?? "managed_read_failed");
711
+ }
712
+ if (!existing.identity || !existing.data) throw new Error("Managed descendant identity is unavailable");
713
+ const removed = (this.#authority as RecoveryFsRoot & RetainedManagedReplacer).removeManaged(
714
+ relative,
715
+ existing.identity.dev,
716
+ existing.identity.ino,
717
+ existing.identity.size,
718
+ existing.identity.mtimeNs,
719
+ existing.identity.ctimeNs,
720
+ existing.identity.sha256 ?? createHash("sha256").update(existing.data).digest("hex"),
721
+ );
722
+ if (!removed.ok) throw new Error(removed.code ?? "managed_consume_failed");
723
+ this.#assertBound();
724
+ return existing.data;
725
+ }
726
+ let snapshot: ManagedFileSnapshot;
727
+ try {
728
+ snapshot = captureManagedFileNoFollow(resolved);
729
+ } catch (error) {
730
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
731
+ throw error;
732
+ }
733
+ await unlinkManagedFileVerified(resolved, snapshot.identity);
734
+ this.#assertBound();
735
+ return snapshot.bytes;
736
+ }
737
+
738
+ /** Remove one managed descendant through retained authority when it exists. */
739
+ async remove(relativePath: string): Promise<void> {
740
+ await this.consume(relativePath);
741
+ }
742
+
743
+ /** Capture a complete descendant tree through this retained root. */
744
+ captureTree(relativePath: string): NativeDirectoryTreeSnapshot {
745
+ this.#assertBound();
746
+ const relative = this.#relative(this.#resolve(relativePath));
747
+ if (this.#authority) {
748
+ const captured = this.#authority.snapshotManagedTree(relative);
749
+ if (!captured.ok || !captured.snapshot) throw new Error(captured.code ?? "unsafe_artifacts");
750
+ return captured.snapshot;
751
+ }
752
+ const captured = snapshotDirectoryTree(this.#resolve(relativePath));
753
+ if (!captured.ok || !captured.snapshot) throw new Error(captured.code ?? "unsafe_artifacts");
754
+ return captured.snapshot;
755
+ }
756
+
757
+ /** Copy an exact captured tree into an absent managed destination. */
758
+ async importTree(
759
+ sourceRelativePath: string,
760
+ destinationRelativePath: string,
761
+ snapshot: NativeDirectoryTreeSnapshot,
762
+ ): Promise<void> {
763
+ const actual = this.captureTree(sourceRelativePath);
764
+ if (JSON.stringify(actual) !== JSON.stringify(snapshot)) throw new Error("artifact_source_changed");
765
+ this.ensureDirectory(destinationRelativePath);
766
+ for (const entry of snapshot.entries) {
767
+ if (entry.relativePath === "") continue;
768
+ const target = path.posix.join(destinationRelativePath.replaceAll(path.sep, "/"), entry.relativePath);
769
+ if (entry.kind === "directory") this.ensureDirectory(target);
770
+ else {
771
+ if (!this.#authority) throw new Error("managed_storage_unsupported");
772
+ const read = this.#authority.readManaged(
773
+ this.#relative(
774
+ this.#resolve(path.posix.join(sourceRelativePath.replaceAll(path.sep, "/"), entry.relativePath)),
775
+ ),
776
+ );
777
+ if (
778
+ !read.ok ||
779
+ !read.data ||
780
+ !read.identity ||
781
+ read.identity.dev !== entry.dev ||
782
+ read.identity.ino !== entry.ino ||
783
+ read.identity.size !== entry.size ||
784
+ read.identity.mtimeNs !== entry.mtimeNs ||
785
+ createHash("sha256").update(read.data).digest("hex") !== entry.sha256
786
+ )
787
+ throw new Error("artifact_source_changed");
788
+ await this.publishNoReplace(target, read.data);
789
+ }
790
+ }
791
+ const imported = this.captureTree(destinationRelativePath);
792
+ const comparable = (tree: NativeDirectoryTreeSnapshot) =>
793
+ tree.entries.map(entry => ({
794
+ relativePath: entry.relativePath,
795
+ kind: entry.kind,
796
+ size: entry.size,
797
+ sha256: entry.sha256,
798
+ }));
799
+ if (JSON.stringify(comparable(imported)) !== JSON.stringify(comparable(snapshot)))
800
+ throw new Error("artifact_destination_mismatch");
801
+ this.fsyncTree();
802
+ }
803
+
804
+ moveTreeNoReplace(
805
+ sourceRelativePath: string,
806
+ destinationRelativePath: string,
807
+ expected: NativeDirectoryTreeSnapshot,
808
+ ): void {
809
+ 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)
856
+ )
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
+ }
893
+ this.#assertBound();
894
+ }
895
+
896
+ removeTreeExpected(relativePath: string, expected: NativeDirectoryTreeSnapshot): void {
897
+ this.#assertBound();
898
+ if (!this.#authority) {
899
+ const removed = exactRemoveDirectoryTree(this.#resolve(relativePath), expected);
900
+ if (!removed.ok) throw new Error(removed.code ?? "managed_remove_failed");
901
+ this.#assertBound();
902
+ return;
903
+ }
904
+ const removed = this.#authority.removeManagedTree(this.#relative(this.#resolve(relativePath)), expected);
905
+ if (!removed.ok) throw new Error(removed.code ?? "managed_remove_failed");
906
+ this.#assertBound();
907
+ }
908
+ fsyncTree(): NativeDirectoryTreeSnapshot {
909
+ this.#assertBound();
910
+ if (!this.#authority) return fsyncManagedArtifactTree(this.#baseDir);
911
+ const baseRelative = this.#relative(this.#baseDir);
912
+ const before = this.#authority.snapshotManagedTree(baseRelative);
913
+ if (!before.ok || !before.snapshot) throw new Error(before.code ?? "unsafe_artifacts");
914
+ const entries = [...before.snapshot.entries].sort((left, right) => {
915
+ const leftDirectory = left.kind === "directory";
916
+ const rightDirectory = right.kind === "directory";
917
+ if (leftDirectory !== rightDirectory) return leftDirectory ? 1 : -1;
918
+ return right.relativePath.split("/").length - left.relativePath.split("/").length;
919
+ });
920
+ for (const entry of entries) {
921
+ const retainedPath = entry.relativePath ? path.posix.join(baseRelative, entry.relativePath) : baseRelative;
922
+ const synced = this.#authority.fsyncExpected(
923
+ retainedPath,
924
+ entry.kind === "directory",
925
+ entry.dev,
926
+ entry.ino,
927
+ entry.size,
928
+ entry.mtimeNs,
929
+ entry.sha256,
930
+ );
931
+ if (!synced.ok) throw new Error(synced.code ?? "fsync_failed");
932
+ }
933
+ const after = this.#authority.snapshotManagedTree(baseRelative);
934
+ if (!after.ok || !after.snapshot || JSON.stringify(after.snapshot) !== JSON.stringify(before.snapshot)) {
935
+ throw new Error("artifact_tree_changed_during_fsync");
936
+ }
937
+ this.#assertBound();
938
+ return after.snapshot;
939
+ }
940
+
941
+ #resolve(relativePath: string): string {
942
+ if (path.isAbsolute(relativePath) || relativePath.split(/[\\/]/).includes("..")) {
943
+ throw new Error("Managed descendant path escapes retained store");
944
+ }
945
+ const resolved = path.resolve(this.#baseDir, relativePath);
946
+ if (resolved !== this.#baseDir && !resolved.startsWith(`${this.#baseDir}${path.sep}`)) {
947
+ throw new Error("Managed descendant path escapes retained store");
948
+ }
949
+ assertManagedDirectoryRoot(this.#root);
950
+ return resolved;
951
+ }
952
+ }
953
+
954
+ function secureFileDescriptor(pathname: string, fd: number, operation: "apply" | "verify"): void {
955
+ if (process.platform !== "linux") {
956
+ if (operation === "apply") secure(pathname, "file");
957
+ else {
958
+ const verified = validateNativeSecurityResult(verifyOwnerOnlyPathSecurity(pathname, "file"), "verify", "file");
959
+ if (!verified.ok) throw securityError(pathname, verified);
960
+ }
961
+ return;
962
+ }
963
+ const result = validateNativeSecurityResult(
964
+ operation === "apply"
965
+ ? applyOwnerOnlyFdSecurity(pathname, "file", fd)
966
+ : verifyOwnerOnlyFdSecurity(pathname, "file", fd),
967
+ operation,
968
+ "file",
969
+ );
970
+ if (!result.ok) throw securityError(pathname, result);
971
+ }
972
+
68
973
  function assertSafeDirectory(pathname: string): void {
69
974
  const stat = fs.lstatSync(pathname);
70
975
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Unsafe managed directory: ${pathname}`);
@@ -92,13 +997,24 @@ function bootId(): string | undefined {
92
997
  }
93
998
  }
94
999
 
95
- function identity(stat: fs.BigIntStats): ManagedFileSnapshot["identity"] {
96
- return { dev: stat.dev, ino: stat.ino, size: Number(stat.size), mtimeNs: stat.mtimeNs };
1000
+ function identity(stat: fs.BigIntStats, sha256 = ""): ManagedFileSnapshot["identity"] {
1001
+ return {
1002
+ dev: stat.dev,
1003
+ ino: stat.ino,
1004
+ size: Number(stat.size),
1005
+ mtimeNs: stat.mtimeNs,
1006
+ ctimeNs: stat.ctimeNs,
1007
+ sha256,
1008
+ };
97
1009
  }
98
1010
 
99
1011
  function sameIdentity(left: ManagedFileSnapshot["identity"], right: ManagedFileSnapshot["identity"]): boolean {
100
1012
  return (
101
- left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs
1013
+ left.dev === right.dev &&
1014
+ left.ino === right.ino &&
1015
+ left.size === right.size &&
1016
+ left.mtimeNs === right.mtimeNs &&
1017
+ left.ctimeNs === right.ctimeNs
102
1018
  );
103
1019
  }
104
1020
 
@@ -144,19 +1060,46 @@ function sameFileIdentity(left: fs.BigIntStats, right: fs.BigIntStats): boolean
144
1060
  }
145
1061
 
146
1062
  /** Create a managed directory and fail closed unless its owner-only mode/ACL verifies. */
147
- export function ensureManagedDirectory(pathname: string): void {
148
- fs.mkdirSync(pathname, { recursive: true, mode: 0o700 });
149
- assertSafeDirectory(pathname);
150
- secure(pathname, "directory");
1063
+ export function ensureManagedDirectory(pathname: string, root?: ManagedDirectoryRoot): void {
1064
+ if (!root) {
1065
+ fs.mkdirSync(pathname, { recursive: true, mode: 0o700 });
1066
+ assertSafeDirectory(pathname);
1067
+ secure(pathname, "directory");
1068
+ return;
1069
+ }
1070
+ ensureManagedRoot(root);
1071
+ let current = root.canonicalPath;
1072
+ for (const component of managedRelativePath(root, pathname)) {
1073
+ // Re-inspect the captured root or already-secured descendant before every
1074
+ // descent; a replaced component cannot grant authority to the next one.
1075
+ if (current === root.canonicalPath) assertManagedDirectoryRoot(root);
1076
+ else {
1077
+ assertSafeDirectory(current);
1078
+ secure(current, "directory");
1079
+ }
1080
+ current = path.join(current, component);
1081
+ ensureDirectoryComponent(current);
1082
+ }
1083
+ }
1084
+
1085
+ /** Captures a bounded prefix from one no-follow descriptor and rechecks the pathname before use. */
1086
+ export function captureManagedFilePrefixNoFollow(pathname: string, maxBytes: number): ManagedFileSnapshot {
1087
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new Error("invalid_capture_limit");
1088
+ return captureManagedFileNoFollowLimit(pathname, maxBytes);
151
1089
  }
152
1090
 
153
1091
  /** Captures header/hash/copy input from one no-follow descriptor and rechecks the pathname before use. */
154
1092
  export function captureManagedFileNoFollow(pathname: string): ManagedFileSnapshot {
1093
+ return captureManagedFileNoFollowLimit(pathname);
1094
+ }
1095
+
1096
+ function captureManagedFileNoFollowLimit(pathname: string, maxBytes?: number): ManagedFileSnapshot {
155
1097
  const fd = fs.openSync(pathname, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
156
1098
  try {
157
1099
  const before = fs.fstatSync(fd, { bigint: true });
158
1100
  if (!before.isFile() || before.nlink > 1) throw new Error("source_changed");
159
- const bytes = Buffer.alloc(Number(before.size));
1101
+ const captureSize = maxBytes === undefined ? Number(before.size) : Math.min(Number(before.size), maxBytes);
1102
+ const bytes = Buffer.alloc(captureSize);
160
1103
  let offset = 0;
161
1104
  while (offset < bytes.byteLength) {
162
1105
  const count = fs.readSync(fd, bytes, offset, bytes.byteLength - offset, offset);
@@ -168,7 +1111,7 @@ export function captureManagedFileNoFollow(pathname: string): ManagedFileSnapsho
168
1111
  const named = fs.lstatSync(pathname, { bigint: true });
169
1112
  if (!named.isFile() || named.isSymbolicLink() || !sameIdentity(identity(before), identity(named)))
170
1113
  throw new Error("source_changed");
171
- return { bytes, identity: identity(before) };
1114
+ return { bytes, identity: identity(before, createHash("sha256").update(bytes).digest("hex")) };
172
1115
  } finally {
173
1116
  fs.closeSync(fd);
174
1117
  }
@@ -179,11 +1122,16 @@ export async function publishManagedFileNoReplace(
179
1122
  destination: string,
180
1123
  bytes: Uint8Array,
181
1124
  assertOwned?: () => void,
1125
+ root?: ManagedDirectoryRoot,
182
1126
  ): Promise<void> {
183
1127
  const parent = path.dirname(destination);
184
- ensureManagedDirectory(parent);
1128
+ ensureManagedDirectory(parent, root);
185
1129
  const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.staging`);
186
1130
  let fd: number | undefined;
1131
+ let stagingIdentity: { dev: bigint; ino: bigint } | undefined;
1132
+ let stagingSnapshot: ManagedFileSnapshot | undefined;
1133
+ let committed = false;
1134
+ let failure: unknown;
187
1135
  try {
188
1136
  assertOwned?.();
189
1137
  fd = fs.openSync(
@@ -191,82 +1139,231 @@ export async function publishManagedFileNoReplace(
191
1139
  fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW,
192
1140
  0o600,
193
1141
  );
1142
+ secureFileDescriptor(staging, fd, "apply");
194
1143
  let offset = 0;
195
1144
  while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
196
1145
  fs.fsyncSync(fd);
197
- fs.closeSync(fd);
198
- fd = undefined;
199
- secure(staging, "file");
1146
+ secureFileDescriptor(staging, fd, "verify");
1147
+ const staged = fs.fstatSync(fd, { bigint: true });
1148
+ 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
+ };
200
1153
  assertOwned?.();
1154
+
201
1155
  const published = renameNoReplacePath(staging, destination);
202
1156
  if (!published.ok) {
203
1157
  if (published.code === "quarantine_collision") throw new Error("destination_conflict");
204
1158
  throw new Error("durability_failed");
205
1159
  }
206
- secure(destination, "file");
1160
+ const named = fs.lstatSync(destination, { bigint: true });
1161
+ if (
1162
+ !named.isFile() ||
1163
+ named.isSymbolicLink() ||
1164
+ named.dev !== stagingIdentity.dev ||
1165
+ named.ino !== stagingIdentity.ino
1166
+ ) {
1167
+ throw new Error("destination_identity_changed");
1168
+ }
1169
+ secureFileDescriptor(destination, fd, "verify");
1170
+ fs.closeSync(fd);
1171
+ fd = undefined;
1172
+
207
1173
  fsyncDirectory(parent);
1174
+ committed = true;
1175
+ } catch (error) {
1176
+ failure = error;
208
1177
  } finally {
209
1178
  if (fd !== undefined) fs.closeSync(fd);
210
- await fsp.unlink(staging).catch(error => {
211
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
212
- });
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) {
1193
+ await fsp
1194
+ .lstat(staging, { bigint: true })
1195
+ .then(stat => {
1196
+ if (stat.dev === stagingIdentity?.dev && stat.ino === stagingIdentity.ino) return fsp.unlink(staging);
1197
+ throw new Error("staging_identity_changed");
1198
+ })
1199
+ .catch(error => {
1200
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") failure ??= error;
1201
+ });
1202
+ }
213
1203
  }
1204
+ if (failure !== undefined) throw failure;
214
1205
  }
215
1206
 
216
- export function publishManagedFileNoReplaceSync(destination: string, bytes: Uint8Array): void {
1207
+ export function publishManagedFileNoReplaceSync(
1208
+ destination: string,
1209
+ bytes: Uint8Array,
1210
+ root?: ManagedDirectoryRoot,
1211
+ ): void {
217
1212
  const parent = path.dirname(destination);
218
- ensureManagedDirectory(parent);
1213
+ ensureManagedDirectory(parent, root);
219
1214
  const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.staging`);
1215
+ let fd: number | undefined;
1216
+ let stagingIdentity: { dev: bigint; ino: bigint } | undefined;
1217
+ let stagingSnapshot: ManagedFileSnapshot | undefined;
1218
+ let committed = false;
220
1219
  let failure: unknown;
221
1220
  try {
222
- const fd = fs.openSync(
1221
+ fd = fs.openSync(
223
1222
  staging,
224
1223
  fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW,
225
1224
  0o600,
226
1225
  );
227
- try {
228
- let offset = 0;
229
- while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
230
- fs.fsyncSync(fd);
231
- } finally {
232
- fs.closeSync(fd);
233
- }
234
- secure(staging, "file");
1226
+ secureFileDescriptor(staging, fd, "apply");
1227
+ let offset = 0;
1228
+ while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
1229
+ fs.fsyncSync(fd);
1230
+ secureFileDescriptor(staging, fd, "verify");
1231
+ const staged = fs.fstatSync(fd, { bigint: true });
1232
+ 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
+
235
1238
  const published = renameNoReplacePath(staging, destination);
236
1239
  if (!published.ok) {
237
1240
  if (published.code === "quarantine_collision") throw new Error("destination_conflict");
238
1241
  throw new Error("durability_failed");
239
1242
  }
240
- secure(destination, "file");
1243
+ const named = fs.lstatSync(destination, { bigint: true });
1244
+ if (!named.isFile() || named.isSymbolicLink() || named.dev !== staged.dev || named.ino !== staged.ino) {
1245
+ throw new Error("destination_identity_changed");
1246
+ }
1247
+ secureFileDescriptor(destination, fd, "verify");
1248
+ fs.closeSync(fd);
1249
+ fd = undefined;
241
1250
  fsyncDirectory(parent);
1251
+ committed = true;
242
1252
  } catch (error) {
243
1253
  failure = error;
1254
+ } finally {
1255
+ if (fd !== undefined) fs.closeSync(fd);
244
1256
  }
245
- try {
246
- fs.unlinkSync(staging);
247
- } catch (error) {
248
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") failure ??= error;
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
+ }
1280
+ try {
1281
+ const named = fs.lstatSync(staging, { bigint: true });
1282
+ if (named.dev !== stagingIdentity.dev || named.ino !== stagingIdentity.ino) {
1283
+ throw new Error("staging_identity_changed");
1284
+ }
1285
+ fs.unlinkSync(staging);
1286
+ } catch (error) {
1287
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") failure ??= error;
1288
+ }
249
1289
  }
250
1290
  if (failure !== undefined) throw failure;
251
1291
  }
252
1292
 
1293
+ /** Replace one managed regular file while retaining the secured staging fd through publication. */
1294
+ export function replaceManagedFileSync(destination: string, bytes: Uint8Array, root: ManagedDirectoryRoot): void {
1295
+ const parent = path.dirname(destination);
1296
+ ensureManagedDirectory(parent, root);
1297
+ const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.replacement`);
1298
+ let fd: number | undefined;
1299
+ let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
1300
+ try {
1301
+ fd = fs.openSync(
1302
+ staging,
1303
+ fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW,
1304
+ 0o600,
1305
+ );
1306
+ secureFileDescriptor(staging, fd, "apply");
1307
+ let offset = 0;
1308
+ while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
1309
+ fs.fsyncSync(fd);
1310
+ secureFileDescriptor(staging, fd, "verify");
1311
+ const staged = fs.fstatSync(fd, { bigint: true });
1312
+ stagedIdentity = { dev: staged.dev, ino: staged.ino };
1313
+ assertManagedDirectoryRoot(root);
1314
+ fs.renameSync(staging, destination);
1315
+ assertManagedDirectoryRoot(root);
1316
+ const named = fs.lstatSync(destination, { bigint: true });
1317
+ if (!named.isFile() || named.isSymbolicLink() || named.dev !== staged.dev || named.ino !== staged.ino) {
1318
+ throw new Error("destination_identity_changed");
1319
+ }
1320
+ secureFileDescriptor(destination, fd, "verify");
1321
+ fs.closeSync(fd);
1322
+ fd = undefined;
1323
+ fsyncDirectory(parent);
1324
+ } finally {
1325
+ if (fd !== undefined) fs.closeSync(fd);
1326
+ if (stagedIdentity) {
1327
+ try {
1328
+ const named = fs.lstatSync(staging, { bigint: true });
1329
+ if (named.dev === stagedIdentity.dev && named.ino === stagedIdentity.ino) fs.unlinkSync(staging);
1330
+ } catch {
1331
+ // Never delete an object whose staging identity is no longer proven.
1332
+ }
1333
+ }
1334
+ }
1335
+ }
1336
+
1337
+ export async function replaceManagedFile(
1338
+ destination: string,
1339
+ bytes: Uint8Array,
1340
+ root: ManagedDirectoryRoot,
1341
+ ): Promise<void> {
1342
+ replaceManagedFileSync(destination, bytes, root);
1343
+ }
1344
+
253
1345
  /** Copy the exact bytes captured from one no-follow source descriptor. */
254
1346
  export async function copyManagedFileNoReplace(
255
1347
  source: string,
256
1348
  destination: string,
257
1349
  snapshot = captureManagedFileNoFollow(source),
1350
+ root?: ManagedDirectoryRoot,
258
1351
  ): Promise<void> {
259
1352
  const named = captureManagedFileNoFollow(source);
260
1353
  if (!sameIdentity(snapshot.identity, named.identity) || !snapshot.bytes.equals(named.bytes))
261
1354
  throw new Error("source_changed");
262
- await publishManagedFileNoReplace(destination, snapshot.bytes);
1355
+ await publishManagedFileNoReplace(destination, snapshot.bytes, undefined, root);
263
1356
  const destinationSnapshot = captureManagedFileNoFollow(destination);
264
1357
  if (!destinationSnapshot.bytes.equals(snapshot.bytes)) throw new Error("durability_failed");
265
1358
  }
266
1359
 
267
1360
  /** Acquire a lease lock with bounded wait, heartbeats, conservative stale reclaim, and fencing. */
268
- export async function acquireManagedLock(locksDirectory: string, name: string): Promise<ManagedStorageLock> {
269
- ensureManagedDirectory(locksDirectory);
1361
+ export async function acquireManagedLock(
1362
+ locksDirectory: string,
1363
+ name: string,
1364
+ root?: ManagedDirectoryRoot,
1365
+ ): Promise<ManagedStorageLock> {
1366
+ ensureManagedDirectory(locksDirectory, root);
270
1367
  const lockPath = path.join(locksDirectory, `${name}.lock`);
271
1368
  const deadline = Date.now() + LOCK_WAIT_MS;
272
1369
  let staleObservedAt: number | undefined;
@@ -289,9 +1386,9 @@ export async function acquireManagedLock(locksDirectory: string, name: string):
289
1386
  0o600,
290
1387
  );
291
1388
  try {
1389
+ secureFileDescriptor(lockPath, fd, "apply");
292
1390
  fs.writeFileSync(fd, `${JSON.stringify(record)}\n`);
293
1391
  fs.fsyncSync(fd);
294
- secure(lockPath, "file");
295
1392
  fsyncDirectory(locksDirectory);
296
1393
  } catch (error) {
297
1394
  fs.closeSync(fd);
@@ -301,7 +1398,10 @@ export async function acquireManagedLock(locksDirectory: string, name: string):
301
1398
  let released = false;
302
1399
  let descriptorClosed = false;
303
1400
  const closeDescriptor = (): void => {
304
- if (!descriptorClosed) {
1401
+ if (descriptorClosed) return;
1402
+ try {
1403
+ secureFileDescriptor(lockPath, fd, "verify");
1404
+ } finally {
305
1405
  fs.closeSync(fd);
306
1406
  descriptorClosed = true;
307
1407
  }
@@ -406,7 +1506,9 @@ export function validateManagedArtifactTree(root: string): void {
406
1506
  }
407
1507
 
408
1508
  /** Flush a copied managed artifact tree, including empty directories, before publishing its receipt. */
409
- export function fsyncManagedArtifactTree(root: string): void {
1509
+ export function fsyncManagedArtifactTree(root: string): NativeDirectoryTreeSnapshot {
1510
+ const before = snapshotDirectoryTree(root);
1511
+ if (!before.ok || !before.snapshot) throw new Error(before.code ?? "unsafe_artifacts");
410
1512
  const visit = (pathname: string): void => {
411
1513
  const stat = fs.lstatSync(pathname);
412
1514
  if (stat.isSymbolicLink()) throw new Error("unsafe_artifacts");
@@ -425,7 +1527,11 @@ export function fsyncManagedArtifactTree(root: string): void {
425
1527
  };
426
1528
  validateManagedArtifactTree(root);
427
1529
  visit(root);
428
- validateManagedArtifactTree(root);
1530
+ const after = snapshotDirectoryTree(root);
1531
+ if (!after.ok || !after.snapshot || JSON.stringify(after.snapshot) !== JSON.stringify(before.snapshot)) {
1532
+ throw new Error("artifact_tree_changed_during_fsync");
1533
+ }
1534
+ return after.snapshot;
429
1535
  }
430
1536
 
431
1537
  export async function publishManagedTombstone(