@gajae-code/coding-agent 0.11.7 → 0.11.9

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 +73 -1
  2. package/dist/types/cli/args.d.ts +0 -2
  3. package/dist/types/cli.d.ts +1 -0
  4. package/dist/types/config/keybindings.d.ts +33 -3
  5. package/dist/types/config/model-profile-activation.d.ts +4 -3
  6. package/dist/types/config/model-profile-contract.d.ts +38 -0
  7. package/dist/types/config/model-profiles.d.ts +2 -3
  8. package/dist/types/config/settings-schema.d.ts +15 -2
  9. package/dist/types/edit/modes/patch.d.ts +13 -0
  10. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  11. package/dist/types/eval/py/executor.d.ts +1 -1
  12. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  13. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
  14. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
  15. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
  16. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
  17. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
  18. package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
  19. package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
  20. package/dist/types/gjc-runtime/state-writer.d.ts +44 -0
  21. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +1 -0
  22. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +24 -0
  23. package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
  24. package/dist/types/main.d.ts +8 -0
  25. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  26. package/dist/types/modes/components/tool-status-header.d.ts +3 -2
  27. package/dist/types/modes/components/welcome.d.ts +4 -0
  28. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  30. package/dist/types/modes/interactive-mode.d.ts +5 -2
  31. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  32. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  33. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  34. package/dist/types/modes/types.d.ts +3 -0
  35. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  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/runtime-mcp/redaction.d.ts +2 -0
  40. package/dist/types/sdk/broker/broker.d.ts +2 -0
  41. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  42. package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
  43. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  44. package/dist/types/sdk/bus/config.d.ts +2 -0
  45. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  46. package/dist/types/sdk/bus/index.d.ts +25 -0
  47. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  48. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
  49. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +15 -3
  50. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  51. package/dist/types/sdk/bus/telegram-daemon.d.ts +128 -1
  52. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  53. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  54. package/dist/types/sdk/host/host.d.ts +1 -1
  55. package/dist/types/sdk/host/query/handlers.d.ts +9 -0
  56. package/dist/types/sdk/index.d.ts +2 -0
  57. package/dist/types/sdk/prompt-status.d.ts +88 -0
  58. package/dist/types/sdk/session-directory.d.ts +1 -1
  59. package/dist/types/sdk/startup-capability.d.ts +3 -0
  60. package/dist/types/session/agent-session.d.ts +2 -0
  61. package/dist/types/session/internal/managed-session-scope.d.ts +22 -3
  62. package/dist/types/session/internal/managed-session-storage.d.ts +24 -4
  63. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  64. package/dist/types/session/session-manager.d.ts +5 -1
  65. package/dist/types/session/session-storage.d.ts +31 -0
  66. package/dist/types/skill-state/active-state.d.ts +4 -0
  67. package/dist/types/task/index.d.ts +3 -1
  68. package/dist/types/task/receipt.d.ts +2 -0
  69. package/dist/types/task/types.d.ts +94 -0
  70. package/dist/types/tools/ask.d.ts +454 -174
  71. package/dist/types/tools/image-gen.d.ts +47 -1
  72. package/dist/types/tools/index.d.ts +4 -0
  73. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  74. package/package.json +8 -8
  75. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  76. package/scripts/dogfood-repository-binding.ts +209 -0
  77. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  78. package/scripts/run-test-manifest.ts +4 -4
  79. package/src/cli/args.ts +2 -92
  80. package/src/cli/mcp-cli.ts +4 -21
  81. package/src/cli/notify-cli.ts +5 -38
  82. package/src/cli.ts +50 -0
  83. package/src/commands/deep-interview.ts +9 -4
  84. package/src/commands/sdk.ts +2 -2
  85. package/src/commit/agentic/index.ts +3 -3
  86. package/src/commit/map-reduce/index.ts +2 -2
  87. package/src/config/keybindings.ts +157 -24
  88. package/src/config/model-profile-activation.ts +7 -18
  89. package/src/config/model-profile-contract.ts +161 -0
  90. package/src/config/model-profiles.ts +2 -3
  91. package/src/config/model-registry.ts +11 -1
  92. package/src/config/settings-schema.ts +17 -2
  93. package/src/coordinator-mcp/model-preset.ts +21 -74
  94. package/src/defaults/gjc/skills/deep-interview/SKILL.md +80 -165
  95. package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
  96. package/src/edit/index.ts +3 -6
  97. package/src/edit/modes/patch.ts +38 -1
  98. package/src/edit/modes/replace.ts +42 -0
  99. package/src/edit/path-mutation-lock.ts +112 -0
  100. package/src/eval/py/executor.ts +30 -11
  101. package/src/eval/py/prelude.py +2 -2
  102. package/src/eval/py/tool-bridge.ts +34 -18
  103. package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
  104. package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
  105. package/src/gjc-runtime/deep-interview-draft.ts +842 -0
  106. package/src/gjc-runtime/deep-interview-payload.ts +262 -0
  107. package/src/gjc-runtime/deep-interview-recorder.ts +294 -226
  108. package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
  109. package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
  110. package/src/gjc-runtime/deep-interview-state.ts +916 -4
  111. package/src/gjc-runtime/launch-tmux.ts +1 -1
  112. package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
  113. package/src/gjc-runtime/ralplan-runtime.ts +78 -4
  114. package/src/gjc-runtime/repository-binding.ts +267 -0
  115. package/src/gjc-runtime/session-state-sidecar.ts +25 -9
  116. package/src/gjc-runtime/state-writer.ts +166 -2
  117. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
  118. package/src/gjc-runtime/tmux-sessions.ts +45 -6
  119. package/src/gjc-runtime/ultragoal-guard.ts +5 -5
  120. package/src/gjc-runtime/ultragoal-runtime.ts +181 -19
  121. package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
  122. package/src/gjc-runtime/workflow-manifest.ts +191 -5
  123. package/src/internal-urls/docs-index.generated.ts +8 -6
  124. package/src/internal-urls/local-protocol.ts +33 -16
  125. package/src/internal-urls/mcp-protocol.ts +73 -11
  126. package/src/main.ts +25 -10
  127. package/src/modes/DESIGN.md +5 -0
  128. package/src/modes/acp/acp-agent.ts +50 -16
  129. package/src/modes/components/model-selector.ts +42 -8
  130. package/src/modes/components/plan-preview-overlay.ts +1 -1
  131. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  132. package/src/modes/components/tool-status-header.ts +20 -4
  133. package/src/modes/components/welcome.ts +32 -20
  134. package/src/modes/controllers/command-controller.ts +97 -54
  135. package/src/modes/controllers/event-controller.ts +10 -0
  136. package/src/modes/controllers/input-controller.ts +7 -2
  137. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  138. package/src/modes/controllers/selector-controller.ts +46 -54
  139. package/src/modes/interactive-mode.ts +70 -36
  140. package/src/modes/prompt-action-autocomplete.ts +32 -13
  141. package/src/modes/prompt-suggestion-controller.ts +96 -0
  142. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  143. package/src/modes/types.ts +3 -0
  144. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  145. package/src/modes/utils/ui-helpers.ts +5 -3
  146. package/src/prompts/agents/architect.md +0 -1
  147. package/src/prompts/agents/critic.md +0 -1
  148. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  149. package/src/prompts/system/system-prompt.md +7 -4
  150. package/src/prompts/tools/cron.md +3 -1
  151. package/src/runtime-mcp/json-rpc.ts +7 -4
  152. package/src/runtime-mcp/manager.ts +51 -53
  153. package/src/runtime-mcp/oauth-flow.ts +0 -72
  154. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  155. package/src/runtime-mcp/redaction.ts +30 -0
  156. package/src/runtime-mcp/transports/http.ts +13 -6
  157. package/src/sdk/broker/broker.ts +34 -20
  158. package/src/sdk/broker/lifecycle.ts +185 -36
  159. package/src/sdk/broker/session-index.ts +11 -1
  160. package/src/sdk/bus/chat-daemon-control.ts +20 -5
  161. package/src/sdk/bus/config-commands.ts +2 -2
  162. package/src/sdk/bus/config.ts +9 -1
  163. package/src/sdk/bus/conversation-store.ts +40 -6
  164. package/src/sdk/bus/index.ts +240 -65
  165. package/src/sdk/bus/lifecycle-commands.ts +47 -3
  166. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  167. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  168. package/src/sdk/bus/notification-orchestration.ts +12 -0
  169. package/src/sdk/bus/notification-service.ts +27 -2
  170. package/src/sdk/bus/prompt-reconciliation.ts +239 -0
  171. package/src/sdk/bus/recent-activity.ts +90 -26
  172. package/src/sdk/bus/telegram-daemon-contract.ts +15 -3
  173. package/src/sdk/bus/telegram-daemon-control.ts +145 -33
  174. package/src/sdk/bus/telegram-daemon.ts +853 -112
  175. package/src/sdk/bus/telegram-reference.ts +20 -2
  176. package/src/sdk/cli/session-cli.ts +15 -2
  177. package/src/sdk/host/control/dispatch.ts +1 -1
  178. package/src/sdk/host/control/operations.ts +1 -1
  179. package/src/sdk/host/host.ts +2 -6
  180. package/src/sdk/host/query/handlers.ts +70 -5
  181. package/src/sdk/index.ts +12 -0
  182. package/src/sdk/prompt-status.ts +97 -0
  183. package/src/sdk/protocol/operation-inventory.generated.json +47 -0
  184. package/src/sdk/protocol/operation-registry.ts +18 -4
  185. package/src/sdk/session-directory.ts +5 -1
  186. package/src/sdk/session.ts +2 -0
  187. package/src/sdk/startup-capability.ts +9 -1
  188. package/src/session/agent-session.ts +138 -5
  189. package/src/session/blob-store.ts +7 -2
  190. package/src/session/internal/managed-session-scope.ts +998 -133
  191. package/src/session/internal/managed-session-storage.ts +268 -203
  192. package/src/session/internal/native-publish-outcome.ts +266 -0
  193. package/src/session/session-manager.ts +406 -360
  194. package/src/session/session-storage.ts +151 -11
  195. package/src/skill-state/active-state.ts +10 -0
  196. package/src/task/executor.ts +13 -5
  197. package/src/task/gjc-command.ts +2 -2
  198. package/src/task/index.ts +121 -15
  199. package/src/task/receipt.ts +3 -0
  200. package/src/task/types.ts +34 -3
  201. package/src/tools/ask.ts +199 -88
  202. package/src/tools/ast-edit.ts +2 -2
  203. package/src/tools/bash-pty-selection.ts +2 -2
  204. package/src/tools/browser/screenshot-format.ts +6 -1
  205. package/src/tools/cron.ts +1 -1
  206. package/src/tools/image-gen.ts +178 -6
  207. package/src/tools/index.ts +4 -0
  208. package/src/tools/render-utils.ts +7 -1
  209. package/src/utils/edit-mode.ts +2 -2
  210. package/src/utils/prompt-suggestion.ts +265 -0
  211. package/src/utils/sixel.ts +3 -3
  212. package/src/web/insane/url-guard.ts +3 -1
  213. package/src/web/scrapers/docs-rs.ts +30 -5
