@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.
- package/dist/antigravityInstaller.d.ts +1 -1
- package/dist/antigravityInstaller.js +17 -29
- package/dist/codexInstaller.d.ts +8 -3
- package/dist/codexInstaller.js +217 -53
- package/dist/cursorRulesInstaller.d.ts +1 -1
- package/dist/cursorRulesInstaller.js +66 -17
- package/dist/envFile.d.ts +2 -1
- package/dist/envFile.js +6 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/injection.d.ts +2 -1
- package/dist/injection.js +10 -7
- package/dist/installer.d.ts +52 -24
- package/dist/installer.js +268 -157
- package/dist/installerShared.d.ts +103 -0
- package/dist/installerShared.js +99 -0
- package/dist/jsonMcpInstaller.d.ts +79 -0
- package/dist/jsonMcpInstaller.js +857 -0
- package/dist/kiroInstaller.d.ts +10 -0
- package/dist/kiroInstaller.js +146 -0
- package/dist/opencodeInstaller.d.ts +18 -0
- package/dist/opencodeInstaller.js +531 -0
- package/dist/primer.js +8 -5
- package/dist/productBridge.d.ts +37 -0
- package/dist/productBridge.js +250 -0
- package/dist/productBridgeShim.d.ts +52 -0
- package/dist/productBridgeShim.js +338 -0
- package/dist/proxyClient.d.ts +27 -0
- package/dist/proxyClient.js +135 -26
- package/dist/sharedFileCommit.d.ts +20 -0
- package/dist/sharedFileCommit.js +256 -0
- package/dist/stdio.js +10 -4
- package/dist/tools.js +71 -7
- package/package.json +13 -2
package/dist/proxyClient.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/proxyClient.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
118
|
+
return this.acceptResult(result, path);
|
|
75
119
|
if (result.status === 401 && this.reloadFromSettings()) {
|
|
76
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
94
|
-
|
|
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 ${
|
|
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
|
|
140
|
-
|
|
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
|
|
147
|
-
|
|
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
|
|
165
|
-
if (!
|
|
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
|
+
}
|