@gajae-code/coding-agent 0.12.0 → 0.12.2

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 (247) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/types/async/job-manager.d.ts +17 -3
  3. package/dist/types/config/settings-schema.d.ts +56 -2
  4. package/dist/types/exa/mcp-client.d.ts +10 -1
  5. package/dist/types/exec/bash-executor.d.ts +45 -12
  6. package/dist/types/extensibility/extensions/types.d.ts +16 -2
  7. package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
  8. package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
  9. package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
  10. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
  11. package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
  12. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
  13. package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
  14. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  15. package/dist/types/gjc-runtime/ralplan-review-conflicts.d.ts +114 -0
  16. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +23 -3
  17. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  18. package/dist/types/gjc-runtime/team-runtime.d.ts +25 -0
  19. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
  20. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +10 -0
  21. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +37 -80
  22. package/dist/types/modes/acp/acp-agent.d.ts +2 -19
  23. package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
  24. package/dist/types/modes/components/irc-sidebar.d.ts +16 -0
  25. package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
  26. package/dist/types/modes/components/session-observer-overlay.d.ts +18 -0
  27. package/dist/types/modes/components/settings-selector.d.ts +9 -0
  28. package/dist/types/modes/components/status-line/gh.d.ts +5 -0
  29. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
  30. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  31. package/dist/types/modes/controllers/event-controller.d.ts +1 -1
  32. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
  33. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  34. package/dist/types/modes/interactive-mode.d.ts +9 -0
  35. package/dist/types/modes/theme/theme.d.ts +3 -1
  36. package/dist/types/modes/types.d.ts +32 -0
  37. package/dist/types/reminders/star-reminder.d.ts +3 -13
  38. package/dist/types/runtime-mcp/manager.d.ts +1 -0
  39. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  40. package/dist/types/sdk/broker/transport.d.ts +2 -0
  41. package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
  42. package/dist/types/sdk/bus/chat-daemon-control.d.ts +5 -1
  43. package/dist/types/sdk/bus/config.d.ts +6 -2
  44. package/dist/types/sdk/bus/index.d.ts +29 -0
  45. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +8 -1
  46. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  47. package/dist/types/sdk/bus/reconciliation-store.d.ts +7 -3
  48. package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
  49. package/dist/types/sdk/bus/rich-render.d.ts +2 -0
  50. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
  51. package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
  52. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -4
  53. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
  54. package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
  55. package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
  56. package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
  57. package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
  58. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  59. package/dist/types/sdk/prompt-status.d.ts +23 -3
  60. package/dist/types/sdk/session.d.ts +6 -0
  61. package/dist/types/session/agent-session.d.ts +40 -2
  62. package/dist/types/session/artifacts.d.ts +8 -0
  63. package/dist/types/session/blob-store.d.ts +57 -4
  64. package/dist/types/session/internal/managed-session-storage.d.ts +30 -4
  65. package/dist/types/session/session-manager.d.ts +23 -6
  66. package/dist/types/session/session-storage.d.ts +4 -0
  67. package/dist/types/session/streaming-output.d.ts +36 -7
  68. package/dist/types/setup/credential-import.d.ts +12 -0
  69. package/dist/types/skill-state/active-state.d.ts +12 -0
  70. package/dist/types/skill-state/workflow-hud.d.ts +6 -0
  71. package/dist/types/task/executor.d.ts +3 -2
  72. package/dist/types/task/index.d.ts +7 -1
  73. package/dist/types/task/output-manager.d.ts +1 -0
  74. package/dist/types/task/provider-retry-status.d.ts +8 -0
  75. package/dist/types/task/receipt.d.ts +9 -1
  76. package/dist/types/task/types.d.ts +23 -0
  77. package/dist/types/tools/ask-answer-registry.d.ts +2 -2
  78. package/dist/types/tools/bash-interactive.d.ts +4 -1
  79. package/dist/types/tools/bash.d.ts +3 -1
  80. package/dist/types/tools/image-gen.d.ts +4 -0
  81. package/dist/types/tools/index.d.ts +6 -0
  82. package/dist/types/tools/output-meta.d.ts +23 -2
  83. package/dist/types/tools/path-utils.d.ts +1 -0
  84. package/dist/types/tools/read.d.ts +0 -6
  85. package/dist/types/tools/review.d.ts +5 -7
  86. package/dist/types/tools/subagent.d.ts +2 -0
  87. package/dist/types/utils/gh.d.ts +13 -0
  88. package/dist/types/utils/image-resize.d.ts +1 -0
  89. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  90. package/dist/types/web/search/providers/searxng.d.ts +7 -0
  91. package/dist/types/web/search/providers/xai.d.ts +2 -0
  92. package/package.json +10 -8
  93. package/scripts/acp-conformance-agent.ts +218 -0
  94. package/scripts/capture-gjc-bundle-settings.ts +306 -0
  95. package/scripts/dogfood-ralplan-review-conflicts.ts +272 -0
  96. package/scripts/generate-sdk-operation-inventory.ts +5 -0
  97. package/scripts/resident-memory-bench.ts +1341 -0
  98. package/scripts/run-acp-conformance.ts +290 -0
  99. package/src/async/job-manager.ts +99 -39
  100. package/src/cli/fast-help.ts +1 -1
  101. package/src/cli/plugin-cli.ts +176 -18
  102. package/src/commands/team.ts +19 -1
  103. package/src/config/file-lock.ts +11 -2
  104. package/src/config/settings-schema.ts +53 -2
  105. package/src/coordinator-mcp/question-gate-codec.ts +1 -1
  106. package/src/coordinator-mcp/server.ts +5 -12
  107. package/src/debug/index.ts +42 -17
  108. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -0
  109. package/src/defaults/gjc/skills/ralplan/SKILL.md +25 -20
  110. package/src/defaults/gjc/skills/team/SKILL.md +2 -0
  111. package/src/defaults/gjc/skills/ultragoal/SKILL.md +39 -26
  112. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
  113. package/src/defaults/gjc-defaults.ts +4 -4
  114. package/src/exa/mcp-client.ts +12 -3
  115. package/src/exec/bash-executor.ts +144 -27
  116. package/src/extensibility/extensions/runner.ts +15 -0
  117. package/src/extensibility/extensions/types.ts +18 -1
  118. package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
  119. package/src/extensibility/gjc-plugins/index.ts +19 -2
  120. package/src/extensibility/gjc-plugins/installer.ts +189 -50
  121. package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
  122. package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
  123. package/src/extensibility/gjc-plugins/observability.ts +3 -3
  124. package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
  125. package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
  126. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
  127. package/src/extensibility/gjc-plugins/schema.ts +84 -12
  128. package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
  129. package/src/extensibility/gjc-plugins/types.ts +141 -0
  130. package/src/extensibility/gjc-plugins/validation.ts +9 -0
  131. package/src/gjc-runtime/launch-tmux.ts +25 -6
  132. package/src/gjc-runtime/launch-worktree.ts +103 -1
  133. package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
  134. package/src/gjc-runtime/managed-owner-admission.ts +1 -1
  135. package/src/gjc-runtime/ralplan-review-conflicts.ts +472 -0
  136. package/src/gjc-runtime/ralplan-runtime.ts +491 -27
  137. package/src/gjc-runtime/team-launch.ts +8 -0
  138. package/src/gjc-runtime/team-runtime.ts +140 -32
  139. package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
  140. package/src/gjc-runtime/tmux-owner-isolation.ts +10 -0
  141. package/src/gjc-runtime/tmux-sessions.ts +58 -13
  142. package/src/gjc-runtime/ultragoal-evidence.ts +6 -74
  143. package/src/gjc-runtime/ultragoal-runtime.ts +635 -612
  144. package/src/gjc-runtime/workflow-manifest.generated.json +37 -99
  145. package/src/gjc-runtime/workflow-manifest.ts +21 -70
  146. package/src/internal-urls/docs-index.generated.ts +16 -16
  147. package/src/modes/DESIGN.md +104 -0
  148. package/src/modes/acp/acp-agent.ts +298 -186
  149. package/src/modes/components/gjc-bundle-settings.ts +508 -0
  150. package/src/modes/components/irc-sidebar.ts +30 -1
  151. package/src/modes/components/notifications-settings-editor.ts +12 -1
  152. package/src/modes/components/oauth-selector.ts +68 -30
  153. package/src/modes/components/session-observer-overlay.ts +305 -86
  154. package/src/modes/components/settings-selector.ts +56 -5
  155. package/src/modes/components/status-line/gh.ts +16 -0
  156. package/src/modes/components/tool-status-header.ts +3 -15
  157. package/src/modes/components/transcript-viewer-overlay.ts +99 -24
  158. package/src/modes/controllers/command-controller.ts +42 -34
  159. package/src/modes/controllers/event-controller.ts +27 -16
  160. package/src/modes/controllers/extension-ui-controller.ts +193 -34
  161. package/src/modes/controllers/selector-controller.ts +160 -22
  162. package/src/modes/interactive-mode.ts +189 -62
  163. package/src/modes/jobs-observer.ts +1 -1
  164. package/src/modes/runtime-init.ts +3 -0
  165. package/src/modes/theme/theme.ts +11 -9
  166. package/src/modes/types.ts +88 -0
  167. package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
  168. package/src/prompts/system/eager-todo.md +8 -4
  169. package/src/prompts/system/system-prompt.md +11 -4
  170. package/src/prompts/tools/bash.md +5 -5
  171. package/src/prompts/tools/search-tool-bm25.md +5 -0
  172. package/src/prompts/tools/task.md +1 -1
  173. package/src/reminders/star-reminder.ts +4 -42
  174. package/src/runtime-mcp/manager.ts +19 -4
  175. package/src/sdk/acp/adapter.ts +22 -6
  176. package/src/sdk/broker/lifecycle.ts +18 -13
  177. package/src/sdk/broker/transport.ts +35 -3
  178. package/src/sdk/bus/attachment-registry.ts +5 -1
  179. package/src/sdk/bus/chat-daemon-cli.ts +4 -39
  180. package/src/sdk/bus/chat-daemon-control.ts +7 -3
  181. package/src/sdk/bus/config.ts +18 -2
  182. package/src/sdk/bus/index.ts +661 -126
  183. package/src/sdk/bus/kind-aware-reconciliation.ts +148 -73
  184. package/src/sdk/bus/lifecycle-commands.ts +4 -1
  185. package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
  186. package/src/sdk/bus/reconciliation-store.ts +95 -16
  187. package/src/sdk/bus/rich-draft.ts +1 -1
  188. package/src/sdk/bus/rich-render.ts +2 -2
  189. package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
  190. package/src/sdk/bus/telegram-cli.ts +33 -8
  191. package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
  192. package/src/sdk/bus/telegram-daemon-contract.ts +12 -4
  193. package/src/sdk/bus/telegram-daemon.ts +916 -23
  194. package/src/sdk/bus/telegram-reference.ts +40 -3
  195. package/src/sdk/bus/threaded-render.ts +48 -3
  196. package/src/sdk/bus/topic-registry.ts +20 -0
  197. package/src/sdk/host/query/revision-store.ts +36 -2
  198. package/src/sdk/host/reverse-leases.ts +44 -7
  199. package/src/sdk/prompt-status.ts +16 -3
  200. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  201. package/src/sdk/session.ts +89 -9
  202. package/src/session/agent-session.ts +844 -173
  203. package/src/session/artifacts.ts +22 -0
  204. package/src/session/auth-broker-config.ts +10 -3
  205. package/src/session/blob-store.ts +915 -24
  206. package/src/session/internal/managed-session-scope.ts +200 -111
  207. package/src/session/internal/managed-session-storage.ts +492 -62
  208. package/src/session/session-manager.ts +1614 -435
  209. package/src/session/session-storage.ts +38 -45
  210. package/src/session/streaming-output.ts +238 -68
  211. package/src/setup/credential-import.ts +75 -13
  212. package/src/skill-state/active-state.ts +32 -0
  213. package/src/skill-state/workflow-hud.ts +17 -0
  214. package/src/skill-state/workflow-mutation-guard.ts +496 -2
  215. package/src/task/executor.ts +122 -39
  216. package/src/task/index.ts +280 -45
  217. package/src/task/output-manager.ts +24 -14
  218. package/src/task/provider-retry-status.ts +16 -0
  219. package/src/task/receipt.ts +34 -8
  220. package/src/task/render.ts +6 -2
  221. package/src/task/types.ts +108 -0
  222. package/src/tools/ask-answer-registry.ts +12 -6
  223. package/src/tools/ask.ts +86 -55
  224. package/src/tools/ast-edit.ts +1 -0
  225. package/src/tools/ast-grep.ts +1 -0
  226. package/src/tools/bash-interactive.ts +8 -3
  227. package/src/tools/bash.ts +503 -60
  228. package/src/tools/find.ts +1 -0
  229. package/src/tools/image-gen.ts +29 -5
  230. package/src/tools/index.ts +6 -0
  231. package/src/tools/output-meta.ts +108 -14
  232. package/src/tools/path-utils.ts +3 -2
  233. package/src/tools/read.ts +18 -0
  234. package/src/tools/review.ts +5 -7
  235. package/src/tools/search.ts +1 -0
  236. package/src/tools/subagent-render.ts +6 -2
  237. package/src/tools/subagent.ts +6 -0
  238. package/src/tools/telegram-send.ts +6 -3
  239. package/src/utils/gh.ts +39 -0
  240. package/src/utils/image-resize.ts +3 -0
  241. package/src/web/search/providers/anthropic.ts +4 -4
  242. package/src/web/search/providers/kimi.ts +15 -5
  243. package/src/web/search/providers/searxng.ts +47 -4
  244. package/src/web/search/providers/xai.ts +7 -2
  245. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
  246. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
  247. package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