@@ -18,16 +18,99 @@ import {
18
18
  verifyOwnerOnlyPathSecurity,
19
19
  verifyOwnerOnlyPathSecurityExpected,
20
20
  } from "@gajae-code/natives";
21
+ import {
22
+ classifyNativePublishOutcome,
23
+ formatNativePublishDiagnostic,
24
+ mayCleanCurrentStaging,
25
+ type NativePublishOutcome,
26
+ } from "./native-publish-outcome";
21
27
 
22
28
  export const MANAGED_ARTIFACT_MAX_DEPTH = 32;
23
- export const MANAGED_ARTIFACT_MAX_FILES = 10_000;
29
+ export const MANAGED_ARTIFACT_MAX_FILES = 50_000;
24
30
  export const MANAGED_ARTIFACT_MAX_FILE_BYTES = 64 * 1024 * 1024;
25
31
  export const MANAGED_ARTIFACT_MAX_TOTAL_BYTES = 512 * 1024 * 1024;
32
+ export const MANAGED_ARTIFACT_COPY_BATCH_SIZE = 256;
26
33
  const LOCK_LEASE_MS = 60_000;
27
34
  const LOCK_HEARTBEAT_MS = 10_000;
28
35
  const LOCK_WAIT_MS = 5_000;
29
36
 
