@evomap/evolver-mcp 2.0.0-beta.2 → 2.0.0-beta.22

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.
@@ -13,6 +13,7 @@ export interface ProxyFetch {
13
13
  export interface EvolverProxyClientOptions {
14
14
  baseUrl: string;
15
15
  token: string;
16
+ expectedHubMode?: 'public' | 'private';
16
17
  fetchFn?: ProxyFetch;
17
18
  reloadSettings?: () => EvolverProxyClientOptions | undefined;
18
19
  }
@@ -23,13 +24,29 @@ export interface ProxySearchArgs {
23
24
  category?: string;
24
25
  gene?: string;
25
26
  limit?: number;
27
+ expectedHubMode?: 'public' | 'private';
28
+ }
29
+ export interface ProxyRecipeSearchArgs {
30
+ q?: string;
31
+ limit?: number;
32
+ cursor?: string;
33
+ sort?: string;
34
+ expectedHubMode?: 'public' | 'private';
35
+ }
36
+ export interface ProxyRecipeExpressArgs {
37
+ recipeId: string;
38
+ inputPayload?: Record<string, unknown>;
39
+ expectedHubMode?: 'public' | 'private';
26
40
  }
27
41
  export interface ProxyFetchArgs {
28
42
  assetId?: string;
29
43
  assetIds?: string[];
44
+ expectedHubMode?: 'public' | 'private';
30
45
  }
31
46
  export interface ProxyAssetBundle {
32
47
  assets: unknown[];
48
+ expected_hub_mode?: 'public' | 'private';
49
+ compose_recipe?: boolean;
33
50
  }
34
51
  export interface ProxyReuseResultArgs {
35
52
  assetId: string;
@@ -40,6 +57,7 @@ export interface ProxyReuseResultArgs {
40
57
  tokensSaved?: number;
41
58
  timeSavedSeconds?: number;
42
59
  reason?: string;
60
+ expectedHubMode?: 'public' | 'private';
43
61
  }
44
62
  export interface ProxyAgentSearchArgs {
45
63
  query?: string;
@@ -59,26 +77,35 @@ export declare class EvolverProxyClient {
59
77
  private baseUrl;
60
78
  private token;
61
79
  private readonly fetchFn;
80
+ private readonly expectedHubMode?;
62
81
  private readonly reloadSettings;
63
82
  constructor(opts: EvolverProxyClientOptions);
64
83
  status(opts?: {
65
84
  signal?: AbortSignal;
66
85
  }): Promise<unknown>;
67
86
  search(args: ProxySearchArgs): Promise<unknown>;
87
+ searchRecipes(args: ProxyRecipeSearchArgs): Promise<unknown>;
88
+ expressRecipe(args: ProxyRecipeExpressArgs): Promise<unknown>;
68
89
  fetchAsset(args: ProxyFetchArgs): Promise<unknown>;
69
90
  searchAgents(args: ProxyAgentSearchArgs): Promise<unknown>;
70
91
  getAgentProfile(agentId: string, timeoutMs?: number): Promise<unknown>;
71
92
  discoverAgentsForTask(args: ProxyAgentDiscoverArgs): Promise<unknown>;
72
93
  submitAsset(asset: unknown): Promise<unknown>;
94
+ submitAssetBundle(bundle: ProxyAssetBundle): Promise<unknown>;
73
95
  /** Pre-publish dry-run: the hub runs its quality + content-safety gate but stores nothing and charges no credits. */
74
96
  validateAsset(asset: unknown): Promise<unknown>;
75
97
  validateAssetBundle(bundle: ProxyAssetBundle): Promise<unknown>;
76
98
  distillConversation(input: unknown): Promise<unknown>;
77
99
  recordReuseResult(args: ProxyReuseResultArgs): Promise<unknown>;
100
+ private modeBoundBody;
78
101
  call(method: string, path: string, body?: unknown, opts?: {
79
102
  signal?: AbortSignal;
80
103
  }): Promise<unknown>;
104
+ private verifyExpectedHubMode;
105
+ private verifyReloadedHubMode;
106
+ private acceptResult;
81
107
  private callOnce;
108
+ private connectionSnapshot;
82
109
  private reloadFromSettings;
83
110
  private proxyError;
84
111
  }
@@ -1,14 +1,17 @@
1
1
  import { lstatSync, readFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { homedir } from 'node:os';
3
4
  import { join } from 'node:path';
4
5
  export class EvolverProxyClient {
5
6
  baseUrl;
6
7
  token;
7
8
  fetchFn;
9
+ expectedHubMode;
8
10
  reloadSettings;
9
11
  constructor(opts) {
10
12
  this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
11
13
  this.token = opts.token;
14
+ this.expectedHubMode = opts.expectedHubMode;
12
15
  this.fetchFn = opts.fetchFn ?? globalFetch;
13
16
  this.reloadSettings = opts.reloadSettings;
14
17
  }
@@ -16,6 +19,7 @@ export class EvolverProxyClient {
16
19
  return this.call('GET', '/proxy/status', undefined, opts);
17
20
  }
18
21
  search(args) {
22
+ const expectedHubMode = args.expectedHubMode ?? this.expectedHubMode;
19
23
  return this.call('POST', '/asset/search', {
20
24
  ...(args.text ? { text: args.text } : {}),
21
25
  ...(args.signalsAny && args.signalsAny.length > 0 ? { signals: args.signalsAny } : {}),
@@ -23,12 +27,33 @@ export class EvolverProxyClient {
23
27
  ...(args.category ? { category: args.category } : {}),
24
28
  ...(args.gene ? { gene: args.gene } : {}),
25
29
  ...(args.limit !== undefined ? { limit: args.limit } : {}),
30
+ ...(expectedHubMode ? { expected_hub_mode: expectedHubMode } : {}),
31
+ });
32
+ }
33
+ searchRecipes(args) {
34
+ const expectedHubMode = args.expectedHubMode ?? this.expectedHubMode;
35
+ return this.call('POST', '/recipe/search', {
36
+ ...(args.q ? { q: args.q } : {}),
37
+ ...(args.limit !== undefined ? { limit: args.limit } : {}),
38
+ ...(args.cursor ? { cursor: args.cursor } : {}),
39
+ ...(args.sort ? { sort: args.sort } : {}),
40
+ ...(expectedHubMode ? { expected_hub_mode: expectedHubMode } : {}),
41
+ });
42
+ }
43
+ expressRecipe(args) {
44
+ const expectedHubMode = args.expectedHubMode ?? this.expectedHubMode;
45
+ return this.call('POST', '/recipe/express', {
46
+ recipe_id: args.recipeId,
47
+ ...(args.inputPayload ? { input_payload: args.inputPayload } : {}),
48
+ ...(expectedHubMode ? { expected_hub_mode: expectedHubMode } : {}),
26
49
  });
27
50
  }
28
51
  fetchAsset(args) {
52
+ const expectedHubMode = args.expectedHubMode ?? this.expectedHubMode;
29
53
  return this.call('POST', '/asset/fetch', {
30
54
  ...(args.assetId ? { asset_id: args.assetId } : {}),
31
55
  ...(args.assetIds ? { asset_ids: args.assetIds } : {}),
56
+ ...(expectedHubMode ? { expected_hub_mode: expectedHubMode } : {}),
32
57
  });
33
58
  }
34
59
  searchAgents(args) {
@@ -45,19 +70,27 @@ export class EvolverProxyClient {
45
70
  });
46
71
  }
47
72
  submitAsset(asset) {
48
- return this.call('POST', '/asset/submit', { assets: [asset] });
73
+ // MCP publishing remains durable and outage-tolerant; the bare route is reserved for V1 synchronous callers.
74
+ return this.call('POST', '/asset/submit?mode=async', this.modeBoundBody({
75
+ assets: [asset],
76
+ request_id: randomUUID(),
77
+ }));
78
+ }
79
+ submitAssetBundle(bundle) {
80
+ return this.call('POST', '/asset/submit', this.modeBoundBody(bundle));
49
81
  }
50
82
  /** Pre-publish dry-run: the hub runs its quality + content-safety gate but stores nothing and charges no credits. */
51
83
  validateAsset(asset) {
52
84
  return this.validateAssetBundle({ assets: [asset] });
53
85
  }
54
86
  validateAssetBundle(bundle) {
55
- return this.call('POST', '/asset/validate', bundle);
87
+ return this.call('POST', '/asset/validate', this.modeBoundBody(bundle));
56
88
  }
57
89
  distillConversation(input) {
58
- return this.call('POST', '/conversation/distill', input);
90
+ return this.call('POST', '/conversation/distill', this.modeBoundBody(input));
59
91
  }
60
92
  recordReuseResult(args) {
93
+ const expectedHubMode = args.expectedHubMode ?? this.expectedHubMode;
61
94
  return this.call('POST', '/asset/reuse-result', {
62
95
  asset_id: args.assetId,
63
96
  outcome: args.outcome,
@@ -65,43 +98,84 @@ export class EvolverProxyClient {
65
98
  ...(args.traceId ? { trace_id: args.traceId } : {}),
66
99
  ...(args.timeSavedSeconds !== undefined ? { time_saved_seconds: args.timeSavedSeconds } : {}),
67
100
  ...(args.reason ? { reason: args.reason } : {}),
101
+ ...(expectedHubMode ? { expected_hub_mode: expectedHubMode } : {}),
68
102
  });
69
103
  }
104
+ modeBoundBody(input) {
105
+ if (!this.expectedHubMode || !input || typeof input !== 'object' || Array.isArray(input))
106
+ return input;
107
+ const body = input;
108
+ return { ...body, expected_hub_mode: body['expected_hub_mode'] ?? this.expectedHubMode };
109
+ }
70
110
  async call(method, path, body, opts = {}) {
111
+ let connection = this.connectionSnapshot();
71
112
  try {
72
- const result = await this.callOnce(method, path, body, opts);
113
+ if (path !== '/proxy/status' && this.expectedHubMode === 'private') {
114
+ await this.verifyExpectedHubMode(connection, opts);
115
+ }
116
+ const result = await this.callOnce(method, path, body, opts, connection);
73
117
  if (result.ok)
74
- return result.parsed;
118
+ return this.acceptResult(result, path);
75
119
  if (result.status === 401 && this.reloadFromSettings()) {
76
- const retry = await this.callOnce(method, path, body, opts);
120
+ connection = this.connectionSnapshot();
121
+ await this.verifyReloadedHubMode(path, connection, opts);
122
+ const retry = await this.callOnce(method, path, body, opts, connection);
77
123
  if (retry.ok)
78
- return retry.parsed;
124
+ return this.acceptResult(retry, path);
79
125
  throw this.proxyError(retry, path);
80
126
  }
81
127
  throw this.proxyError(result, path);
82
128
  }
83
129
  catch (err) {
84
130
  if (this.reloadFromSettings()) {
85
- const retry = await this.callOnce(method, path, body, opts);
131
+ connection = this.connectionSnapshot();
132
+ await this.verifyReloadedHubMode(path, connection, opts);
133
+ const retry = await this.callOnce(method, path, body, opts, connection);
86
134
  if (retry.ok)
87
- return retry.parsed;
135
+ return this.acceptResult(retry, path);
88
136
  throw this.proxyError(retry, path);
89
137
  }
90
138
  throw err;
91
139
  }
92
140
  }
93
- async callOnce(method, path, body, opts) {
94
- const res = await this.fetchFn(`${this.baseUrl}${path}`, {
141
+ async verifyExpectedHubMode(connection, opts) {
142
+ // A proxy can restart on the same loopback URL with the same operator-supplied token. Verify every private
143
+ // operation against the same immutable connection snapshot used for its payload. This prevents a concurrent
144
+ // settings reload from moving the payload to an endpoint that the status probe never verified.
145
+ const result = await this.callOnce('GET', '/proxy/status', undefined, opts, connection);
146
+ if (!result.ok)
147
+ throw this.proxyError(result, '/proxy/status');
148
+ this.acceptResult(result, '/proxy/status');
149
+ }
150
+ async verifyReloadedHubMode(path, connection, opts) {
151
+ if (path !== '/proxy/status' && this.expectedHubMode === 'private') {
152
+ await this.verifyExpectedHubMode(connection, opts);
153
+ }
154
+ }
155
+ acceptResult(result, path) {
156
+ if (path === '/proxy/status' && this.expectedHubMode === 'private') {
157
+ const status = recordValue(result.parsed);
158
+ if (status['hub_mode'] !== 'private')
159
+ throw new Error('proxy_hub_mode_mismatch');
160
+ }
161
+ return result.parsed;
162
+ }
163
+ async callOnce(method, path, body, opts, connection) {
164
+ const res = await this.fetchFn(`${connection.baseUrl}${path}`, {
95
165
  method,
96
166
  headers: {
97
- authorization: `Bearer ${this.token}`,
167
+ authorization: `Bearer ${connection.token}`,
98
168
  'content-type': 'application/json',
169
+ ...(this.expectedHubMode ? { 'x-evomap-expected-hub-mode': this.expectedHubMode } : {}),
99
170
  },
100
171
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
101
172
  ...(opts.signal ? { signal: opts.signal } : {}),
102
173
  });
103
174
  return { ok: res.ok, status: res.status, parsed: await res.json() };
104
175
  }
176
+ connectionSnapshot() {
177
+ return { baseUrl: this.baseUrl, token: this.token };
178
+ }
105
179
  reloadFromSettings() {
106
180
  const next = this.reloadSettings?.();
107
181
  if (!next)
@@ -133,38 +207,46 @@ function agentDirectoryBody(args) {
133
207
  };
134
208
  }
135
209
  export function proxyClientFromEnv(env = process.env) {
210
+ const expectedHubMode = expectedHubModeFromEnv(env);
211
+ if (!expectedHubMode)
212
+ return undefined;
136
213
  const token = env['EVOLVER_IPC_TOKEN']?.trim();
137
214
  if (!token)
138
- return proxyClientFromSettings(env, env === process.env);
139
- const explicitUrl = env['EVOLVER_PROXY_URL']?.trim();
140
- const port = env['EVOLVER_IPC_PORT']?.trim() || env['EVOMAP_PROXY_PORT']?.trim() || '19820';
141
- return new EvolverProxyClient({ baseUrl: explicitUrl || `http://127.0.0.1:${port}`, token });
215
+ return proxyClientFromSettings(env, env === process.env, undefined, expectedHubMode);
216
+ const baseUrl = proxyBaseUrlFromEnv(env);
217
+ return baseUrl ? new EvolverProxyClient({ baseUrl, token, expectedHubMode }) : undefined;
142
218
  }
143
219
  export async function reachableProxyClientFromEnv(env = process.env, opts = {}) {
220
+ const expectedHubMode = expectedHubModeFromEnv(env);
221
+ if (!expectedHubMode)
222
+ return undefined;
144
223
  const token = env['EVOLVER_IPC_TOKEN']?.trim();
145
224
  if (token) {
146
- const explicitUrl = env['EVOLVER_PROXY_URL']?.trim();
147
- const port = env['EVOLVER_IPC_PORT']?.trim() || env['EVOMAP_PROXY_PORT']?.trim() || '19820';
148
- return new EvolverProxyClient({ baseUrl: explicitUrl || `http://127.0.0.1:${port}`, token, ...(opts.fetchFn ? { fetchFn: opts.fetchFn } : {}) });
225
+ const baseUrl = proxyBaseUrlFromEnv(env);
226
+ return baseUrl ? new EvolverProxyClient({ baseUrl, token, expectedHubMode, ...(opts.fetchFn ? { fetchFn: opts.fetchFn } : {}) }) : undefined;
149
227
  }
150
- const client = proxyClientFromSettings(env, env === process.env, opts.fetchFn);
228
+ const client = proxyClientFromSettings(env, env === process.env, opts.fetchFn, expectedHubMode);
151
229
  if (!client)
152
230
  return undefined;
153
231
  return await proxyClientReachable(client, opts.timeoutMs ?? 250) ? client : undefined;
154
232
  }
155
- function proxyClientFromSettings(env, allowDefaultHome, fetchFn) {
233
+ function proxyClientFromSettings(env, allowDefaultHome, fetchFn, expectedHubMode = 'public') {
156
234
  const settings = readProxySettings(env, allowDefaultHome);
157
235
  return settings ? new EvolverProxyClient({
158
236
  ...settings,
237
+ expectedHubMode,
159
238
  ...(fetchFn ? { fetchFn } : {}),
160
239
  reloadSettings: () => readProxySettings(env, allowDefaultHome),
161
240
  }) : undefined;
162
241
  }
242
+ function expectedHubModeFromEnv(env) {
243
+ const value = env['EVOMAP_HUB_MODE']?.trim().toLowerCase() || 'public';
244
+ return value === 'public' || value === 'private' ? value : undefined;
245
+ }
163
246
  function readProxySettings(env, allowDefaultHome) {
164
- const homeDir = env['HOME']?.trim() || (allowDefaultHome ? homedir() : '');
165
- if (!homeDir)
247
+ const settingsPath = resolveProxySettingsPath(env, allowDefaultHome);
248
+ if (!settingsPath)
166
249
  return undefined;
167
- const settingsPath = join(homeDir, '.evolver', 'settings.json');
168
250
  try {
169
251
  if (!lstatSync(settingsPath).isFile())
170
252
  return undefined;
@@ -180,11 +262,23 @@ function readProxySettings(env, allowDefaultHome) {
180
262
  return undefined;
181
263
  }
182
264
  }
265
+ function resolveProxySettingsPath(env, allowDefaultHome) {
266
+ const explicit = env['EVOLVER_PROXY_SETTINGS_FILE']?.trim();
267
+ if (explicit)
268
+ return explicit;
269
+ const settingsDir = env['EVOLVER_SETTINGS_DIR']?.trim();
270
+ if (settingsDir)
271
+ return join(settingsDir, 'settings.json');
272
+ const homeDir = env['HOME']?.trim() || (allowDefaultHome ? homedir() : '');
273
+ return homeDir ? join(homeDir, '.evolver', 'settings.json') : undefined;
274
+ }
183
275
  function isLoopbackHttpUrl(raw) {
184
276
  try {
185
277
  const url = new URL(raw);
186
278
  if (url.protocol !== 'http:' && url.protocol !== 'https:')
187
279
  return false;
280
+ if (url.username || url.password || url.search || url.hash || (url.pathname && url.pathname !== '/'))
281
+ return false;
188
282
  const hostname = url.hostname.toLowerCase();
189
283
  return hostname === '127.0.0.1'
190
284
  || hostname === 'localhost'
@@ -195,6 +289,19 @@ function isLoopbackHttpUrl(raw) {
195
289
  return false;
196
290
  }
197
291
  }
292
+ function proxyBaseUrlFromEnv(env) {
293
+ const explicitUrl = env['EVOLVER_PROXY_URL']?.trim();
294
+ if (explicitUrl)
295
+ return isLoopbackHttpUrl(explicitUrl) ? explicitUrl : undefined;
296
+ const rawPort = env['EVOLVER_IPC_PORT']?.trim() || env['EVOMAP_PROXY_PORT']?.trim() || '19820';
297
+ if (!/^\d+$/.test(rawPort))
298
+ return undefined;
299
+ const port = Number(rawPort);
300
+ if (!Number.isInteger(port) || port < 0 || port > 65_535)
301
+ return undefined;
302
+ const baseUrl = `http://127.0.0.1:${port}`;
303
+ return isLoopbackHttpUrl(baseUrl) ? baseUrl : undefined;
304
+ }
198
305
  function recordValue(value) {
199
306
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
200
307
  }
@@ -205,7 +312,9 @@ async function proxyClientReachable(client, timeoutMs) {
205
312
  await client.status({ signal: controller.signal });
206
313
  return true;
207
314
  }
208
- catch {
315
+ catch (error) {
316
+ if (error instanceof Error && error.message === 'proxy_hub_mode_mismatch')
317
+ throw error;
209
318
  return false;
210
319
  }
211
320
  finally {
@@ -214,5 +323,5 @@ async function proxyClientReachable(client, timeoutMs) {
214
323
  }
215
324
  }
216
325
  async function globalFetch(url, init) {
217
- return fetch(url, init);
326
+ return fetch(url, { ...init, redirect: 'error' });
218
327
  }
@@ -0,0 +1,20 @@
1
+ import { linkSync } from 'node:fs';
2
+ export interface SharedFileCommitOptions {
3
+ path: string;
4
+ expectedRaw: string | undefined;
5
+ nextRaw: string | undefined;
6
+ mode?: number;
7
+ beforeCommitForTest?: () => void;
8
+ afterValidateForTest?: () => void;
9
+ afterDisplaceForTest?: (displacedPath: string) => void;
10
+ beforePublishForTest?: () => void;
11
+ linkForTest?: typeof linkSync;
12
+ }
13
+ export declare class SharedFileConflictError extends Error {
14
+ readonly recoveryPath?: string | undefined;
15
+ constructor(path: string, recoveryPath?: string | undefined, options?: {
16
+ cause?: unknown;
17
+ });
18
+ }
19
+ /** Commits only if the target bytes still match the caller's snapshot. */
20
+ export declare function commitSharedFile(options: SharedFileCommitOptions): void;
@@ -0,0 +1,256 @@
1
+ import { chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, linkSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ export class SharedFileConflictError extends Error {
5
+ recoveryPath;
6
+ constructor(path, recoveryPath, options) {
7
+ super(recoveryPath
8
+ ? `Shared config changed during commit: ${path}; conflicting bytes preserved at ${recoveryPath}`
9
+ : `Shared config changed during commit: ${path}`, options?.cause === undefined ? undefined : { cause: options.cause });
10
+ this.recoveryPath = recoveryPath;
11
+ this.name = 'SharedFileConflictError';
12
+ }
13
+ }
14
+ function tempPath(path, label) {
15
+ return join(dirname(path), `.${basename(path)}.evolver-${label}-${process.pid}-${randomUUID()}`);
16
+ }
17
+ function writePrepared(path, raw, mode) {
18
+ const fd = openSync(path, 'wx', mode);
19
+ try {
20
+ writeFileSync(fd, raw, 'utf8');
21
+ }
22
+ finally {
23
+ closeSync(fd);
24
+ }
25
+ chmodSync(path, mode);
26
+ }
27
+ function removeIfPresent(path) {
28
+ if (!path)
29
+ return;
30
+ try {
31
+ unlinkSync(path);
32
+ }
33
+ catch (err) {
34
+ if (err.code !== 'ENOENT')
35
+ throw err;
36
+ }
37
+ }
38
+ function publishNoClobber(source, target, linkFile = linkSync) {
39
+ try {
40
+ linkFile(source, target);
41
+ }
42
+ catch (error) {
43
+ const code = error.code;
44
+ if (code !== 'EPERM' && code !== 'EACCES' && code !== 'ENOTSUP' && code !== 'EOPNOTSUPP' && code !== 'EXDEV') {
45
+ throw error;
46
+ }
47
+ copyFileSync(source, target, fsConstants.COPYFILE_EXCL);
48
+ }
49
+ }
50
+ function snapshotNoClobber(source, snapshot, linkFile = linkSync) {
51
+ try {
52
+ linkFile(source, snapshot);
53
+ }
54
+ catch (error) {
55
+ const code = error.code;
56
+ if (code !== 'EPERM' && code !== 'EACCES' && code !== 'ENOTSUP' && code !== 'EOPNOTSUPP' && code !== 'EXDEV') {
57
+ throw error;
58
+ }
59
+ copyFileSync(source, snapshot, fsConstants.COPYFILE_EXCL);
60
+ }
61
+ }
62
+ function restoreNoClobber(displaced, target, linkFile = linkSync) {
63
+ try {
64
+ publishNoClobber(displaced, target, linkFile);
65
+ unlinkSync(displaced);
66
+ return undefined;
67
+ }
68
+ catch (err) {
69
+ const code = err.code;
70
+ if (code === 'EEXIST')
71
+ return displaced;
72
+ if (code === 'EPERM' || code === 'EACCES' || code === 'ENOTSUP' || code === 'EOPNOTSUPP' || code === 'EXDEV') {
73
+ try {
74
+ copyFileSync(displaced, target, fsConstants.COPYFILE_EXCL);
75
+ unlinkSync(displaced);
76
+ return undefined;
77
+ }
78
+ catch (copyError) {
79
+ if (copyError.code === 'EEXIST')
80
+ return displaced;
81
+ throw new AggregateError([err, copyError], `Unable to restore shared config at ${target}`);
82
+ }
83
+ }
84
+ throw err;
85
+ }
86
+ }
87
+ function fileVersion(path) {
88
+ const stat = statSync(path, { bigint: true });
89
+ return {
90
+ dev: stat.dev,
91
+ ino: stat.ino,
92
+ size: stat.size,
93
+ mtimeNs: stat.mtimeNs,
94
+ ctimeNs: stat.ctimeNs,
95
+ };
96
+ }
97
+ function sameFileVersion(left, right) {
98
+ return left.dev === right.dev
99
+ && left.ino === right.ino
100
+ && left.size === right.size
101
+ && left.mtimeNs === right.mtimeNs
102
+ && left.ctimeNs === right.ctimeNs;
103
+ }
104
+ /** Commits only if the target bytes still match the caller's snapshot. */
105
+ export function commitSharedFile(options) {
106
+ const mode = options.mode ?? 0o600;
107
+ const prepared = options.nextRaw === undefined ? undefined : tempPath(options.path, 'next');
108
+ const displaced = options.expectedRaw === undefined ? undefined : tempPath(options.path, 'previous');
109
+ const movedLive = options.expectedRaw === undefined ? undefined : tempPath(options.path, 'live');
110
+ let preserveDisplaced = false;
111
+ let preserveMovedLive = false;
112
+ let liveWasMoved = false;
113
+ let published = false;
114
+ try {
115
+ if (prepared)
116
+ writePrepared(prepared, options.nextRaw, mode);
117
+ options.beforeCommitForTest?.();
118
+ if (options.expectedRaw === undefined) {
119
+ if (!prepared)
120
+ return;
121
+ try {
122
+ publishNoClobber(prepared, options.path, options.linkForTest);
123
+ }
124
+ catch (err) {
125
+ if (err.code === 'EEXIST') {
126
+ throw new SharedFileConflictError(options.path);
127
+ }
128
+ throw err;
129
+ }
130
+ return;
131
+ }
132
+ // Validate while the live path is still present. The no-clobber publish below then limits the unavoidable
133
+ // crash-only missing-path interval to the two adjacent metadata operations.
134
+ let versionBeforeRead;
135
+ let actualRaw;
136
+ let validatedVersion;
137
+ try {
138
+ versionBeforeRead = fileVersion(options.path);
139
+ actualRaw = readFileSync(options.path, 'utf8');
140
+ validatedVersion = fileVersion(options.path);
141
+ }
142
+ catch (err) {
143
+ if (err.code === 'ENOENT') {
144
+ throw new SharedFileConflictError(options.path);
145
+ }
146
+ throw err;
147
+ }
148
+ if (actualRaw !== options.expectedRaw) {
149
+ throw new SharedFileConflictError(options.path);
150
+ }
151
+ if (!sameFileVersion(versionBeforeRead, validatedVersion)) {
152
+ throw new SharedFileConflictError(options.path);
153
+ }
154
+ options.afterValidateForTest?.();
155
+ snapshotNoClobber(options.path, displaced, options.linkForTest);
156
+ try {
157
+ const liveAfterSnapshot = fileVersion(options.path);
158
+ if (readFileSync(options.path, 'utf8') !== options.expectedRaw
159
+ || liveAfterSnapshot.dev !== validatedVersion.dev
160
+ || liveAfterSnapshot.ino !== validatedVersion.ino
161
+ || liveAfterSnapshot.size !== validatedVersion.size
162
+ || liveAfterSnapshot.mtimeNs !== validatedVersion.mtimeNs) {
163
+ throw new SharedFileConflictError(options.path);
164
+ }
165
+ options.afterDisplaceForTest?.(displaced);
166
+ const liveBeforePublish = fileVersion(options.path);
167
+ if (!sameFileVersion(liveAfterSnapshot, liveBeforePublish)
168
+ || readFileSync(options.path, 'utf8') !== options.expectedRaw
169
+ || readFileSync(displaced, 'utf8') !== options.expectedRaw) {
170
+ throw new SharedFileConflictError(options.path);
171
+ }
172
+ options.beforePublishForTest?.();
173
+ renameSync(options.path, movedLive);
174
+ liveWasMoved = true;
175
+ if (readFileSync(movedLive, 'utf8') !== options.expectedRaw) {
176
+ const recoveryPath = restoreNoClobber(movedLive, options.path, options.linkForTest);
177
+ liveWasMoved = recoveryPath !== undefined;
178
+ preserveMovedLive = recoveryPath !== undefined;
179
+ throw new SharedFileConflictError(options.path, recoveryPath);
180
+ }
181
+ if (prepared) {
182
+ publishNoClobber(prepared, options.path);
183
+ removeIfPresent(prepared);
184
+ }
185
+ else {
186
+ unlinkSync(movedLive);
187
+ liveWasMoved = false;
188
+ }
189
+ if (prepared) {
190
+ unlinkSync(movedLive);
191
+ liveWasMoved = false;
192
+ }
193
+ published = true;
194
+ removeIfPresent(displaced);
195
+ }
196
+ catch (error) {
197
+ if (!published) {
198
+ if (liveWasMoved && movedLive !== undefined) {
199
+ const recoveryPath = restoreNoClobber(movedLive, options.path, options.linkForTest);
200
+ liveWasMoved = recoveryPath !== undefined;
201
+ preserveMovedLive = recoveryPath !== undefined;
202
+ if (recoveryPath !== undefined) {
203
+ removeIfPresent(displaced);
204
+ throw new SharedFileConflictError(options.path, recoveryPath, { cause: error });
205
+ }
206
+ }
207
+ removeIfPresent(displaced);
208
+ if (error instanceof SharedFileConflictError)
209
+ throw error;
210
+ throw new SharedFileConflictError(options.path, undefined, { cause: error });
211
+ }
212
+ let recoveryPath;
213
+ try {
214
+ if (prepared && existsSync(options.path)) {
215
+ try {
216
+ if (readFileSync(options.path, 'utf8') === options.nextRaw)
217
+ removeIfPresent(options.path);
218
+ }
219
+ catch {
220
+ // Preserve a concurrent replacement and expose the snapshot as recoveryPath.
221
+ }
222
+ }
223
+ recoveryPath = restoreNoClobber(displaced, options.path, options.linkForTest);
224
+ preserveDisplaced = recoveryPath !== undefined;
225
+ }
226
+ catch (restoreError) {
227
+ preserveDisplaced = true;
228
+ throw new Error(`Shared config commit failed for ${options.path}; original bytes preserved at ${displaced}`, { cause: new AggregateError([error, restoreError]) });
229
+ }
230
+ throw new SharedFileConflictError(options.path, recoveryPath, { cause: error });
231
+ }
232
+ }
233
+ finally {
234
+ if (!preserveDisplaced && displaced && existsSync(displaced)) {
235
+ if (!existsSync(options.path)) {
236
+ const recoveryPath = restoreNoClobber(displaced, options.path, options.linkForTest);
237
+ preserveDisplaced = recoveryPath !== undefined;
238
+ }
239
+ else {
240
+ // Keep a displaced file only when it is the sole recovery copy of concurrent bytes.
241
+ try {
242
+ const displacedRaw = readFileSync(displaced, 'utf8');
243
+ if (displacedRaw === options.expectedRaw)
244
+ removeIfPresent(displaced);
245
+ }
246
+ catch {
247
+ // Preserve an unreadable displaced file instead of masking the primary result.
248
+ }
249
+ }
250
+ }
251
+ removeIfPresent(prepared);
252
+ if (!preserveMovedLive && movedLive && existsSync(movedLive)) {
253
+ removeIfPresent(movedLive);
254
+ }
255
+ }
256
+ }