@@ -1,22 +1,65 @@
1
+ import * as crypto from "node:crypto";
1
2
  import * as fs from "node:fs";
2
3
  import * as fsp from "node:fs/promises";
3
4
  import * as path from "node:path";
4
- import { isEnoent, logger } from "@gajae-code/utils";
5
+ import { isEnoent, logger, postmortem } from "@gajae-code/utils";
5
6
 
6
7
  const BLOB_PREFIX = "blob:sha256:";
8
+ const TAKE_BLOB_BUFFER_OWNERSHIP = Symbol("takeBlobBufferOwnership");
7
9
 
8
10
  /**
9
11
  * Owner-only permissions for on-disk blob directories and files.
10
12
  *
11
- * Blob stores can live inside a managed session scope (e.g. the resident-cache
12
- * `EphemeralBlobStore` created on the explicit session path). The managed-tree
13
- * snapshot fails closed unless every descendant is owner-only, so blobs and
14
- * their directories MUST be created 0700/0600 rather than inheriting a
15
- * group/other-readable mode from the process umask.
13
+ * Generic blob stores may live inside a managed session scope, whose snapshot
14
+ * fails closed unless every descendant is owner-only. Resident text instead
15
+ * uses a verified private cache root, but its instance directories and blobs
16
+ * obey the same 0700/0600 contract.
16
17
  */
17
18
  const BLOB_DIR_MODE = 0o700;
18
19
  const BLOB_FILE_MODE = 0o600;
19
20
 
21
+ const RESIDENT_CACHE_OWNER_RECORD = "owner.json";
22
+ const RESIDENT_CACHE_GC_MAX_DIRECTORIES = 64;
23
+ const RESIDENT_CACHE_GC_MAX_DURATION_MS = 250;
24
+ const RESIDENT_CACHE_SWEEP_EXPECTED_IO_CODES = new Set([
25
+ "EACCES",
26
+ "EBUSY",
27
+ "EEXIST",
28
+ "EIO",
29
+ "EISDIR",
30
+ "ELOOP",
31
+ "EMFILE",
32
+ "ENAMETOOLONG",
33
+ "ENFILE",
34
+ "ENOENT",
35
+ "ENOTDIR",
36
+ "ENOTEMPTY",
37
+ "EPERM",
38
+ "EROFS",
39
+ "ESTALE",
40
+ ]);
41
+ const ownedResidentCacheInstanceDirs = new Set<string>();
42
+ const activeResidentCacheRootSweeps = new Set<string>();
43
+ let ownResidentCacheProcessStartTimeMs: number | null | undefined;
44
+
45
+ interface ResidentCacheOwnerToken {
46
+ readonly pid: number;
47
+ readonly startTimeMs: number | null;
48
+ readonly nonce: string;
49
+ readonly createdAt?: number;
50
+ }
51
+
52
+ interface ResidentCacheOwnerSnapshot {
53
+ readonly owner: ResidentCacheOwnerToken;
54
+ readonly directory: fs.Stats;
55
+ }
56
+
57
+ /** Limits for a single best-effort resident-cache garbage-collection pass. */
58
+ export interface ResidentCacheGcSweepOptions {
59
+ readonly maxDirectories?: number;
60
+ readonly maxDurationMs?: number;
61
+ }
62
+
20
63
  export interface BlobPutResult {
21
64
  hash: string;
22
65
  path: string;
@@ -37,6 +80,563 @@ export class BlobCorruptError extends Error {
37
80
  }
38
81
  }