30
37
  const LOCK_STALE_RECHECK_MS = 100;
38
+ export class ManagedPublishError extends Error {
39
+ readonly classification:
40
+ | "destination_conflict"
41
+ | "atomic_unavailable"
42
+ | "invalid_request"
43
+ | "durability_not_provable"
44
+ | "identity_mismatch"
45
+ | "io_error"
46
+ | "durability_failed";
47
+ readonly diagnostic: string;
48
+
49
+ constructor(classification: ManagedPublishError["classification"], outcome: NativePublishOutcome) {
50
+ super(classification);
51
+ this.name = "ManagedPublishError";
52
+ this.classification = classification;
53
+ this.diagnostic = formatNativePublishDiagnostic(outcome);
54
+ }
55
+ }
56
+
57
+ function publishFailure(outcome: NativePublishOutcome): ManagedPublishError {
58
+ const classification =
59
+ outcome.reason === "destination_exists"
60
+ ? "destination_conflict"
61
+ : outcome.reason === "atomic_unavailable"
62
+ ? "atomic_unavailable"
63
+ : outcome.reason === "invalid_request"
64
+ ? "invalid_request"
65
+ : outcome.reason === "durability_not_provable"
66
+ ? "durability_not_provable"
67
+ : outcome.reason === "identity_violation"
68
+ ? "identity_mismatch"
69
+ : outcome.reason === "io_failure"
70
+ ? "io_error"
71
+ : "durability_failed";
72
+ return new ManagedPublishError(classification, outcome);
73
+ }
74
+
75
+ /** A same-filesystem rename updates the moved root's ctime but no other tree identity. */
76
+ function sameDirectoryTreeSnapshotAfterRename(
77
+ left: NativeDirectoryTreeSnapshot,
78
+ right: NativeDirectoryTreeSnapshot,
79
+ ): boolean {
80
+ return (
81
+ left.rootDev === right.rootDev &&
82
+ left.rootIno === right.rootIno &&
83
+ left.entries.length === right.entries.length &&
84
+ left.entries.every((entry, index) => {
85
+ const other = right.entries[index];
86
+ return (
87
+ other !== undefined &&
88
+ entry.relativePath === other.relativePath &&
89
+ entry.kind === other.kind &&
90
+ entry.dev === other.dev &&
91
+ entry.ino === other.ino &&
92
+ entry.size === other.size &&
93
+ entry.mtimeNs === other.mtimeNs &&
94
+ (entry.relativePath === "" || entry.ctimeNs === other.ctimeNs) &&
95
+ entry.sha256 === other.sha256
96
+ );
97
+ })
98
+ );
99
+ }
100
+
101
+ class ManagedTreeMoveOutcomeError extends Error {
102
+ constructor(
103
+ message: string,
104
+ readonly stagingCleanupSafe: boolean,
105
+ ) {
106
+ super(message);
107
+ }
108
+ }
109
+
110
+ /** Cleanup is safe only when the native move reports that it did not commit. */
111
+ export function mayCleanManagedTreeStaging(error: unknown): boolean {
112
+ return !(error instanceof ManagedTreeMoveOutcomeError) || error.stagingCleanupSafe;
113
+ }
31
114
 
32
115
  export type ManagedSessionSecurityPolicy = "default" | "windows-existing-verify-first";
33
116
 
@@ -38,7 +121,10 @@ export type ManagedStorageFailure =
38
121
  | "destination_conflict"
39
122
  | "source_changed"
40
123
  | "unsafe_artifacts"
124
+ | "artifact_capacity_exceeded"
41
125
  | "durability_failed"
126
+ | "atomic_unavailable"
127
+ | "durability_not_provable"
42
128
  | "migration_retired"
43
129
  | "managed_storage_unsupported";
44
130
 
@@ -97,6 +183,9 @@ export function validateNativeSecurityResult(
97
183
  ) {
98
184
  throw new Error("Malformed ACL security failure evidence");
99
185
  }
186
+ if (operation === "verify" && result.operation !== "query") {
187
+ throw new Error("Verify failure unexpectedly reports ACL mutation");
188
+ }
100
189
  } else if (result.operation !== undefined || result.attribute !== undefined) {
101
190
  throw new Error("Unexpected ACL fields on owner-only security failure");
102
191
  }
@@ -183,6 +272,40 @@ export interface ManagedDirectoryRoot {
183
272
  readonly ino: bigint;
184
273
  }
185
274
 
