@gajae-code/coding-agent 0.12.0 → 0.12.1
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.
- package/CHANGELOG.md +82 -1
- package/dist/types/async/job-manager.d.ts +17 -3
- package/dist/types/config/settings-schema.d.ts +54 -0
- package/dist/types/exa/mcp-client.d.ts +10 -1
- package/dist/types/exec/bash-executor.d.ts +45 -12
- package/dist/types/extensibility/extensions/types.d.ts +16 -2
- package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
- package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
- package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
- package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
- package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
- package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
- package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +18 -0
- package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
- package/dist/types/gjc-runtime/team-runtime.d.ts +25 -0
- package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +10 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +31 -80
- package/dist/types/modes/acp/acp-agent.d.ts +2 -19
- package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
- package/dist/types/modes/components/session-observer-overlay.d.ts +18 -0
- package/dist/types/modes/components/settings-selector.d.ts +9 -0
- package/dist/types/modes/components/status-line/gh.d.ts +5 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
- package/dist/types/reminders/star-reminder.d.ts +3 -13
- package/dist/types/runtime-mcp/manager.d.ts +1 -0
- package/dist/types/sdk/acp/adapter.d.ts +3 -1
- package/dist/types/sdk/broker/transport.d.ts +2 -0
- package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -1
- package/dist/types/sdk/bus/config.d.ts +6 -2
- package/dist/types/sdk/bus/index.d.ts +29 -0
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +8 -1
- package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
- package/dist/types/sdk/bus/reconciliation-store.d.ts +7 -3
- package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
- package/dist/types/sdk/bus/rich-render.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
- package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
- package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
- package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
- package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
- package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
- package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
- package/dist/types/sdk/prompt-status.d.ts +23 -3
- package/dist/types/sdk/session.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +38 -2
- package/dist/types/session/artifacts.d.ts +8 -0
- package/dist/types/session/blob-store.d.ts +57 -4
- package/dist/types/session/internal/managed-session-storage.d.ts +8 -1
- package/dist/types/session/session-manager.d.ts +23 -6
- package/dist/types/session/session-storage.d.ts +4 -0
- package/dist/types/session/streaming-output.d.ts +36 -7
- package/dist/types/setup/credential-import.d.ts +12 -0
- package/dist/types/skill-state/active-state.d.ts +12 -0
- package/dist/types/skill-state/workflow-hud.d.ts +6 -0
- package/dist/types/task/executor.d.ts +3 -2
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/task/provider-retry-status.d.ts +8 -0
- package/dist/types/task/receipt.d.ts +7 -1
- package/dist/types/task/types.d.ts +23 -0
- package/dist/types/tools/ask-answer-registry.d.ts +2 -2
- package/dist/types/tools/bash-interactive.d.ts +4 -1
- package/dist/types/tools/bash.d.ts +3 -1
- package/dist/types/tools/image-gen.d.ts +4 -0
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/tools/output-meta.d.ts +23 -2
- package/dist/types/tools/path-utils.d.ts +1 -0
- package/dist/types/tools/read.d.ts +0 -6
- package/dist/types/tools/review.d.ts +5 -7
- package/dist/types/tools/subagent.d.ts +2 -0
- package/dist/types/utils/gh.d.ts +13 -0
- package/dist/types/utils/image-resize.d.ts +1 -0
- package/dist/types/web/search/providers/kimi.d.ts +2 -0
- package/dist/types/web/search/providers/searxng.d.ts +7 -0
- package/dist/types/web/search/providers/xai.d.ts +2 -0
- package/package.json +10 -8
- package/scripts/acp-conformance-agent.ts +218 -0
- package/scripts/capture-gjc-bundle-settings.ts +306 -0
- package/scripts/generate-sdk-operation-inventory.ts +3 -0
- package/scripts/resident-memory-bench.ts +1341 -0
- package/scripts/run-acp-conformance.ts +285 -0
- package/src/async/job-manager.ts +78 -20
- package/src/cli/fast-help.ts +1 -1
- package/src/cli/plugin-cli.ts +176 -18
- package/src/commands/team.ts +19 -1
- package/src/config/file-lock.ts +11 -2
- package/src/config/settings-schema.ts +51 -0
- package/src/coordinator-mcp/question-gate-codec.ts +1 -1
- package/src/coordinator-mcp/server.ts +5 -12
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -0
- package/src/defaults/gjc/skills/ralplan/SKILL.md +19 -16
- package/src/defaults/gjc/skills/team/SKILL.md +2 -0
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +37 -24
- package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
- package/src/defaults/gjc-defaults.ts +4 -4
- package/src/exa/mcp-client.ts +12 -3
- package/src/exec/bash-executor.ts +144 -27
- package/src/extensibility/extensions/runner.ts +15 -0
- package/src/extensibility/extensions/types.ts +18 -1
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
- package/src/extensibility/gjc-plugins/index.ts +19 -2
- package/src/extensibility/gjc-plugins/installer.ts +189 -50
- package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
- package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
- package/src/extensibility/gjc-plugins/observability.ts +3 -3
- package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
- package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
- package/src/extensibility/gjc-plugins/schema.ts +84 -12
- package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
- package/src/extensibility/gjc-plugins/types.ts +141 -0
- package/src/extensibility/gjc-plugins/validation.ts +9 -0
- package/src/gjc-runtime/launch-tmux.ts +25 -6
- package/src/gjc-runtime/managed-owner-admission.ts +1 -1
- package/src/gjc-runtime/ralplan-runtime.ts +411 -17
- package/src/gjc-runtime/team-launch.ts +8 -0
- package/src/gjc-runtime/team-runtime.ts +140 -32
- package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
- package/src/gjc-runtime/tmux-owner-isolation.ts +10 -0
- package/src/gjc-runtime/tmux-sessions.ts +58 -13
- package/src/gjc-runtime/ultragoal-runtime.ts +401 -611
- package/src/gjc-runtime/workflow-manifest.generated.json +8 -99
- package/src/gjc-runtime/workflow-manifest.ts +4 -68
- package/src/internal-urls/docs-index.generated.ts +15 -15
- package/src/modes/DESIGN.md +51 -0
- package/src/modes/acp/acp-agent.ts +298 -186
- package/src/modes/components/gjc-bundle-settings.ts +508 -0
- package/src/modes/components/notifications-settings-editor.ts +12 -1
- package/src/modes/components/oauth-selector.ts +68 -30
- package/src/modes/components/session-observer-overlay.ts +305 -86
- package/src/modes/components/settings-selector.ts +56 -5
- package/src/modes/components/status-line/gh.ts +16 -0
- package/src/modes/components/tool-status-header.ts +3 -14
- package/src/modes/components/transcript-viewer-overlay.ts +99 -24
- package/src/modes/controllers/command-controller.ts +4 -0
- package/src/modes/controllers/extension-ui-controller.ts +7 -0
- package/src/modes/controllers/selector-controller.ts +45 -0
- package/src/modes/runtime-init.ts +3 -0
- package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
- package/src/prompts/system/eager-todo.md +8 -4
- package/src/prompts/system/system-prompt.md +11 -4
- package/src/prompts/tools/bash.md +5 -5
- package/src/prompts/tools/search-tool-bm25.md +5 -0
- package/src/prompts/tools/task.md +1 -1
- package/src/reminders/star-reminder.ts +4 -42
- package/src/runtime-mcp/manager.ts +19 -4
- package/src/sdk/acp/adapter.ts +22 -6
- package/src/sdk/broker/transport.ts +35 -3
- package/src/sdk/bus/attachment-registry.ts +5 -1
- package/src/sdk/bus/chat-daemon-cli.ts +4 -39
- package/src/sdk/bus/chat-daemon-control.ts +6 -3
- package/src/sdk/bus/config.ts +18 -2
- package/src/sdk/bus/index.ts +661 -126
- package/src/sdk/bus/kind-aware-reconciliation.ts +148 -73
- package/src/sdk/bus/lifecycle-commands.ts +4 -1
- package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
- package/src/sdk/bus/reconciliation-store.ts +95 -16
- package/src/sdk/bus/rich-draft.ts +1 -1
- package/src/sdk/bus/rich-render.ts +2 -2
- package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
- package/src/sdk/bus/telegram-cli.ts +33 -8
- package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +11 -4
- package/src/sdk/bus/telegram-daemon.ts +916 -23
- package/src/sdk/bus/telegram-reference.ts +40 -3
- package/src/sdk/bus/threaded-render.ts +48 -3
- package/src/sdk/bus/topic-registry.ts +20 -0
- package/src/sdk/host/query/revision-store.ts +36 -2
- package/src/sdk/host/reverse-leases.ts +44 -7
- package/src/sdk/prompt-status.ts +16 -3
- package/src/sdk/protocol/operation-inventory.generated.json +22 -0
- package/src/sdk/session.ts +88 -9
- package/src/session/agent-session.ts +822 -172
- package/src/session/artifacts.ts +22 -0
- package/src/session/auth-broker-config.ts +10 -3
- package/src/session/blob-store.ts +915 -24
- package/src/session/internal/managed-session-scope.ts +44 -6
- package/src/session/internal/managed-session-storage.ts +48 -6
- package/src/session/session-manager.ts +1614 -435
- package/src/session/session-storage.ts +9 -1
- package/src/session/streaming-output.ts +238 -68
- package/src/setup/credential-import.ts +75 -13
- package/src/skill-state/active-state.ts +32 -0
- package/src/skill-state/workflow-hud.ts +17 -0
- package/src/skill-state/workflow-mutation-guard.ts +496 -2
- package/src/task/executor.ts +122 -39
- package/src/task/index.ts +23 -14
- package/src/task/provider-retry-status.ts +16 -0
- package/src/task/receipt.ts +23 -7
- package/src/task/render.ts +6 -2
- package/src/task/types.ts +108 -0
- package/src/tools/ask-answer-registry.ts +12 -6
- package/src/tools/ask.ts +86 -55
- package/src/tools/ast-edit.ts +1 -0
- package/src/tools/ast-grep.ts +1 -0
- package/src/tools/bash-interactive.ts +8 -3
- package/src/tools/bash.ts +503 -60
- package/src/tools/find.ts +1 -0
- package/src/tools/image-gen.ts +29 -5
- package/src/tools/index.ts +4 -0
- package/src/tools/output-meta.ts +108 -14
- package/src/tools/path-utils.ts +3 -2
- package/src/tools/read.ts +18 -0
- package/src/tools/review.ts +5 -7
- package/src/tools/search.ts +1 -0
- package/src/tools/subagent-render.ts +6 -2
- package/src/tools/subagent.ts +6 -0
- package/src/tools/telegram-send.ts +6 -3
- package/src/utils/gh.ts +39 -0
- package/src/utils/image-resize.ts +3 -0
- package/src/web/search/providers/anthropic.ts +4 -4
- package/src/web/search/providers/kimi.ts +15 -5
- package/src/web/search/providers/searxng.ts +47 -4
- package/src/web/search/providers/xai.ts +7 -2
- package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
- package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
- 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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
this
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
|
|
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
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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.
|
|
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.
|
|
1395
|
+
return blobStore.putOwnedSync(Buffer.from(base64Data, "base64")).ref;
|
|
505
1396
|
}
|
|
506
1397
|
|
|
507
1398
|
/**
|