39
82
 
83
+ /**
84
+ * A resident-cache path or blob failed the owner-only verification contract.
85
+ * SessionManager catches this error to demote the entire resident store to
86
+ * memory before retrying the triggering write.
87
+ */
88
+ export class ResidentCacheTrustError extends Error {
89
+ constructor(
90
+ readonly reason: string,
91
+ readonly path: string,
92
+ options?: ErrorOptions,
93
+ ) {
94
+ super(`Resident cache trust validation failed (${reason}): ${path}`, options);
95
+ this.name = "ResidentCacheTrustError";
96
+ }
97
+ }
98
+
99
+ function residentCacheTrustError(reason: string, pathname: string, cause?: unknown): ResidentCacheTrustError {
100
+ if (cause instanceof ResidentCacheTrustError) return cause;
101
+ return new ResidentCacheTrustError(reason, pathname, cause === undefined ? undefined : { cause });
102
+ }
103
+
104
+ function errorCode(error: unknown): string | undefined {
105
+ if (typeof error !== "object" || error === null || !("code" in error)) return undefined;
106
+ return typeof error.code === "string" ? error.code : undefined;
107
+ }
108
+
109
+ function isExpectedResidentCacheSweepFailure(error: unknown): boolean {
110
+ return (
111
+ error instanceof ResidentCacheTrustError || RESIDENT_CACHE_SWEEP_EXPECTED_IO_CODES.has(errorCode(error) ?? "")
112
+ );
113
+ }
114
+
115
+ function residentCacheOwnerUid(pathname: string): number {
116
+ if (process.platform === "win32") {
117
+ throw new ResidentCacheTrustError("unsupported_platform", pathname);
118
+ }
119
+ const uid = process.getuid?.();
120
+ if (uid === undefined) throw new ResidentCacheTrustError("owner_identity_unavailable", pathname);
121
+ return uid;
122
+ }
123
+
124
+ function hasOwnerOnlyMode(mode: number): boolean {
125
+ return (mode & 0o077) === 0;
126
+ }
127
+
128
+ function residentCacheDirectoryOpenFlags(pathname: string): number {
129
+ const noFollow = fs.constants.O_NOFOLLOW;
130
+ const directory = fs.constants.O_DIRECTORY;
131
+ if (!noFollow || !directory) throw new ResidentCacheTrustError("no_follow_unavailable", pathname);
132
+ return fs.constants.O_RDONLY | directory | noFollow;
133
+ }
134
+
135
+ function hasSameFilesystemIdentity(a: fs.Stats, b: fs.Stats): boolean {
136
+ return a.dev === b.dev && a.ino === b.ino;
137
+ }
138
+
139
+ function assertResidentCacheDirectory(pathname: string, uid: number): fs.Stats {
140
+ let stat: fs.Stats;
141
+ try {
142
+ stat = fs.lstatSync(pathname);
143
+ } catch (error) {
144
+ throw residentCacheTrustError("directory_unverifiable", pathname, error);
145
+ }
146
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== uid || !hasOwnerOnlyMode(stat.mode)) {
147
+ throw new ResidentCacheTrustError("directory_untrusted", pathname);
148
+ }
149
+ return stat;
150
+ }
151
+
152
+ function assertResidentCacheDirectoryDescriptor(
153
+ pathname: string,
154
+ expected: fs.Stats,
155
+ descriptor: number,
156
+ uid: number,
157
+ ): void {
158
+ let opened: fs.Stats;
159
+ try {
160
+ opened = fs.fstatSync(descriptor);
161
+ } catch (error) {
162
+ throw residentCacheTrustError("directory_descriptor_unverifiable", pathname, error);
163
+ }
164
+ if (
165
+ !opened.isDirectory() ||
166
+ opened.uid !== uid ||
167
+ !hasOwnerOnlyMode(opened.mode) ||
168
+ !hasSameFilesystemIdentity(expected, opened)
169
+ ) {
170
+ throw new ResidentCacheTrustError("directory_identity_changed", pathname);
171
+ }
172
+ }
173
+
174
+ function openVerifiedResidentCacheDirectory(pathname: string, uid: number): number {
175
+ const expected = assertResidentCacheDirectory(pathname, uid);
176
+ let descriptor: number | null = null;
177
+ try {
178
+ descriptor = fs.openSync(pathname, residentCacheDirectoryOpenFlags(pathname));
179
+ assertResidentCacheDirectoryDescriptor(pathname, expected, descriptor, uid);
180
+ return descriptor;
181
+ } catch (error) {
182
+ if (descriptor !== null) {
183
+ try {
184
+ fs.closeSync(descriptor);
185
+ } catch {
186
+ // Preserve the verification failure.
187
+ }
188
+ }
189
+ throw residentCacheTrustError("directory_open_untrusted", pathname, error);
190
+ }
191
+ }
192
+
193
+ function assertResidentCacheDirectoryPathMatchesDescriptor(pathname: string, descriptor: number, uid: number): void {
194
+ const current = assertResidentCacheDirectory(pathname, uid);
195
+ assertResidentCacheDirectoryDescriptor(pathname, current, descriptor, uid);
196
+ }
197
+
198
+ function residentCacheProcessStartTimeMs(pid: number): number | null {
199
+ if (pid === process.pid && ownResidentCacheProcessStartTimeMs !== undefined) {
200
+ return ownResidentCacheProcessStartTimeMs;
201
+ }
202
+
203
+ // Mirror file-lock's `ps`-based process incarnation probe, but pin the
204
+ // locale so its `lstart` output can be represented as a stable epoch value.
205
+ let startTimeMs: number | null = null;
206
+ try {
207
+ const result = Bun.spawnSync(["ps", "-o", "lstart=", "-p", String(pid)], {
208
+ stdout: "pipe",
209
+ stderr: "ignore",
210
+ env: { ...process.env, LC_ALL: "C", LANG: "C" },
211
+ });
212
+ if (result.exitCode === 0) {
213
+ const parsed = Date.parse(new TextDecoder().decode(result.stdout).trim());
214
+ if (Number.isFinite(parsed)) startTimeMs = parsed;
215
+ }
216
+ } catch {
217
+ // An unavailable process-start source cannot prove PID reuse.
218
+ }
219
+ if (pid === process.pid) ownResidentCacheProcessStartTimeMs = startTimeMs;
220
+ return startTimeMs;
221
+ }
222
+
223
+ type ResidentCacheOwnerLiveness = "alive" | "dead" | "unknown";
224
+
225
+ function residentCacheOwnerLiveness(pid: number): ResidentCacheOwnerLiveness {
226
+ if (!Number.isFinite(pid) || pid <= 0) return "unknown";
227
+ try {
228
+ process.kill(pid, 0);
229
+ return "alive";
230
+ } catch (error) {
231
+ const code = errorCode(error);
232
+ if (code === "ESRCH") return "dead";
233
+ // EPERM means the process exists but the current user cannot signal it.
234
+ // All other errors leave liveness indeterminate and fail closed.
235
+ return code === "EPERM" ? "alive" : "unknown";
236
+ }
237
+ }
238
+
239
+ function isTrustedResidentCacheOwnerFile(stat: fs.Stats, uid: number): boolean {
240
+ return (
241
+ stat.isFile() &&
242
+ !stat.isSymbolicLink() &&
243
+ stat.uid === uid &&
244
+ hasOwnerOnlyMode(stat.mode) &&
245
+ stat.size <= 16 * 1024
246
+ );
247
+ }
248
+
249
+ function parseResidentCacheOwnerToken(text: string): ResidentCacheOwnerToken | null {
250
+ let parsed: unknown;
251
+ try {
252
+ parsed = JSON.parse(text);
253
+ } catch {
254
+ return null;
255
+ }
256
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
257
+ const { pid, startTimeMs, nonce, createdAt } = parsed as {
258
+ pid?: unknown;
259
+ startTimeMs?: unknown;
260
+ nonce?: unknown;
261
+ createdAt?: unknown;
262
+ };
263
+ if (
264
+ typeof pid !== "number" ||
265
+ !Number.isInteger(pid) ||
266
+ pid <= 0 ||
267
+ (startTimeMs !== null && (typeof startTimeMs !== "number" || !Number.isFinite(startTimeMs))) ||
268
+ typeof nonce !== "string" ||
269
+ nonce.length === 0 ||
270
+ (createdAt !== undefined && (typeof createdAt !== "number" || !Number.isFinite(createdAt)))
271
+ ) {
272
+ return null;
273
+ }
274
+ return createdAt === undefined ? { pid, startTimeMs, nonce } : { pid, startTimeMs, nonce, createdAt };
275
+ }
276
+
277
+ function sameResidentCacheOwnerToken(a: ResidentCacheOwnerToken, b: ResidentCacheOwnerToken): boolean {
278
+ return a.pid === b.pid && a.startTimeMs === b.startTimeMs && a.nonce === b.nonce && a.createdAt === b.createdAt;
279
+ }
280
+
281
+ function readResidentCacheOwnerSnapshot(instanceDir: string, uid: number): ResidentCacheOwnerSnapshot | null {
282
+ let descriptor: number | null = null;
283
+ try {
284
+ descriptor = openVerifiedResidentCacheDirectory(instanceDir, uid);
285
+ assertResidentCacheDirectoryPathMatchesDescriptor(instanceDir, descriptor, uid);
286
+ const directory = fs.fstatSync(descriptor);
287
+ const ownerPath = path.join(instanceDir, RESIDENT_CACHE_OWNER_RECORD);
288
+ const before = fs.lstatSync(ownerPath);
289
+ if (!isTrustedResidentCacheOwnerFile(before, uid)) return null;
290
+ const owner = parseResidentCacheOwnerToken(fs.readFileSync(ownerPath, "utf8"));
291
+ if (!owner) return null;
292
+ const after = fs.lstatSync(ownerPath);
293
+ if (!isTrustedResidentCacheOwnerFile(after, uid) || !hasSameFilesystemIdentity(before, after)) return null;
294
+ assertResidentCacheDirectoryPathMatchesDescriptor(instanceDir, descriptor, uid);
295
+ return { owner, directory };
296
+ } catch {
297
+ return null;
298
+ } finally {
299
+ if (descriptor !== null) {
300
+ try {
301
+ fs.closeSync(descriptor);
302
+ } catch {
303
+ // A failed close cannot make a previously unreadable owner trustworthy.
304
+ }
305
+ }
306
+ }
307
+ }
308
+
309
+ function writeResidentCacheOwnerToken(instanceDir: string, uid: number): void {
310
+ const nonce = path.basename(instanceDir).slice("i-".length);
311
+ if (!nonce) throw new ResidentCacheTrustError("instance_nonce_missing", instanceDir);
312
+ const owner: ResidentCacheOwnerToken = {
313
+ pid: process.pid,
314
+ startTimeMs: residentCacheProcessStartTimeMs(process.pid),
315
+ nonce,
316
+ createdAt: Date.now(),
317
+ };
318
+ const ownerPath = path.join(instanceDir, RESIDENT_CACHE_OWNER_RECORD);
319
+ const noFollow = fs.constants.O_NOFOLLOW;
320
+ if (!noFollow) throw new ResidentCacheTrustError("no_follow_unavailable", ownerPath);
321
+
322
+ let descriptor: number | null = null;
323
+ let expected: fs.Stats | null = null;
324
+ let failure: ResidentCacheTrustError | null = null;
325
+ try {
326
+ descriptor = fs.openSync(
327
+ ownerPath,
328
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow,
329
+ BLOB_FILE_MODE,
330
+ );
331
+ fs.writeFileSync(descriptor, JSON.stringify(owner), "utf8");
332
+ fs.fchmodSync(descriptor, BLOB_FILE_MODE);
333
+ expected = fs.fstatSync(descriptor);
334
+ if (!isTrustedResidentCacheOwnerFile(expected, uid)) {
335
+ throw new ResidentCacheTrustError("owner_file_untrusted", ownerPath);
336
+ }
337
+ } catch (error) {
338
+ failure = residentCacheTrustError("owner_write_failed", ownerPath, error);
339
+ } finally {
340
+ if (descriptor !== null) {
341
+ try {
342
+ fs.closeSync(descriptor);
343
+ } catch (error) {
344
+ failure ??= residentCacheTrustError("owner_close_failed", ownerPath, error);
345
+ }
346
+ }
347
+ }
348
+ if (failure !== null) throw failure;
349
+
350
+ try {
351
+ const current = fs.lstatSync(ownerPath);
352
+ if (
353
+ expected === null ||
354
+ !isTrustedResidentCacheOwnerFile(current, uid) ||
355
+ !hasSameFilesystemIdentity(expected, current)
356
+ ) {
357
+ throw new ResidentCacheTrustError("owner_identity_changed", ownerPath);
358
+ }
359
+ } catch (error) {
360
+ throw residentCacheTrustError("owner_write_untrusted", ownerPath, error);
361
+ }
362
+ }
363
+
364
+ function removeEmptyResidentCacheInstanceDir(instanceDir: string): void {
365
+ try {
366
+ const stat = fs.lstatSync(instanceDir);
367
+ if (stat.isSymbolicLink()) {
368
+ fs.unlinkSync(instanceDir);
369
+ } else if (stat.isDirectory()) {
370
+ fs.rmdirSync(instanceDir);
371
+ }
372
+ } catch {
373
+ // The candidate is already absent, was replaced, or is no longer empty.
374
+ // Never recursively delete an unverified path while handling a trust failure.
375
+ }
376
+ }
377
+
378
+ function removeResidentCacheTreeNoFollow(pathname: string): void {
379
+ const stat = fs.lstatSync(pathname);
380
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
381
+ for (const entry of fs.readdirSync(pathname)) {
382
+ removeResidentCacheTreeNoFollow(path.join(pathname, entry));
383
+ }
384
+ fs.rmdirSync(pathname);
385
+ return;
386
+ }
387
+ fs.unlinkSync(pathname);
388
+ }
389
+
390
+ function disposeVerifiedResidentCacheInstanceDir(instanceDir: string): void {
391
+ const instanceKey = path.resolve(instanceDir);
392
+ const uid = residentCacheOwnerUid(instanceDir);
393
+ const quarantineDir = path.join(
394
+ path.dirname(instanceDir),
395
+ `${path.basename(instanceDir)}.dispose.${crypto.randomUUID()}`,
396
+ );
397
+ try {
398
+ fs.lstatSync(quarantineDir);
399
+ throw new ResidentCacheTrustError("quarantine_exists", quarantineDir);
400
+ } catch (error) {
401
+ if (error instanceof ResidentCacheTrustError) throw error;
402
+ if (errorCode(error) !== "ENOENT") throw residentCacheTrustError("quarantine_unverifiable", quarantineDir, error);
403
+ }
404
+
405
+ let descriptor: number | null = null;
406
+ let removed = false;
407
+ try {
408
+ descriptor = openVerifiedResidentCacheDirectory(instanceDir, uid);
409
+ assertResidentCacheDirectoryPathMatchesDescriptor(instanceDir, descriptor, uid);
410
+ const expected = fs.fstatSync(descriptor);
411
+ fs.renameSync(instanceDir, quarantineDir);
412
+ const moved = fs.lstatSync(quarantineDir);
413
+ if (
414
+ !moved.isDirectory() ||
415
+ moved.isSymbolicLink() ||
416
+ moved.uid !== uid ||
417
+ !hasOwnerOnlyMode(moved.mode) ||
418
+ !hasSameFilesystemIdentity(expected, moved)
419
+ ) {
420
+ throw new ResidentCacheTrustError("quarantine_identity_changed", quarantineDir);
421
+ }
422
+ removeResidentCacheTreeNoFollow(quarantineDir);
423
+ removed = true;
424
+ } catch (error) {
425
+ throw residentCacheTrustError("instance_dispose_failed", instanceDir, error);
426
+ } finally {
427
+ if (descriptor !== null) {
428
+ try {
429
+ fs.closeSync(descriptor);
430
+ } catch {
431
+ // The instance was either quarantined or left intact by the primary failure.
432
+ }
433
+ }
434
+ if (removed) ownedResidentCacheInstanceDirs.delete(instanceKey);
435
+ }
436
+ }
437
+
438
+ function removePartialResidentCacheInstanceDir(instanceDir: string): void {
439
+ try {
440
+ disposeVerifiedResidentCacheInstanceDir(instanceDir);
441
+ } catch {
442
+ removeEmptyResidentCacheInstanceDir(instanceDir);
443
+ }
444
+ }
445
+
446
+ function isResidentCacheInstanceDirName(name: string): boolean {
447
+ return /^i-[A-Za-z0-9_-]+$/.test(name);
448
+ }
449
+
450
+ function residentCacheSweepLimit(value: number | undefined, ceiling: number): number {
451
+ if (value === undefined || !Number.isFinite(value)) return ceiling;
452
+ return Math.max(0, Math.min(Math.floor(value), ceiling));
453
+ }
454
+
455
+ function cachedResidentCacheProcessStartTimeMs(pid: number, cache: Map<number, number | null>): number | null {
456
+ if (cache.has(pid)) return cache.get(pid) ?? null;
457
+ const startTimeMs = residentCacheProcessStartTimeMs(pid);
458
+ cache.set(pid, startTimeMs);
459
+ return startTimeMs;
460
+ }
461
+
462
+ function residentCacheOwnerIsStale(
463
+ owner: ResidentCacheOwnerToken,
464
+ startTimeCache: Map<number, number | null>,
465
+ ): boolean {
466
+ const liveness = residentCacheOwnerLiveness(owner.pid);
467
+ if (liveness === "dead") return true;
468
+ if (liveness !== "alive" || owner.startTimeMs === null) return false;
469
+ const currentStartTimeMs = cachedResidentCacheProcessStartTimeMs(owner.pid, startTimeCache);
470
+ return currentStartTimeMs !== null && currentStartTimeMs !== owner.startTimeMs;
471
+ }
472
+
473
+ function reapResidentCacheInstanceDir(
474
+ root: string,
475
+ rootDescriptor: number,
476
+ instanceDir: string,
477
+ expected: ResidentCacheOwnerSnapshot,
478
+ uid: number,
479
+ ): boolean {
480
+ const quarantineDir = path.join(root, `${path.basename(instanceDir)}.reap.${crypto.randomUUID()}`);
481
+ try {
482
+ fs.lstatSync(quarantineDir);
483
+ return false;
484
+ } catch (error) {
485
+ if (errorCode(error) !== "ENOENT") return false;
486
+ }
487
+
488
+ // Re-read immediately before rename so a replacement owner cannot be reaped
489
+ // based on the stale observation above (the same compare-before-delete shape
490
+ // used by file-lock GC).
491
+ const current = readResidentCacheOwnerSnapshot(instanceDir, uid);
492
+ if (
493
+ current === null ||
494
+ !sameResidentCacheOwnerToken(current.owner, expected.owner) ||
495
+ !hasSameFilesystemIdentity(current.directory, expected.directory)
496
+ ) {
497
+ return false;
498
+ }
499
+
500
+ try {
501
+ const beforeRename = fs.lstatSync(instanceDir);
502
+ if (
503
+ !beforeRename.isDirectory() ||
504
+ beforeRename.isSymbolicLink() ||
505
+ beforeRename.uid !== uid ||
506
+ !hasOwnerOnlyMode(beforeRename.mode) ||
507
+ !hasSameFilesystemIdentity(beforeRename, current.directory)
508
+ ) {
509
+ return false;
510
+ }
511
+ assertResidentCacheDirectoryPathMatchesDescriptor(root, rootDescriptor, uid);
512
+ fs.renameSync(instanceDir, quarantineDir);
513
+ const moved = fs.lstatSync(quarantineDir);
514
+ if (
515
+ !moved.isDirectory() ||
516
+ moved.isSymbolicLink() ||
517
+ moved.uid !== uid ||
518
+ !hasOwnerOnlyMode(moved.mode) ||
519
+ !hasSameFilesystemIdentity(moved, current.directory)
520
+ ) {
521
+ throw new ResidentCacheTrustError("reap_quarantine_identity_changed", quarantineDir);
522
+ }
523
+ removeResidentCacheTreeNoFollow(quarantineDir);
524
+ return true;
525
+ } catch {
526
+ return false;
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Best-effort cleanup of abandoned resident-cache instances. The root is
532
+ * re-verified before scanning, work is bounded, and all failures fail closed.
533
+ */
534
+ export async function sweepResidentCacheRoot(root: string, options: ResidentCacheGcSweepOptions = {}): Promise<void> {
535
+ const rootKey = path.resolve(root);
536
+ if (activeResidentCacheRootSweeps.has(rootKey)) return;
537
+ activeResidentCacheRootSweeps.add(rootKey);
538
+ try {
539
+ // Yield before filesystem work so callers can fire-and-forget this sweep.
540
+ await Promise.resolve();
541
+ const startedAt = Date.now();
542
+ const maxDirectories = residentCacheSweepLimit(options.maxDirectories, RESIDENT_CACHE_GC_MAX_DIRECTORIES);
543
+ const maxDurationMs = residentCacheSweepLimit(options.maxDurationMs, RESIDENT_CACHE_GC_MAX_DURATION_MS);
544
+ if (maxDirectories === 0 || maxDurationMs === 0) return;
545
+
546
+ const uid = residentCacheOwnerUid(root);
547
+ let rootDescriptor: number | null = null;
548
+ try {
549
+ // Do not create the root here: a failed verification must leave no new
550
+ // cache state behind.
551
+ rootDescriptor = openVerifiedResidentCacheDirectory(root, uid);
552
+ assertResidentCacheDirectoryPathMatchesDescriptor(root, rootDescriptor, uid);
553
+ const startTimeCache = new Map<number, number | null>();
554
+ const entries = fs.opendirSync(root);
555
+ try {
556
+ let examined = 0;
557
+ for (let entry = entries.readSync(); entry !== null; entry = entries.readSync()) {
558
+ if (examined >= maxDirectories || Date.now() - startedAt >= maxDurationMs) return;
559
+ if (!isResidentCacheInstanceDirName(entry.name)) continue;
560
+ const instanceDir = path.join(root, entry.name);
561
+ if (ownedResidentCacheInstanceDirs.has(path.resolve(instanceDir))) continue;
562
+ assertResidentCacheDirectoryPathMatchesDescriptor(root, rootDescriptor, uid);
563
+ examined++;
564
+ const expected = readResidentCacheOwnerSnapshot(instanceDir, uid);
565
+ if (expected === null || !residentCacheOwnerIsStale(expected.owner, startTimeCache)) continue;
566
+ reapResidentCacheInstanceDir(root, rootDescriptor, instanceDir, expected, uid);
567
+ }
568
+ } finally {
569
+ entries.closeSync();
570
+ }
571
+ } finally {
572
+ if (rootDescriptor !== null) fs.closeSync(rootDescriptor);
573
+ }
574
+ } catch (error) {
575
+ if (!isExpectedResidentCacheSweepFailure(error)) throw error;
576
+ logger.debug("Resident cache GC sweep skipped", { root, error: String(error) });
577
+ } finally {
578
+ activeResidentCacheRootSweeps.delete(rootKey);
579
+ }
580
+ }
581
+
582
+ function scheduleResidentCacheRootSweep(root: string): void {
583
+ void sweepResidentCacheRoot(root).catch(error => {
584
+ logger.debug("Resident cache GC sweep failed", { root, error: String(error) });
585
+ });
586
+ }
587
+
588
+ /**
589
+ * Create a private resident-cache instance directory beneath a trusted profile
590
+ * root. The cache-owned root and nonce instance are both verified owner-only
591
+ * directories; callers must use {@link EphemeralBlobStore.adoptVerifiedDir}
592
+ * rather than the destructive EphemeralBlobStore constructor.
593
+ */
594
+ export function openVerifiedResidentCacheInstanceDir(root: string): string {
595
+ const uid = residentCacheOwnerUid(root);
596
+ let rootDescriptor: number | null = null;
597
+ let instanceDir: string | null = null;
598
+ try {
599
+ try {
600
+ fs.mkdirSync(root, { recursive: true, mode: BLOB_DIR_MODE });
601
+ } catch (error) {
602
+ throw residentCacheTrustError("root_create_failed", root, error);
603
+ }
604
+
605
+ rootDescriptor = openVerifiedResidentCacheDirectory(root, uid);
606
+ try {
607
+ instanceDir = fs.mkdtempSync(path.join(root, "i-"));
608
+ } catch (error) {
609
+ throw residentCacheTrustError("instance_create_failed", root, error);
610
+ }
611
+
612
+ // Keep the root descriptor open while creating the nonce directory, then
613
+ // re-check the path against that descriptor before trusting the child.
614
+ assertResidentCacheDirectoryPathMatchesDescriptor(root, rootDescriptor, uid);
615
+ let instanceDescriptor: number | null = null;
616
+ try {
617
+ instanceDescriptor = openVerifiedResidentCacheDirectory(instanceDir, uid);
618
+ } finally {
619
+ if (instanceDescriptor !== null) fs.closeSync(instanceDescriptor);
620
+ }
621
+ writeResidentCacheOwnerToken(instanceDir, uid);
622
+ assertResidentCacheDirectoryPathMatchesDescriptor(root, rootDescriptor, uid);
623
+ ownedResidentCacheInstanceDirs.add(path.resolve(instanceDir));
624
+ scheduleResidentCacheRootSweep(root);
625
+ return instanceDir;
626
+ } catch (error) {
627
+ if (instanceDir !== null) removePartialResidentCacheInstanceDir(instanceDir);
628
+ throw residentCacheTrustError("instance_directory_untrusted", root, error);
629
+ } finally {
630
+ if (rootDescriptor !== null) {
631
+ try {
632
+ fs.closeSync(rootDescriptor);
633
+ } catch {
634
+ // Descriptor cleanup must not turn a verified candidate into a failure.
635
+ }
636
+ }
637
+ }
638
+ }
639
+
40
640
  function sha256Hex(data: Buffer): string {
41
641
  return new Bun.SHA256().update(data).digest("hex");
42
642
  }
@@ -99,6 +699,141 @@ function verifyBlobFileSync(hash: string, blobPath: string): Buffer {
99
699
  return data;
100
700
  }
101
701
 
702
+ function isTrustedResidentCacheBlobDescriptor(stat: fs.Stats, uid: number): boolean {
703
+ return stat.isFile() && stat.uid === uid && hasOwnerOnlyMode(stat.mode);
704
+ }
705
+
706
+ function isTrustedResidentCacheBlobFile(stat: fs.Stats, uid: number, byteLength: number): boolean {
707
+ return isTrustedResidentCacheBlobDescriptor(stat, uid) && stat.size === byteLength;
708
+ }
709
+
710
+ function readVerifiedResidentCacheBlobSync(hash: string, blobPath: string): Buffer | null {
711
+ const uid = residentCacheOwnerUid(blobPath);
712
+ const noFollow = fs.constants.O_NOFOLLOW;
713
+ if (!noFollow) throw new ResidentCacheTrustError("no_follow_unavailable", blobPath);
714
+
715
+ let descriptor: number;
716
+ try {
717
+ descriptor = fs.openSync(blobPath, fs.constants.O_RDONLY | noFollow);
718
+ } catch (error) {
719
+ if (isEnoent(error)) return null;
720
+ throw residentCacheTrustError("blob_open_failed", blobPath, error);
721
+ }
722
+
723
+ let data: Buffer | null = null;
724
+ let failure: ResidentCacheTrustError | null = null;
725
+ try {
726
+ const stat = fs.fstatSync(descriptor);
727
+ if (!isTrustedResidentCacheBlobDescriptor(stat, uid)) {
728
+ throw new ResidentCacheTrustError("blob_descriptor_untrusted", blobPath);
729
+ }
730
+ data = fs.readFileSync(descriptor);
731
+ verifyBlobBytesSync(hash, blobPath, data);
732
+ } catch (error) {
733
+ failure = residentCacheTrustError("blob_read_untrusted", blobPath, error);
734
+ } finally {
735
+ try {
736
+ fs.closeSync(descriptor);
737
+ } catch (error) {
738
+ failure ??= residentCacheTrustError("blob_close_failed", blobPath, error);
739
+ }
740
+ }
741
+ if (failure !== null) throw failure;
742
+ return data;
743
+ }
744
+
745
+ function assertResidentCacheBlobFile(pathname: string, uid: number, byteLength: number): fs.Stats {
746
+ let stat: fs.Stats;
747
+ try {
748
+ stat = fs.lstatSync(pathname);
749
+ } catch (error) {
750
+ throw residentCacheTrustError("blob_unverifiable", pathname, error);
751
+ }
752
+ if (!isTrustedResidentCacheBlobFile(stat, uid, byteLength)) {
753
+ throw new ResidentCacheTrustError("blob_untrusted", pathname);
754
+ }
755
+ return stat;
756
+ }
757
+
758
+ function verifyExistingResidentCacheBlob(
759
+ hash: string,
760
+ blobPath: string,
761
+ uid: number,
762
+ byteLength: number,
763
+ ): BlobPutResult {
764
+ const before = assertResidentCacheBlobFile(blobPath, uid, byteLength);
765
+ try {
766
+ verifyBlobFileSync(hash, blobPath);
767
+ } catch (error) {
768
+ throw residentCacheTrustError("blob_content_unverifiable", blobPath, error);
769
+ }
770
+ const after = assertResidentCacheBlobFile(blobPath, uid, byteLength);
771
+ if (!hasSameFilesystemIdentity(before, after)) {
772
+ throw new ResidentCacheTrustError("blob_identity_changed", blobPath);
773
+ }
774
+ return makeBlobPutResult(hash, blobPath);
775
+ }
776
+
777
+ function removeNewResidentCacheBlob(blobPath: string, expected: fs.Stats): void {
778
+ try {
779
+ const current = fs.lstatSync(blobPath);
780
+ if (current.isFile() && !current.isSymbolicLink() && hasSameFilesystemIdentity(current, expected)) {
781
+ fs.unlinkSync(blobPath);
782
+ }
783
+ } catch {
784
+ // A failed put must never delete a path whose identity no longer matches
785
+ // the exclusively-created file descriptor we opened.
786
+ }
787
+ }
788
+
789
+ function putResidentCacheBlobSync(dir: string, data: Buffer): BlobPutResult {
790
+ const hash = sha256Hex(data);
791
+ const blobPath = path.join(dir, hash);
792
+ const uid = residentCacheOwnerUid(blobPath);
793
+ const noFollow = fs.constants.O_NOFOLLOW;
794
+ if (!noFollow) throw new ResidentCacheTrustError("no_follow_unavailable", blobPath);
795
+
796
+ let descriptor: number;
797
+ try {
798
+ descriptor = fs.openSync(
799
+ blobPath,
800
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow,
801
+ BLOB_FILE_MODE,
802
+ );
803
+ } catch (error) {
804
+ if (errorCode(error) === "EEXIST") return verifyExistingResidentCacheBlob(hash, blobPath, uid, data.byteLength);
805
+ throw residentCacheTrustError("blob_create_failed", blobPath, error);
806
+ }
807
+
808
+ let created: fs.Stats | null = null;
809
+ let failure: ResidentCacheTrustError | null = null;
810
+ try {
811
+ created = fs.fstatSync(descriptor);
812
+ if (!created.isFile() || created.uid !== uid)
813
+ throw new ResidentCacheTrustError("blob_descriptor_untrusted", blobPath);
814
+ fs.fchmodSync(descriptor, BLOB_FILE_MODE);
815
+ fs.writeFileSync(descriptor, data);
816
+ const written = fs.fstatSync(descriptor);
817
+ if (!isTrustedResidentCacheBlobFile(written, uid, data.byteLength)) {
818
+ throw new ResidentCacheTrustError("blob_write_untrusted", blobPath);
819
+ }
820
+ } catch (error) {
821
+ failure = residentCacheTrustError("blob_write_failed", blobPath, error);
822
+ } finally {
823
+ try {
824
+ fs.closeSync(descriptor);
825
+ } catch (error) {
826
+ failure ??= residentCacheTrustError("blob_close_failed", blobPath, error);
827
+ }
828
+ }
829
+
830
+ if (failure !== null) {
831
+ if (created !== null) removeNewResidentCacheBlob(blobPath, created);
832
+ throw failure;
833
+ }
834
+ return makeBlobPutResult(hash, blobPath);
835
+ }
836
+
102
837
  /**
103
838
  * Content-addressed blob store for externalizing large binary data (images) from session JSONL files.
104
839
  *
@@ -139,7 +874,7 @@ export class BlobStore {
139
874
  * cannot afford the microtask hops of the async version (e.g. OOM-safe session writes).
140
875
  * Returns once the bytes are in the kernel page cache.
141
876
  */
142
- putSync(data: Buffer): BlobPutResult {
877
+ putSync(data: Buffer, _ownership?: typeof TAKE_BLOB_BUFFER_OWNERSHIP): BlobPutResult {
143
878
  const hash = new Bun.SHA256().update(data).digest("hex");
144
879
  const blobPath = path.join(this.dir, hash);
145
880
  const result = {
@@ -154,6 +889,15 @@ export class BlobStore {
154
889
  return result;
155
890
  }
156
891
 
892
+ /**
893
+ * Store a buffer that the caller will not mutate after this call returns.
894
+ * Resident-cache implementations may retain that private buffer without an
895
+ * additional defensive copy.
896
+ */
897
+ putOwnedSync(data: Buffer): BlobPutResult {
898
+ return this.putSync(data, TAKE_BLOB_BUFFER_OWNERSHIP);
899
+ }
900
+
157
901
  /**
158
902
  * Durably install binary data as an immutable content-addressed blob.
159
903
  *
@@ -278,6 +1022,42 @@ export class BlobStore {
278
1022
  }
279
1023
  }
280
1024
 
1025
+ interface EphemeralBlobStoreOptions {
1026
+ readonly adoptVerifiedDir?: boolean;
1027
+ }
1028
+
1029
+ /**
1030
+ * Resident-cache directories this process created and has not disposed.
1031
+ *
1032
+ * The directory name embeds the pid (`<session>-<pid>-<n>`), so a run that never
1033
+ * reaches `dispose()` cannot be cleaned by a later run: the next process picks a
1034
+ * different name, and the constructor's wipe only ever clears its own path. The
1035
+ * leftovers are therefore permanent. A developer machine held seven of them from
1036
+ * dead pids, up to 26 days old, totalling 13.4 MB of externalized session text.
1037
+ */
1038
+ const liveResidentCacheDirs = new Set<string>();
1039
+
1040
+ /** Resident-cache directories still tracked by this process. Test seam. */
1041
+ export function trackedResidentCacheDirsForTest(): string[] {
1042
+ return [...liveResidentCacheDirs];
1043
+ }
1044
+
1045
+ /**
1046
+ * Sweep the directories this process created when it exits abnormally, matching
1047
+ * `shell-snapshot` and `python-runner-artifact`. Best-effort by contract: a
1048
+ * postmortem handler must not throw.
1049
+ */
1050
+ postmortem.register("session-resident-cache", () => {
1051
+ for (const dir of [...liveResidentCacheDirs]) {
1052
+ try {
1053
+ fs.rmSync(dir, { recursive: true, force: true });
1054
+ } catch {
1055
+ // Exit path: a failed removal must not mask the original fault.
1056
+ }
1057
+ liveResidentCacheDirs.delete(dir);
1058
+ }
1059
+ });
1060
+
281
1061
  export class EphemeralBlobStore extends BlobStore {
282
1062
  /**
283
1063
  * Bounded LRU byte budget for the in-memory buffer cache. Keeps recent
@@ -288,11 +1068,44 @@ export class EphemeralBlobStore extends BlobStore {
288
1068
 
289
1069
  #bufferCache = new Map<string, Buffer>();
290
1070
  #bufferCacheBytes = 0;
1071
+ #adoptedVerifiedDir = false;
1072
+ #disposed = false;
291
1073
 
292
- constructor(dir: string) {
1074
+ constructor(dir: string, options: EphemeralBlobStoreOptions = {}) {
293
1075
  super(dir);
1076
+ this.#adoptedVerifiedDir = options.adoptVerifiedDir === true;
1077
+ if (this.#adoptedVerifiedDir) return;
294
1078
  fs.rmSync(dir, { recursive: true, force: true });
295
1079
  fs.mkdirSync(dir, { recursive: true, mode: BLOB_DIR_MODE });
1080
+ liveResidentCacheDirs.add(dir);
1081
+ }
1082
+
1083
+ /**
1084
+ * Adopt a directory returned by {@link openVerifiedResidentCacheInstanceDir}
1085
+ * without the ordinary constructor's destructive remove-and-recreate path.
1086
+ */
1087
+ static adoptVerifiedDir(dir: string): EphemeralBlobStore {
1088
+ const uid = residentCacheOwnerUid(dir);
1089
+ let descriptor: number | null = null;
1090
+ let failure: unknown;
1091
+ try {
1092
+ descriptor = openVerifiedResidentCacheDirectory(dir, uid);
1093
+ assertResidentCacheDirectoryPathMatchesDescriptor(dir, descriptor, uid);
1094
+ } catch (error) {
1095
+ failure = error;
1096
+ }
1097
+ if (descriptor !== null) {
1098
+ try {
1099
+ fs.closeSync(descriptor);
1100
+ } catch (error) {
1101
+ failure ??= residentCacheTrustError("instance_close_failed", dir, error);
1102
+ }
1103
+ }
1104
+ if (failure !== undefined) {
1105
+ removeEmptyResidentCacheInstanceDir(dir);
1106
+ throw residentCacheTrustError("instance_adoption_failed", dir, failure);
1107
+ }
1108
+ return new EphemeralBlobStore(dir, { adoptVerifiedDir: true });
296
1109
  }
297
1110
 
298
1111
  #cachePut(hash: string, data: Buffer): void {
@@ -311,18 +1124,33 @@ export class EphemeralBlobStore extends BlobStore {
311
1124
  }
312
1125
  }
313
1126
 
314
- putSync(data: Buffer): BlobPutResult {
315
- const result = super.putSync(data);
316
- this.#cachePut(result.hash, Buffer.from(data));
1127
+ async put(data: Buffer): Promise<BlobPutResult> {
1128
+ if (!this.#adoptedVerifiedDir) return super.put(data);
1129
+ return this.putSync(data);
1130
+ }
1131
+
1132
+ putSync(data: Buffer, ownership?: typeof TAKE_BLOB_BUFFER_OWNERSHIP): BlobPutResult {
1133
+ const result = this.#adoptedVerifiedDir ? putResidentCacheBlobSync(this.dir, data) : super.putSync(data);
1134
+ this.#cachePut(result.hash, ownership === TAKE_BLOB_BUFFER_OWNERSHIP ? data : Buffer.from(data));
317
1135
  return result;
318
1136
  }
319
1137
 
320
1138
  putImmutableSync(data: Buffer): CheckedBlobPutResult {
1139
+ if (this.#adoptedVerifiedDir) {
1140
+ const result = putResidentCacheBlobSync(this.dir, data);
1141
+ this.#cachePut(result.hash, Buffer.from(data));
1142
+ return makeBlobPutResult(result.hash, result.path, data.byteLength);
1143
+ }
321
1144
  const result = super.putImmutableSync(data);
322
1145
  this.#cachePut(result.hash, Buffer.from(data));
323
1146
  return result;
324
1147
  }
325
1148
 
1149
+ async get(hash: string): Promise<Buffer | null> {
1150
+ if (this.#adoptedVerifiedDir) return this.getSync(hash);
1151
+ return super.get(hash);
1152
+ }
1153
+
326
1154
  getSync(hash: string): Buffer | null {
327
1155
  const cached = this.#bufferCache.get(hash);
328
1156
  if (cached) {
@@ -336,12 +1164,25 @@ export class EphemeralBlobStore extends BlobStore {
336
1164
  this.#bufferCache.delete(hash);
337
1165
  this.#bufferCacheBytes -= cached.byteLength;
338
1166
  }
339
- const data = super.getSync(hash);
1167
+
1168
+ const data = this.#adoptedVerifiedDir
1169
+ ? readVerifiedResidentCacheBlobSync(hash, path.join(this.dir, hash))
1170
+ : super.getSync(hash);
340
1171
  if (data) this.#cachePut(hash, Buffer.from(data));
341
1172
  return data;
342
1173
  }
343
1174
 
1175
+ /** Return a trusted in-memory copy without reopening an invalidated cache path. */
1176
+ getBufferedSync(hash: string): Buffer | null {
1177
+ const cached = this.#bufferCache.get(hash);
1178
+ if (!cached) return null;
1179
+ this.#bufferCache.delete(hash);
1180
+ this.#bufferCache.set(hash, cached);
1181
+ return Buffer.from(cached);
1182
+ }
1183
+
344
1184
  getCheckedSync(hash: string): Buffer | null {
1185
+ if (this.#adoptedVerifiedDir) return this.getSync(hash);
345
1186
  const data = super.getCheckedSync(hash);
346
1187
  if (data) this.#cachePut(hash, Buffer.from(data));
347
1188
  return data;
@@ -350,32 +1191,79 @@ export class EphemeralBlobStore extends BlobStore {
350
1191
  clear(): void {
351
1192
  this.#bufferCache.clear();
352
1193
  this.#bufferCacheBytes = 0;
353
- fs.rmSync(this.dir, { recursive: true, force: true });
354
- fs.mkdirSync(this.dir, { recursive: true, mode: BLOB_DIR_MODE });
1194
+ if (!this.#adoptedVerifiedDir) {
1195
+ fs.rmSync(this.dir, { recursive: true, force: true });
1196
+ fs.mkdirSync(this.dir, { recursive: true, mode: BLOB_DIR_MODE });
1197
+ return;
1198
+ }
1199
+
1200
+ const uid = residentCacheOwnerUid(this.dir);
1201
+ const descriptor = openVerifiedResidentCacheDirectory(this.dir, uid);
1202
+ try {
1203
+ for (const entry of fs.readdirSync(this.dir)) {
1204
+ if (entry === RESIDENT_CACHE_OWNER_RECORD) continue;
1205
+ const blobPath = path.join(this.dir, entry);
1206
+ const stat = fs.lstatSync(blobPath);
1207
+ if (
1208
+ !/^[a-f0-9]{64}$/.test(entry) ||
1209
+ !stat.isFile() ||
1210
+ stat.isSymbolicLink() ||
1211
+ stat.uid !== uid ||
1212
+ !hasOwnerOnlyMode(stat.mode)
1213
+ ) {
1214
+ throw new ResidentCacheTrustError("clear_untrusted_entry", blobPath);
1215
+ }
1216
+ fs.unlinkSync(blobPath);
1217
+ }
1218
+ } catch (error) {
1219
+ throw residentCacheTrustError("clear_failed", this.dir, error);
1220
+ } finally {
1221
+ try {
1222
+ fs.closeSync(descriptor);
1223
+ } catch {
1224
+ // The clear result is already determined; do not mask it with cleanup.
1225
+ }
1226
+ }
355
1227
  }
356
1228
 
357
1229
  dispose(): void {
1230
+ if (this.#adoptedVerifiedDir && this.#disposed) return;
358
1231
  this.#bufferCache.clear();
359
1232
  this.#bufferCacheBytes = 0;
1233
+ if (this.#adoptedVerifiedDir) {
1234
+ disposeVerifiedResidentCacheInstanceDir(this.dir);
1235
+ this.#disposed = true;
1236
+ return;
1237
+ }
1238
+ // Untrack before removing so the sweep cannot race a clean disposal.
1239
+ liveResidentCacheDirs.delete(this.dir);
360
1240
  fs.rmSync(this.dir, { recursive: true, force: true });
361
1241
  }
362
1242
  }
363
1243
 
1244
+ export interface MemoryBlobStoreOptions {
1245
+ /** A canonical store owns every reference in its session's resident entries. */
1246
+ readonly ownership?: "cache" | "canonical";
1247
+ }
1248
+
364
1249
  export class MemoryBlobStore extends BlobStore {
365
1250
  /**
366
- * Generous byte/count LRU bound (F8). Content-addressed resident blobs are fail-closed
367
- * on miss (callers raise/handle {@link ResidentBlobMissingError}), so evicting the
368
- * least-recently-used entry on an extremely large session is preferable to unbounded
369
- * RAM growth. The caps sit well above normal usage and only trip on pathological sizes.
1251
+ * Cache-owned stores use a bounded LRU. Canonical ownership deliberately
1252
+ * retains every blob: a degraded resident session has no trusted disk store
1253
+ * behind its references, so eviction would silently replace valid content
1254
+ * with unavailable placeholders. This matches the pre-resident-cache
1255
+ * in-RAM raw-string lifetime while leaving non-canonical caches bounded.
370
1256
  */
371
1257
  static readonly #MAX_BYTES = 64 * 1024 * 1024;
372
1258
  static readonly #MAX_COUNT = 4096;
373
1259
 
374
1260
  #blobs = new Map<string, Buffer>();
375
1261
  #bytes = 0;
1262
+ #canonical: boolean;
376
1263
 
377
- constructor() {
1264
+ constructor(options: MemoryBlobStoreOptions = {}) {
378
1265
  super(":memory:");
1266
+ this.#canonical = options.ownership === "canonical";
379
1267
  }
380
1268
 
381
1269
  #store(hash: string, data: Buffer): void {
@@ -386,6 +1274,7 @@ export class MemoryBlobStore extends BlobStore {
386
1274
  }
387
1275
  this.#blobs.set(hash, data);
388
1276
  this.#bytes += data.byteLength;
1277
+ if (this.#canonical) return;
389
1278
  while (
390
1279
  (this.#bytes > MemoryBlobStore.#MAX_BYTES || this.#blobs.size > MemoryBlobStore.#MAX_COUNT) &&
391
1280
  this.#blobs.size > 1
@@ -421,9 +1310,11 @@ export class MemoryBlobStore extends BlobStore {
421
1310
  getSync(hash: string): Buffer | null {
422
1311
  const data = this.#blobs.get(hash);
423
1312
  if (!data) return null;
424
- // Refresh LRU recency on hit so hot blobs survive eviction.
425
- this.#blobs.delete(hash);
426
- this.#blobs.set(hash, data);
1313
+ if (!this.#canonical) {
1314
+ // Refresh LRU recency on hit so hot blobs survive eviction.
1315
+ this.#blobs.delete(hash);
1316
+ this.#blobs.set(hash, data);
1317
+ }
427
1318
  return Buffer.from(data);
428
1319
  }
429
1320
 
@@ -484,7 +1375,7 @@ export async function externalizeImageDataUrl(blobStore: BlobStore, dataUrl: str
484
1375
  /** Synchronous variant of {@link externalizeImageDataUrl}. */
485
1376
  export function externalizeImageDataUrlSync(blobStore: BlobStore, dataUrl: string): string {
486
1377
  if (isBlobRef(dataUrl)) return dataUrl;
487
- return blobStore.putSync(Buffer.from(dataUrl, "utf8")).ref;
1378
+ return blobStore.putOwnedSync(Buffer.from(dataUrl, "utf8")).ref;
488
1379
  }
489
1380
 
490
1381
  /**
@@ -501,7 +1392,7 @@ export async function externalizeImageData(blobStore: BlobStore, base64Data: str
501
1392
  /** Synchronous variant of {@link externalizeImageData}. */
502
1393
  export function externalizeImageDataSync(blobStore: BlobStore, base64Data: string): string {
503
1394
  if (isBlobRef(base64Data)) return base64Data;
504
- return blobStore.putSync(Buffer.from(base64Data, "base64")).ref;
1395
+ return blobStore.putOwnedSync(Buffer.from(base64Data, "base64")).ref;
505
1396
  }
506
1397
 
507
1398
  /**