275
+ /**
276
+ * Establish the first managed directory beneath a potentially shared ancestor.
277
+ * The ancestor is used only to create the managed chain; every component from
278
+ * `configuredRoot` downward is independently type, symlink, owner, mode, and
279
+ * ACL checked. This deliberately does not impose owner-only policy on `/tmp`.
280
+ */
281
+ export function prepareManagedDirectoryRoot(
282
+ configuredRoot: string,
283
+ policy: ManagedSessionSecurityPolicy = "default",
284
+ ): ManagedDirectoryRoot {
285
+ const target = path.resolve(configuredRoot);
286
+ const missing: string[] = [];
287
+ let existing = target;
288
+ for (;;) {
289
+ try {
290
+ assertSafeDirectory(existing);
291
+ break;
292
+ } catch (error) {
293
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
294
+ const parent = path.dirname(existing);
295
+ if (parent === existing) throw new Error(`Managed root is unavailable: ${configuredRoot}`);
296
+ missing.unshift(path.basename(existing));
297
+ existing = parent;
298
+ }
299
+ }
300
+ let current = fs.realpathSync.native(existing);
301
+ for (const component of missing) {
302
+ current = path.join(current, component);
303
+ const created = ensureDirectoryComponent(current);
304
+ secureManagedDirectory(current, created, policy);
305
+ }
306
+ if (missing.length === 0) secureManagedDirectory(current, false, policy);
307
+ return managedDirectoryRoot(current);
308
+ }
186
309
  export function managedDirectoryRoot(configuredRoot: string): ManagedDirectoryRoot {
187
310
  const canonicalPath = fs.realpathSync.native(configuredRoot);
188
311
  const stat = fs.lstatSync(canonicalPath, { bigint: true });
@@ -339,8 +462,8 @@ export class ManagedSessionDescendantStore {
339
462
  retained?: { authority: RecoveryFsRoot; authorityBaseDir: string },
340
463
  policy?: ManagedSessionSecurityPolicy,
341
464
  ) {
342
- assertManagedDirectoryRoot(root);
343
465
  managedRelativePath(root, baseDir);
466
+
344
467
  this.#root = root;
345
468
  this.#baseDir = path.resolve(baseDir);
346
469
  this.#policy = policy ?? "default";
@@ -350,20 +473,17 @@ export class ManagedSessionDescendantStore {
350
473
  const captured = retained.authority.snapshotManagedTree(relative);
351
474
  if (!captured.ok || !captured.snapshot)
352
475
  throw new Error(captured.code ?? "Managed subtree identity unavailable");
353
- const subtreeStat = fs.lstatSync(this.#baseDir, { bigint: true });
354
- if (
355
- captured.snapshot.rootDev !== subtreeStat.dev.toString() ||
356
- captured.snapshot.rootIno !== subtreeStat.ino.toString()
357
- )
358
- throw new Error("Managed descendant root identity changed");
359
476
  this.#subtreeRoot = Object.freeze({
360
477
  canonicalPath: this.#baseDir,
361
- dev: subtreeStat.dev,
362
- ino: subtreeStat.ino,
478
+ dev: BigInt(captured.snapshot.rootDev),
479
+ ino: BigInt(captured.snapshot.rootIno),
363
480
  });
364
481
  this.#authority = retained.authority;
482
+ this.#assertBound();
483
+
365
484
  return;
366
485
  }
486
+ assertManagedDirectoryRoot(root);
367
487
  ensureManagedDirectory(this.#baseDir, root, this.#policy);
368
488
  const subtreeStat = fs.lstatSync(this.#baseDir, { bigint: true });
369
489
  this.#subtreeRoot = Object.freeze({ canonicalPath: this.#baseDir, dev: subtreeStat.dev, ino: subtreeStat.ino });
@@ -557,7 +677,7 @@ export class ManagedSessionDescendantStore {
557
677
  appendSync(relativePath: string, bytes: Uint8Array): void {
558
678
  this.#assertBound();
559
679
  const resolved = this.#resolve(relativePath);
560
- const existing = this.readExpected(relativePath);
680
+ let existing = this.readExpected(relativePath);
561
681
  if (!existing) throw new Error("managed_append_missing");
562
682
  if (this.#authority) {
563
683
  const appended = this.#authority.appendManaged(
@@ -574,20 +694,49 @@ export class ManagedSessionDescendantStore {
574
694
  this.#assertBound();
575
695
  return;
576
696
  }
697
+ // On Darwin, the first write-append open can change only ctime (e.g. com.apple.provenance)
698
+ // while leaving dev/ino/size/mtime/content intact. Allow one bounded refresh+retry for that
699
+ // transition only; any other identity change or a second ctime transition stays fail-closed.
700
+ // See https://github.com/Yeachan-Heo/gajae-code/issues/2944
577
701
  let fd: number | undefined;
702
+ let darwinCtimeRefreshConsumed = false;
578
703
  try {
579
- fd = fs.openSync(resolved, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW);
580
- const before = identity(fs.fstatSync(fd, { bigint: true }));
581
- if (!sameIdentity(before, existing.identity)) throw new Error("identity_mismatch");
582
- secureFileDescriptor(resolved, fd, "verify");
583
- let offset = 0;
584
- while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
585
- fs.fsyncSync(fd);
586
- secureFileDescriptor(resolved, fd, "verify");
587
- const after = identity(fs.fstatSync(fd, { bigint: true }));
588
- const named = identity(fs.lstatSync(resolved, { bigint: true }));
589
- if (!sameIdentity(after, named) || after.dev !== before.dev || after.ino !== before.ino)
590
- throw new Error("identity_mismatch");
704
+ for (;;) {
705
+ fd = fs.openSync(resolved, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW);
706
+ const before = identity(fs.fstatSync(fd, { bigint: true }));
707
+ if (!sameIdentity(before, existing.identity)) {
708
+ const allowDarwinCtimeRefresh =
709
+ process.platform === "darwin" &&
710
+ !darwinCtimeRefreshConsumed &&
711
+ isCtimeOnlyIdentityMismatch(before, existing.identity);
712
+ if (!allowDarwinCtimeRefresh) throw new Error("identity_mismatch");
713
+ fs.closeSync(fd);
714
+ fd = undefined;
715
+ darwinCtimeRefreshConsumed = true;
716
+ const original = existing;
717
+ const refreshed = this.readExpected(relativePath);
718
+ if (
719
+ !refreshed ||
720
+ !sameStableIdentityIgnoringCtime(refreshed.identity, original.identity) ||
721
+ refreshed.identity.sha256 !== original.identity.sha256 ||
722
+ !refreshed.bytes.equals(original.bytes)
723
+ ) {
724
+ throw new Error("identity_mismatch");
725
+ }
726
+ existing = refreshed;
727
+ continue;
728
+ }
729
+ secureFileDescriptor(resolved, fd, "verify");
730
+ let offset = 0;
731
+ while (offset < bytes.byteLength) offset += fs.writeSync(fd, bytes, offset, bytes.byteLength - offset);
732
+ fs.fsyncSync(fd);
733
+ secureFileDescriptor(resolved, fd, "verify");
734
+ const after = identity(fs.fstatSync(fd, { bigint: true }));
735
+ const named = identity(fs.lstatSync(resolved, { bigint: true }));
736
+ if (!sameIdentity(after, named) || after.dev !== before.dev || after.ino !== before.ino)
737
+ throw new Error("identity_mismatch");
738
+ break;
739
+ }
591
740
  } finally {
592
741
  if (fd !== undefined) fs.closeSync(fd);
593
742
  }
@@ -608,6 +757,7 @@ export class ManagedSessionDescendantStore {
608
757
  if (!created.ok) throw new Error(created.code ?? "managed_publish_failed");
609
758
  if (!created.identity) throw new Error("managed_publish_identity_unavailable");
610
759
  let captured: ReturnType<RecoveryFsRoot["readManaged"]> | undefined;
760
+ let published: unknown;
611
761
  try {
612
762
  captured = this.#authority.readManaged(temporary);
613
763
  if (!captured.ok || !captured.identity || !captured.data)
@@ -633,7 +783,7 @@ export class ManagedSessionDescendantStore {
633
783
  digest,
634
784
  );
635
785
  if (!synced.ok) throw new Error(synced.code ?? "managed_publish_fsync_failed");
636
- const published = this.#authority.renameManagedFileNoReplace(
786
+ published = this.#authority.renameManagedFileNoReplace(
637
787
  temporary,
638
788
  relative,
639
789
  captured.identity.dev,
@@ -643,35 +793,14 @@ export class ManagedSessionDescendantStore {
643
793
  captured.identity.ctimeNs,
644
794
  digest,
645
795
  );
646
- if (!published.ok)
647
- throw new Error(
648
- published.code === "already_exists"
649
- ? "destination_conflict"
650
- : (published.code ?? "managed_publish_failed"),
651
- );
796
+ const outcome = classifyNativePublishOutcome(published, "retained_file");
797
+ if (!outcome.ok) throw publishFailure(outcome);
652
798
  } catch (error) {
653
- if (captured?.ok && captured.identity && captured.data) {
654
- const digest = captured.identity.sha256 ?? createHash("sha256").update(captured.data).digest("hex");
655
- const installed = this.#authority.readManaged(relative);
656
- if (
657
- installed.ok &&
658
- installed.identity &&
659
- installed.data &&
660
- installed.identity.dev === captured.identity.dev &&
661
- installed.identity.ino === captured.identity.ino &&
662
- (installed.identity.sha256 ?? createHash("sha256").update(installed.data).digest("hex")) === digest
663
- ) {
664
- const removed = this.#authority.removeManaged(
665
- relative,
666
- installed.identity.dev,
667
- installed.identity.ino,
668
- installed.identity.size,
669
- installed.identity.mtimeNs,
670
- installed.identity.ctimeNs,
671
- digest,
672
- );
673
- if (!removed.ok) throw new Error(removed.code ?? "managed_publish_reconcile_failed");
674
- }
799
+ // A committed or unknown native outcome is evidence, not authorization to
800
+ // probe or remove the destination. Only a validated pre-mutation result
801
+ // may clean this operation's still-named staging object.
802
+ const outcome = captured ? classifyNativePublishOutcome(published, "retained_file") : undefined;
803
+ if (captured?.ok && captured.identity && captured.data && outcome && mayCleanCurrentStaging(outcome)) {
675
804
  const staged = this.#authority.readManaged(temporary);
676
805
  if (
677
806
  staged.ok &&
@@ -689,7 +818,8 @@ export class ManagedSessionDescendantStore {
689
818
  staged.identity.ctimeNs,
690
819
  staged.identity.sha256 ?? createHash("sha256").update(staged.data).digest("hex"),
691
820
  );
692
- if (!removed.ok) throw new Error(removed.code ?? "managed_publish_reconcile_failed");
821
+ if (!removed.ok && removed.code !== "cleanup_pending")
822
+ throw new Error(removed.code ?? "managed_publish_reconcile_failed");
693
823
  }
694
824
  }
695
825
  throw error;
@@ -762,7 +892,17 @@ export class ManagedSessionDescendantStore {
762
892
  sha256: expected.identity.sha256,
763
893
  quarantineName: `.gjc-remove-${process.pid}-${randomUUID()}`,
764
894
  });
765
- if (!removed.ok) throw new Error(removed.code ?? "managed_remove_failed");
895
+ if (
896
+ !removed.ok &&
897
+ !(
898
+ removed.code === "cleanup_pending" &&
899
+ (removed.detachedPath ??
900
+ removed.retainedSuccessorPath ??
901
+ removed.retainedPlaceholderPath ??
902
+ removed.retainedUnknownPath) !== undefined
903
+ )
904
+ )
905
+ throw new Error(removed.code ?? "managed_remove_failed");
766
906
  this.#assertBound();
767
907
  return;
768
908
  }
@@ -775,7 +915,8 @@ export class ManagedSessionDescendantStore {
775
915
  expected.identity.ctimeNs.toString(),
776
916
  expected.identity.sha256,
777
917
  );
778
- if (!removed.ok) throw new Error(removed.code ?? "managed_remove_failed");
918
+ if (!removed.ok && !(removed.code === "cleanup_pending" && removed.recoveryPath !== undefined))
919
+ throw new Error(removed.code ?? "managed_remove_failed");
779
920
  this.#assertBound();
780
921
  }
781
922
  /** Read and remove one managed descendant through retained authority. */
@@ -799,7 +940,8 @@ export class ManagedSessionDescendantStore {
799
940
  existing.identity.ctimeNs,
800
941
  existing.identity.sha256 ?? createHash("sha256").update(existing.data).digest("hex"),
801
942
  );
802
- if (!removed.ok) throw new Error(removed.code ?? "managed_consume_failed");
943
+ if (!removed.ok && !(removed.code === "cleanup_pending" && removed.recoveryPath !== undefined))
944
+ throw new Error(removed.code ?? "managed_consume_failed");
803
945
  this.#assertBound();
804
946
  return existing.data;
805
947
  }
@@ -885,92 +1027,31 @@ export class ManagedSessionDescendantStore {
885
1027
  sourceRelativePath: string,
886
1028
  destinationRelativePath: string,
887
1029
  expected: NativeDirectoryTreeSnapshot,
888
- ): void {
1030
+ ): NativeDirectoryTreeSnapshot {
889
1031
  this.#assertBound();
890
- if (!this.#authority) {
891
- const source = this.#resolve(sourceRelativePath);
892
- const destination = this.#resolve(destinationRelativePath);
893
- if (fs.existsSync(destination)) throw new Error("already_exists");
894
- let destinationSnapshot: NativeDirectoryTreeSnapshot | undefined;
895
- try {
896
- this.ensureDirectory(destinationRelativePath);
897
- destinationSnapshot = this.captureTree(destinationRelativePath);
898
- for (const entry of expected.entries) {
899
- if (entry.relativePath === "") continue;
900
- const target = path.posix.join(destinationRelativePath.replaceAll(path.sep, "/"), entry.relativePath);
901
- if (entry.kind === "directory") {
902
- this.ensureDirectory(target);
903
- destinationSnapshot = this.captureTree(destinationRelativePath);
904
- continue;
905
- }
906
- const sourceEntry = this.readExpected(
907
- path.posix.join(sourceRelativePath.replaceAll(path.sep, "/"), entry.relativePath),
908
- );
909
- if (
910
- !sourceEntry ||
911
- sourceEntry.identity.dev.toString() !== entry.dev ||
912
- sourceEntry.identity.ino.toString() !== entry.ino ||
913
- sourceEntry.identity.size.toString() !== entry.size ||
914
- sourceEntry.identity.mtimeNs.toString() !== entry.mtimeNs ||
915
- sourceEntry.identity.sha256 !== entry.sha256
916
- )
917
- throw new Error("artifact_source_changed");
918
- this.publishNoReplaceSync(target, sourceEntry.bytes);
919
- destinationSnapshot = this.captureTree(destinationRelativePath);
920
- }
921
- destinationSnapshot = this.captureTree(destinationRelativePath);
922
- const comparable = (tree: NativeDirectoryTreeSnapshot) =>
923
- tree.entries.map(entry => ({
924
- relativePath: entry.relativePath,
925
- kind: entry.kind,
926
- size: entry.size,
927
- sha256: entry.sha256,
928
- }));
929
- if (JSON.stringify(comparable(destinationSnapshot)) !== JSON.stringify(comparable(expected)))
930
- throw new Error("artifact_destination_mismatch");
931
- const terminalSource = snapshotDirectoryTree(source);
932
- if (
933
- !terminalSource.ok ||
934
- !terminalSource.snapshot ||
935
- JSON.stringify(terminalSource.snapshot) !== JSON.stringify(expected)
1032
+ const moved = this.#authority
1033
+ ? this.#authority.renameManagedTreeNoReplace(
1034
+ this.#relative(this.#resolve(sourceRelativePath)),
1035
+ this.#relative(this.#resolve(destinationRelativePath)),
1036
+ expected,
936
1037
  )
937
- throw new Error("artifact_source_changed");
938
- const retired = exactRemoveDirectoryTree(source, expected);
939
- if (!retired.ok) throw new Error(retired.code ?? "managed_remove_failed");
940
- } catch (error) {
941
- if (destinationSnapshot) {
942
- const removed = exactRemoveDirectoryTree(destination, destinationSnapshot);
943
- if (!removed.ok) throw new Error(removed.code ?? "managed_publish_reconcile_failed");
944
- }
945
- throw error;
946
- }
947
- this.#assertBound();
948
- return;
949
- }
950
- const moved = this.#authority.renameManagedTreeNoReplace(
951
- this.#relative(this.#resolve(sourceRelativePath)),
952
- this.#relative(this.#resolve(destinationRelativePath)),
953
- expected,
954
- );
955
- if (!moved.ok) {
956
- try {
957
- const installed = this.captureTree(destinationRelativePath);
958
- const comparable = (tree: NativeDirectoryTreeSnapshot) =>
959
- tree.entries.map(entry => ({
960
- relativePath: entry.relativePath,
961
- kind: entry.kind,
962
- size: entry.size,
963
- sha256: entry.sha256,
964
- }));
965
- if (JSON.stringify(comparable(installed)) !== JSON.stringify(comparable(expected)))
966
- throw new Error("managed_publish_reconcile_failed");
967
- this.removeTreeExpected(destinationRelativePath, installed);
968
- } catch (error) {
969
- if (!(error instanceof Error) || error.message !== "not_found") throw error;
970
- }
971
- throw new Error(moved.code ?? "managed_move_failed");
972
- }
1038
+ : renameNoReplacePath(this.#resolve(sourceRelativePath), this.#resolve(destinationRelativePath));
1039
+ const outcome = classifyNativePublishOutcome(moved, this.#authority ? "retained_tree" : "direct_rename");
1040
+ if (!outcome.ok)
1041
+ throw new ManagedTreeMoveOutcomeError(publishFailure(outcome).message, mayCleanCurrentStaging(outcome));
1042
+ const movedSnapshot = this.#authority
1043
+ ? this.#authority.snapshotManagedTree(this.#relative(this.#resolve(destinationRelativePath)))
1044
+ : snapshotDirectoryTree(this.#resolve(destinationRelativePath));
1045
+ if (
1046
+ !movedSnapshot.ok ||
1047
+ !movedSnapshot.snapshot ||
1048
+ !sameDirectoryTreeSnapshotAfterRename(movedSnapshot.snapshot, expected)
1049
+ )
1050
+ throw new ManagedTreeMoveOutcomeError("artifact_destination_mismatch", false);
1051
+ if (!this.#authority) fsyncDirectory(this.#baseDir);
1052
+
973
1053
  this.#assertBound();
1054
+ return movedSnapshot.snapshot;
974
1055
  }
975
1056
 
976
1057
  removeTreeExpected(relativePath: string, expected: NativeDirectoryTreeSnapshot): void {
@@ -1098,6 +1179,24 @@ function sameIdentity(left: ManagedFileSnapshot["identity"], right: ManagedFileS
1098
1179
  );
1099
1180
  }
1100
1181
 
1182
+ /** Content/path identity that remains meaningful across a Darwin write-open ctime-only transition. */
1183
+ function sameStableIdentityIgnoringCtime(
1184
+ left: ManagedFileSnapshot["identity"],
1185
+ right: ManagedFileSnapshot["identity"],
1186
+ ): boolean {
1187
+ return (
1188
+ left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs
1189
+ );
1190
+ }
1191
+
1192
+ /** True when observed identity matches expected on every field except ctimeNs. */
1193
+ function isCtimeOnlyIdentityMismatch(
1194
+ observed: ManagedFileSnapshot["identity"],
1195
+ expected: ManagedFileSnapshot["identity"],
1196
+ ): boolean {
1197
+ return sameStableIdentityIgnoringCtime(observed, expected) && observed.ctimeNs !== expected.ctimeNs;
1198
+ }
1199
+
1101
1200
  function parseLock(pathname: string): LockRecord | undefined {
1102
1201
  try {
1103
1202
  const stat = fs.lstatSync(pathname);
@@ -1228,9 +1327,10 @@ export async function publishManagedFileNoReplace(
1228
1327
  const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.staging`);
1229
1328
  let fd: number | undefined;
1230
1329
  let stagingIdentity: { dev: bigint; ino: bigint } | undefined;
1231
- let stagingSnapshot: ManagedFileSnapshot | undefined;
1232
- let committed = false;
1233
1330
  let failure: unknown;
1331
+ let outcome: NativePublishOutcome | undefined;
1332
+ let renameAttempted = false;
1333
+
1234
1334
  try {
1235
1335
  assertOwned?.();
1236
1336
  fd = fs.openSync(
@@ -1245,17 +1345,13 @@ export async function publishManagedFileNoReplace(
1245
1345
  secureFileDescriptor(staging, fd, "verify");
1246
1346
  const staged = fs.fstatSync(fd, { bigint: true });
1247
1347
  stagingIdentity = { dev: staged.dev, ino: staged.ino };
1248
- stagingSnapshot = {
1249
- bytes: Buffer.from(bytes),
1250
- identity: identity(staged, createHash("sha256").update(bytes).digest("hex")),
1251
- };
1252
1348
  assertOwned?.();
1253
1349
 
1254
- const published = renameNoReplacePath(staging, destination);
1255
- if (!published.ok) {
1256
- if (published.code === "quarantine_collision") throw new Error("destination_conflict");
1257
- throw new Error("durability_failed");
1258
- }
1350
+ renameAttempted = true;
1351
+ outcome = classifyNativePublishOutcome(renameNoReplacePath(staging, destination));
1352
+
1353
+ if (!outcome.ok) throw publishFailure(outcome);
1354
+
1259
1355
  const named = fs.lstatSync(destination, { bigint: true });
1260
1356
  if (
1261
1357
  !named.isFile() ||
@@ -1270,25 +1366,12 @@ export async function publishManagedFileNoReplace(
1270
1366
  fd = undefined;
1271
1367
 
1272
1368
  fsyncDirectory(parent);
1273
- committed = true;
1274
1369
  } catch (error) {
1275
1370
  failure = error;
1276
1371
  } finally {
1277
1372
  if (fd !== undefined) fs.closeSync(fd);
1278
- if (!committed && stagingSnapshot) {
1279
- try {
1280
- const installed = captureManagedFileNoFollow(destination);
1281
- if (
1282
- installed.identity.dev === stagingSnapshot.identity.dev &&
1283
- installed.identity.ino === stagingSnapshot.identity.ino &&
1284
- installed.identity.sha256 === stagingSnapshot.identity.sha256
1285
- )
1286
- await unlinkManagedFileVerified(destination, installed.identity);
1287
- } catch (error) {
1288
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") failure ??= error;
1289
- }
1290
- }
1291
- if (stagingIdentity) {
1373
+
1374
+ if (stagingIdentity && (!renameAttempted || (outcome && mayCleanCurrentStaging(outcome)))) {
1292
1375
  await fsp
1293
1376
  .lstat(staging, { bigint: true })
1294
1377
  .then(stat => {
@@ -1314,9 +1397,9 @@ export function publishManagedFileNoReplaceSync(
1314
1397
  const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.staging`);
1315
1398
  let fd: number | undefined;
1316
1399
  let stagingIdentity: { dev: bigint; ino: bigint } | undefined;
1317
- let stagingSnapshot: ManagedFileSnapshot | undefined;
1318
- let committed = false;
1319
1400
  let failure: unknown;
1401
+ let outcome: NativePublishOutcome | undefined;
1402
+
1320
1403
  try {
1321
1404
  fd = fs.openSync(
1322
1405
  staging,
@@ -1330,16 +1413,10 @@ export function publishManagedFileNoReplaceSync(
1330
1413
  secureFileDescriptor(staging, fd, "verify");
1331
1414
  const staged = fs.fstatSync(fd, { bigint: true });
1332
1415
  stagingIdentity = { dev: staged.dev, ino: staged.ino };
1333
- stagingSnapshot = {
1334
- bytes: Buffer.from(bytes),
1335
- identity: identity(staged, createHash("sha256").update(bytes).digest("hex")),
1336
- };
1337
1416
 
1338
- const published = renameNoReplacePath(staging, destination);
1339
- if (!published.ok) {
1340
- if (published.code === "quarantine_collision") throw new Error("destination_conflict");
1341
- throw new Error("durability_failed");
1342
- }
1417
+ outcome = classifyNativePublishOutcome(renameNoReplacePath(staging, destination));
1418
+ if (!outcome.ok) throw publishFailure(outcome);
1419
+
1343
1420
  const named = fs.lstatSync(destination, { bigint: true });
1344
1421
  if (!named.isFile() || named.isSymbolicLink() || named.dev !== staged.dev || named.ino !== staged.ino) {
1345
1422
  throw new Error("destination_identity_changed");
@@ -1348,35 +1425,12 @@ export function publishManagedFileNoReplaceSync(
1348
1425
  fs.closeSync(fd);
1349
1426
  fd = undefined;
1350
1427
  fsyncDirectory(parent);
1351
- committed = true;
1352
1428
  } catch (error) {
1353
1429
  failure = error;
1354
1430
  } finally {
1355
1431
  if (fd !== undefined) fs.closeSync(fd);
1356
1432
  }
1357
- if (stagingIdentity) {
1358
- if (!committed && stagingSnapshot) {
1359
- try {
1360
- const installed = captureManagedFileNoFollow(destination);
1361
- if (
1362
- installed.identity.dev === stagingSnapshot.identity.dev &&
1363
- installed.identity.ino === stagingSnapshot.identity.ino &&
1364
- installed.identity.sha256 === stagingSnapshot.identity.sha256
1365
- ) {
1366
- const removed = exactUnlink(destination, {
1367
- dev: installed.identity.dev,
1368
- ino: installed.identity.ino,
1369
- size: BigInt(installed.identity.size),
1370
- mtimeNs: installed.identity.mtimeNs,
1371
- sha256: installed.identity.sha256,
1372
- quarantineName: `.gjc-remove-${process.pid}-${randomUUID()}`,
1373
- });
1374
- if (!removed.ok) failure ??= new Error(removed.code ?? "managed_publish_reconcile_failed");
1375
- }
1376
- } catch (error) {
1377
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") failure ??= error;
1378
- }
1379
- }
1433
+ if (stagingIdentity && outcome && mayCleanCurrentStaging(outcome)) {
1380
1434
  try {
1381
1435
  const named = fs.lstatSync(staging, { bigint: true });
1382
1436
  if (named.dev !== stagingIdentity.dev || named.ino !== stagingIdentity.ino) {
@@ -1586,8 +1640,20 @@ export async function acquireManagedLock(
1586
1640
  }
1587
1641
  }
1588
1642
 
1643
+ export interface ManagedArtifactTreeLimits {
1644
+ maxFiles?: number;
1645
+ maxTotalBytes?: number;
1646
+ }
1647
+
1589
1648
  /** Bounds a no-follow artifact tree before it can be copied or deleted. */
1590
- export function validateManagedArtifactTree(root: string): void {
1649
+ export function validateManagedArtifactTree(root: string, limits: ManagedArtifactTreeLimits = {}): void {
1650
+ const clampLimit = (limit: number | undefined, maximum: number): number => {
1651
+ if (limit === undefined) return maximum;
1652
+ if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit <= 0) throw new Error("unsafe_artifacts");
1653
+ return Math.min(limit, maximum);
1654
+ };
1655
+ const maxFiles = clampLimit(limits.maxFiles, MANAGED_ARTIFACT_MAX_FILES);
1656
+ const maxTotalBytes = clampLimit(limits.maxTotalBytes, MANAGED_ARTIFACT_MAX_TOTAL_BYTES);
1591
1657
  let files = 0;
1592
1658
  let bytes = 0;
1593
1659
  const visit = (directory: string, depth: number): void => {
@@ -1604,8 +1670,7 @@ export function validateManagedArtifactTree(root: string): void {
1604
1670
  if (!stat.isFile() || stat.size > MANAGED_ARTIFACT_MAX_FILE_BYTES) throw new Error("unsafe_artifacts");
1605
1671
  files++;
1606
1672
  bytes += stat.size;
1607
- if (files > MANAGED_ARTIFACT_MAX_FILES || bytes > MANAGED_ARTIFACT_MAX_TOTAL_BYTES)
1608
- throw new Error("unsafe_artifacts");
1673
+ if (files > maxFiles || bytes > maxTotalBytes) throw new Error("artifact_capacity_exceeded");
1609
1674
  }
1610
1675
  };
1611
1676
  const rootStat = fs.lstatSync(root);