@bjorntech/key-ferry 0.1.13 → 0.1.17
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/README.md +20 -0
- package/dist/broker-registry.d.ts +26 -0
- package/dist/broker-registry.d.ts.map +1 -1
- package/dist/broker-registry.js +253 -54
- package/dist/broker-registry.js.map +1 -1
- package/dist/contracts.d.ts +7 -3
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +1 -0
- package/dist/contracts.js.map +1 -1
- package/dist/options.d.ts +1 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +17 -0
- package/dist/options.js.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +13 -4
- package/dist/plugin.js.map +1 -1
- package/dist/relay/index.d.ts +2 -0
- package/dist/relay/index.d.ts.map +1 -1
- package/dist/relay/index.js +150 -110
- package/dist/relay/index.js.map +1 -1
- package/dist/remote.d.ts +12 -1
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +114 -76
- package/dist/remote.js.map +1 -1
- package/dist/rpc.d.ts +22 -12
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +27 -27
- package/dist/rpc.js.map +1 -1
- package/dist/scoped-controller.d.ts +1 -1
- package/dist/scoped-controller.d.ts.map +1 -1
- package/dist/scoped-controller.js +37 -7
- package/dist/scoped-controller.js.map +1 -1
- package/dist/scopes/controller-helpers.d.ts.map +1 -1
- package/dist/scopes/controller-helpers.js +5 -0
- package/dist/scopes/controller-helpers.js.map +1 -1
- package/dist/services/process.d.ts +2 -0
- package/dist/services/process.d.ts.map +1 -1
- package/dist/services/process.js +13 -2
- package/dist/services/process.js.map +1 -1
- package/dist/varlock/manager.d.ts.map +1 -1
- package/dist/varlock/manager.js +312 -74
- package/dist/varlock/manager.js.map +1 -1
- package/dist/varlock/ownership.d.ts +88 -0
- package/dist/varlock/ownership.d.ts.map +1 -0
- package/dist/varlock/ownership.js +574 -0
- package/dist/varlock/ownership.js.map +1 -0
- package/package.json +1 -1
package/dist/varlock/manager.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { constants as fsConstants } from "node:fs";
|
|
2
3
|
import { access, mkdtemp, realpath, rm, stat, lstat } from "node:fs/promises";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
@@ -6,6 +7,7 @@ import { Duration, Effect, Layer, Scope, Semaphore } from "effect";
|
|
|
6
7
|
import { ProjectKeyFerryManager, VarlockError, } from "../contracts.js";
|
|
7
8
|
import { Process, ProcessLive, } from "../services/process.js";
|
|
8
9
|
import { classifyStderr } from "./diagnostics.js";
|
|
10
|
+
import { removeOwnershipRecord, appendProxyLedgerEntry, reapStaleOwnershipRecords, writeOwnershipRecord, ownershipFileOps, ownershipDirectory, } from "./ownership.js";
|
|
9
11
|
import { decodeSessionList, findOwnedSession, sanitizeSessionStatus, selectPlaceholderEnvironment, validateProxyWiring, } from "./protocol.js";
|
|
10
12
|
const DEFAULT_COMMAND = ["varlock"];
|
|
11
13
|
const DEFAULT_START_TIMEOUT = 30_000;
|
|
@@ -14,8 +16,15 @@ const DEFAULT_REFRESH_TIMEOUT = 30_000;
|
|
|
14
16
|
const DEFAULT_POLL_INTERVAL = 100;
|
|
15
17
|
const MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
|
|
16
18
|
const MAX_TIMEOUT = 10 * 60_000;
|
|
19
|
+
const DEFAULT_HEALTH_CHECK_INTERVAL = 1_000;
|
|
17
20
|
const defaultFileOps = { mkdtemp, realpath, rm };
|
|
18
|
-
const safeError = (code, retryable) => new VarlockError({
|
|
21
|
+
const safeError = (code, retryable) => new VarlockError({
|
|
22
|
+
code,
|
|
23
|
+
retryable,
|
|
24
|
+
message: code === "ownership_unavailable"
|
|
25
|
+
? "Key Ferry ownership directory is unavailable"
|
|
26
|
+
: `Varlock ${code.replaceAll("_", " ")}`,
|
|
27
|
+
});
|
|
19
28
|
const fromProcessError = (error, code) => new VarlockError({
|
|
20
29
|
code,
|
|
21
30
|
retryable: error.code === "timeout" || error.code === "spawn-failed" || error.code === "io",
|
|
@@ -31,37 +40,64 @@ function adapterOptions(options) {
|
|
|
31
40
|
const raw = options.adapter ?? {};
|
|
32
41
|
const command = raw.command === undefined ? [...DEFAULT_COMMAND] : raw.command;
|
|
33
42
|
const environmentKeys = raw.environmentKeys;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
const [validCommand, validEnvironmentKeys] = validateAdapterArrays(command, environmentKeys);
|
|
44
|
+
const ownershipDirectory = raw.ownershipDirectory;
|
|
45
|
+
validateOwnershipDirectory(ownershipDirectory);
|
|
46
|
+
return {
|
|
47
|
+
command: [...validCommand],
|
|
48
|
+
environmentKeys: [...validEnvironmentKeys],
|
|
49
|
+
startupTimeoutMs: adapterNumber(raw, "startupTimeoutMs", DEFAULT_START_TIMEOUT, 100, MAX_TIMEOUT),
|
|
50
|
+
commandTimeoutMs: adapterNumber(raw, "commandTimeoutMs", DEFAULT_COMMAND_TIMEOUT, 100, MAX_TIMEOUT),
|
|
51
|
+
reloadTimeoutMs: adapterNumber(raw, "reloadTimeoutMs", DEFAULT_REFRESH_TIMEOUT, 100, MAX_TIMEOUT),
|
|
52
|
+
pollIntervalMs: adapterNumber(raw, "pollIntervalMs", DEFAULT_POLL_INTERVAL, 10, 10_000),
|
|
53
|
+
requireStrictEgress: readStrictEgress(raw.requireStrictEgress),
|
|
54
|
+
...(ownershipDirectory === undefined ? {} : { ownershipDirectory }),
|
|
55
|
+
healthCheckIntervalMs: adapterNumber(raw, "healthCheckIntervalMs", DEFAULT_HEALTH_CHECK_INTERVAL, 100, MAX_TIMEOUT),
|
|
56
|
+
...readLifecycleLogger(raw),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function validateAdapterArrays(command, environmentKeys) {
|
|
60
|
+
if (!validCommandArray(command) || !validEnvironmentKeyArray(environmentKeys))
|
|
38
61
|
throw safeError("invalid_config", false);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
62
|
+
return [command, environmentKeys];
|
|
63
|
+
}
|
|
64
|
+
function validateOwnershipDirectory(value) {
|
|
65
|
+
if (value !== undefined && (typeof value !== "string" || value.length === 0))
|
|
43
66
|
throw safeError("invalid_config", false);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
throw safeError("invalid_config", false);
|
|
51
|
-
return value;
|
|
52
|
-
};
|
|
53
|
-
const strict = raw.requireStrictEgress;
|
|
54
|
-
if (strict !== undefined && typeof strict !== "boolean")
|
|
67
|
+
}
|
|
68
|
+
function adapterNumber(raw, name, fallback, min, max) {
|
|
69
|
+
const value = raw[name];
|
|
70
|
+
if (value === undefined)
|
|
71
|
+
return fallback;
|
|
72
|
+
if (!validAdapterNumber(value, min, max))
|
|
55
73
|
throw safeError("invalid_config", false);
|
|
56
|
-
return
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
function validAdapterNumber(value, min, max) {
|
|
77
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= max;
|
|
78
|
+
}
|
|
79
|
+
function readLifecycleLogger(raw) {
|
|
80
|
+
return typeof raw.lifecycleLogger === "function"
|
|
81
|
+
? {
|
|
82
|
+
lifecycleLogger: raw.lifecycleLogger,
|
|
83
|
+
}
|
|
84
|
+
: {};
|
|
85
|
+
}
|
|
86
|
+
function validCommandArray(value) {
|
|
87
|
+
return (Array.isArray(value) &&
|
|
88
|
+
value.length > 0 &&
|
|
89
|
+
value.length <= 32 &&
|
|
90
|
+
value.every((v) => typeof v === "string" && v.length > 0 && v.length <= 1024 && !v.includes("\0")));
|
|
91
|
+
}
|
|
92
|
+
function validEnvironmentKeyArray(value) {
|
|
93
|
+
return (Array.isArray(value) &&
|
|
94
|
+
value.length <= 128 &&
|
|
95
|
+
value.every((v) => typeof v === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(v) && v.length <= 256));
|
|
96
|
+
}
|
|
97
|
+
function readStrictEgress(value) {
|
|
98
|
+
if (value !== undefined && typeof value !== "boolean")
|
|
99
|
+
throw safeError("invalid_config", false);
|
|
100
|
+
return value ?? true;
|
|
65
101
|
}
|
|
66
102
|
function safeProjectDirectory(value) {
|
|
67
103
|
if (typeof value !== "string" ||
|
|
@@ -102,32 +138,48 @@ function parseEnvironmentOutput(raw) {
|
|
|
102
138
|
return result;
|
|
103
139
|
}
|
|
104
140
|
function statusSnapshot(state, projectDirectory) {
|
|
105
|
-
let endpoint;
|
|
106
|
-
if (state.session) {
|
|
107
|
-
try {
|
|
108
|
-
endpoint = validateProxyWiring(state.session.env).proxyUrl;
|
|
109
|
-
}
|
|
110
|
-
catch {
|
|
111
|
-
endpoint = undefined;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
141
|
return {
|
|
115
142
|
phase: state.phase,
|
|
116
143
|
projectDirectory: projectDirectory,
|
|
117
144
|
generation: state.generation,
|
|
145
|
+
...statusOptionalFields(state),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function statusOptionalFields(state) {
|
|
149
|
+
return {
|
|
118
150
|
...(state.process ? { pid: state.process.pid } : {}),
|
|
119
|
-
...(
|
|
151
|
+
...(state.session ? { sessionId: state.session.uuid, proxySessionId: state.session.uuid } : {}),
|
|
152
|
+
...statusEndpointField(state),
|
|
120
153
|
...(state.lastRefreshAt ? { lastRefreshAt: state.lastRefreshAt } : {}),
|
|
121
154
|
...(state.error ? { error: state.error } : {}),
|
|
122
155
|
};
|
|
123
156
|
}
|
|
157
|
+
function statusEndpointField(state) {
|
|
158
|
+
if (!state.session)
|
|
159
|
+
return {};
|
|
160
|
+
try {
|
|
161
|
+
return { endpoint: validateProxyWiring(state.session.env).proxyUrl };
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return {};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
124
167
|
function commandParts(options, args) {
|
|
125
|
-
return {
|
|
168
|
+
return {
|
|
169
|
+
executable: options.command[0],
|
|
170
|
+
args: [...options.command.slice(1), ...args],
|
|
171
|
+
};
|
|
126
172
|
}
|
|
127
173
|
function runCommand(process, options, cwd, args, timeoutMs, environment) {
|
|
128
174
|
const parts = commandParts(options, args);
|
|
129
175
|
return process
|
|
130
|
-
.run({
|
|
176
|
+
.run({
|
|
177
|
+
...parts,
|
|
178
|
+
cwd,
|
|
179
|
+
env: environment,
|
|
180
|
+
maxBytes: MAX_OUTPUT_BYTES,
|
|
181
|
+
timeoutMs,
|
|
182
|
+
})
|
|
131
183
|
.pipe(Effect.mapError((error) => fromProcessError(error, error.code === "timeout" ? "timeout" : "unavailable")), Effect.map(({ stdout, stderr }) => ({ stdout, stderr })));
|
|
132
184
|
}
|
|
133
185
|
function candidatesForOwner(records, pid, cwd) {
|
|
@@ -143,7 +195,17 @@ function prepareStart(state, healthCheck, snapshot) {
|
|
|
143
195
|
return Effect.fail(safeError("disposed", false));
|
|
144
196
|
if (!state.process || !state.session)
|
|
145
197
|
return Effect.succeed(undefined);
|
|
146
|
-
return healthCheck().pipe(Effect.
|
|
198
|
+
return healthCheck(true).pipe(Effect.flatMap(() => prepareExistingStart(state, snapshot)));
|
|
199
|
+
}
|
|
200
|
+
function prepareExistingStart(state, snapshot) {
|
|
201
|
+
if (!state.process || !state.session)
|
|
202
|
+
return Effect.succeed(undefined);
|
|
203
|
+
if (state.phase === "ready")
|
|
204
|
+
return Effect.succeed(snapshot());
|
|
205
|
+
return Effect.fail(startFailure(state.error));
|
|
206
|
+
}
|
|
207
|
+
function startFailure(error) {
|
|
208
|
+
return error ?? safeError("not_ready", true);
|
|
147
209
|
}
|
|
148
210
|
function requireRefreshSession(state) {
|
|
149
211
|
if (!state.process || !state.session)
|
|
@@ -187,13 +249,10 @@ function validateRefreshCapability(state, session, requireStrictEgress, healthCh
|
|
|
187
249
|
}
|
|
188
250
|
});
|
|
189
251
|
}
|
|
190
|
-
function validateStartupEgress(state, requireStrictEgress, session,
|
|
252
|
+
function validateStartupEgress(state, requireStrictEgress, session, _handle) {
|
|
191
253
|
if (!requireStrictEgress || session.egressMode === "strict")
|
|
192
254
|
return Effect.void;
|
|
193
|
-
|
|
194
|
-
return handle.stop.pipe(Effect.catch(() => Effect.void), Effect.tap(() => Effect.sync(() => {
|
|
195
|
-
state.process = undefined;
|
|
196
|
-
})), Effect.flatMap(() => Effect.fail(error)));
|
|
255
|
+
return Effect.fail(safeError("invalid_config", false));
|
|
197
256
|
}
|
|
198
257
|
function assertRegularCertificate(pathInfo) {
|
|
199
258
|
if (!pathInfo.isFile() || pathInfo.isSymbolicLink())
|
|
@@ -210,6 +269,9 @@ function assertOwnedCertificate(cert) {
|
|
|
210
269
|
(Number(cert.mode) & 0o022) !== 0)
|
|
211
270
|
throw new Error("certificate ownership or permissions invalid");
|
|
212
271
|
}
|
|
272
|
+
function healthCheckDue(force, lastHealthCheckAt, intervalMs) {
|
|
273
|
+
return force || Date.now() - lastHealthCheckAt >= intervalMs;
|
|
274
|
+
}
|
|
213
275
|
function rejectUnavailableEnvironment(state) {
|
|
214
276
|
if (state.phase === "failed" || state.phase === "degraded")
|
|
215
277
|
return Effect.fail(safeError("not_ready", true));
|
|
@@ -223,6 +285,8 @@ export function makeProjectKeyFerryManager(options, processLayer = ProcessLive,
|
|
|
223
285
|
// released, rather than being released at the end of ensureStarted.
|
|
224
286
|
const managerScope = yield* Scope.Scope;
|
|
225
287
|
const config = adapterOptions(options);
|
|
288
|
+
// Stale cleanup is bounded, idempotent, and never gates MCP startup.
|
|
289
|
+
yield* Effect.promise(() => reapStaleOwnershipRecords(config.command, ownershipFileOps(config.ownershipDirectory ?? ownershipDirectory()))).pipe(Effect.forkDetach, Effect.asVoid);
|
|
226
290
|
const requestedCwd = safeProjectDirectory(options.projectDirectory);
|
|
227
291
|
const cwd = yield* Effect.tryPromise({
|
|
228
292
|
try: async () => {
|
|
@@ -244,25 +308,136 @@ export function makeProjectKeyFerryManager(options, processLayer = ProcessLive,
|
|
|
244
308
|
let certDirectoryReal;
|
|
245
309
|
const ownedCertDirectories = new Set();
|
|
246
310
|
const lock = yield* Semaphore.make(1);
|
|
247
|
-
const state = {
|
|
311
|
+
const state = {
|
|
312
|
+
phase: "stopped",
|
|
313
|
+
generation: 0,
|
|
314
|
+
disposed: false,
|
|
315
|
+
};
|
|
316
|
+
let ownershipHash = createHash("sha256").update(cwd).digest("hex");
|
|
317
|
+
const processStartTime = (pid, swallow = false) => processService.startTime
|
|
318
|
+
? Effect.runPromise(processService.startTime(pid)).catch((error) => {
|
|
319
|
+
if (swallow)
|
|
320
|
+
return undefined;
|
|
321
|
+
throw error;
|
|
322
|
+
})
|
|
323
|
+
: Promise.resolve(undefined);
|
|
324
|
+
const ownershipRecord = (session, pid, _command) => Effect.tryPromise({
|
|
325
|
+
try: async () => {
|
|
326
|
+
const starterStartTime = await processStartTime(pid);
|
|
327
|
+
await writeOwnershipRecord({
|
|
328
|
+
brokerKeyHash: (ownershipHash = createHash("sha256")
|
|
329
|
+
.update(`${cwd}\0${session.uuid}`)
|
|
330
|
+
.digest("hex")),
|
|
331
|
+
version: 2,
|
|
332
|
+
sessionUuid: session.uuid,
|
|
333
|
+
starterPid: pid,
|
|
334
|
+
...(starterStartTime ? { starterStartTime } : {}),
|
|
335
|
+
cwd,
|
|
336
|
+
created: new Date().toISOString(),
|
|
337
|
+
}, ownershipFileOps(config.ownershipDirectory ?? ownershipDirectory()));
|
|
338
|
+
const proxyStartTime = await processStartTime(session.ownerPid, true);
|
|
339
|
+
await appendProxyLedgerEntry({
|
|
340
|
+
version: 1,
|
|
341
|
+
sessionUuid: session.uuid,
|
|
342
|
+
proxyPid: session.ownerPid,
|
|
343
|
+
...(proxyStartTime ? { proxyStartTime } : {}),
|
|
344
|
+
ownerPid: pid,
|
|
345
|
+
...(starterStartTime ? { ownerStartTime: starterStartTime } : {}),
|
|
346
|
+
cwd,
|
|
347
|
+
created: new Date().toISOString(),
|
|
348
|
+
}, ownershipFileOps(config.ownershipDirectory ?? ownershipDirectory()));
|
|
349
|
+
},
|
|
350
|
+
catch: () => safeError("ownership_unavailable", false),
|
|
351
|
+
});
|
|
248
352
|
const clearCapability = (phase, error) => {
|
|
249
353
|
state.phase = phase;
|
|
250
354
|
state.environment = undefined;
|
|
251
|
-
state.session = undefined;
|
|
252
355
|
state.error = error;
|
|
253
356
|
state.generation += 1;
|
|
254
357
|
};
|
|
255
|
-
|
|
256
|
-
|
|
358
|
+
let lastHealthCheckAt = 0;
|
|
359
|
+
let healthCheckInFlight;
|
|
360
|
+
const healthCheck = (force = false) => {
|
|
361
|
+
if (!state.process || !state.session)
|
|
257
362
|
return Effect.void;
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
clearCapability("degraded", safeError("unavailable", true));
|
|
363
|
+
if (healthCheckInFlight)
|
|
364
|
+
return healthCheckInFlight;
|
|
365
|
+
if (!healthCheckDue(force, lastHealthCheckAt, config.healthCheckIntervalMs))
|
|
262
366
|
return Effect.void;
|
|
263
|
-
|
|
367
|
+
lastHealthCheckAt = Date.now();
|
|
368
|
+
const old = state.session;
|
|
369
|
+
const oldHandle = state.process;
|
|
370
|
+
const check = runCommand(processService, config, cwd, ["proxy", "status", "--format", "json"], config.commandTimeoutMs, ambientEnvironment).pipe(
|
|
371
|
+
// A failed status query is unknown, not evidence that the proxy died.
|
|
372
|
+
Effect.flatMap(({ stdout }) => Effect.try({
|
|
373
|
+
try: () => decodeSessionList(stdout),
|
|
374
|
+
catch: () => undefined,
|
|
375
|
+
})), Effect.flatMap((records) => {
|
|
376
|
+
if (!records)
|
|
377
|
+
return Effect.void;
|
|
378
|
+
return oldHandle.health.pipe(Effect.flatMap((health) => {
|
|
379
|
+
const failedStarter = !health.running &&
|
|
380
|
+
health.exit !== undefined &&
|
|
381
|
+
(health.exit.code !== 0 || health.exit.signal !== null);
|
|
382
|
+
const active = records.some((item) => item.uuid === old.uuid && item.endedAt === undefined);
|
|
383
|
+
if (active && !failedStarter)
|
|
384
|
+
return Effect.void;
|
|
385
|
+
return stopCapturedSession(old).pipe(Effect.tap((stopped) => oldHandle.stop.pipe(Effect.catch(() => Effect.void)).pipe(Effect.andThen(Effect.sync(() => {
|
|
386
|
+
if (stopped) {
|
|
387
|
+
state.process = undefined;
|
|
388
|
+
clearCapability("degraded", safeError("unavailable", true));
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
state.phase = "degraded";
|
|
392
|
+
state.error = safeError("unavailable", true);
|
|
393
|
+
}
|
|
394
|
+
})))), Effect.asVoid);
|
|
395
|
+
}));
|
|
396
|
+
}), Effect.catch(() => Effect.void));
|
|
397
|
+
healthCheckInFlight = check.pipe(Effect.ensuring(Effect.sync(() => (healthCheckInFlight = undefined))));
|
|
398
|
+
return healthCheckInFlight;
|
|
264
399
|
};
|
|
265
400
|
const status = Effect.suspend(() => healthCheck()).pipe(Effect.asVoid, Effect.map(() => statusSnapshot(state, cwd)));
|
|
401
|
+
// `proxy start` is a daemon command: the CLI process we spawn may hand
|
|
402
|
+
// the listener to a longer-lived session process. Always use Varlock's
|
|
403
|
+
// session protocol first, then reap our owned process group as a bounded
|
|
404
|
+
// fallback. The command is deliberately best-effort during teardown.
|
|
405
|
+
const stopProxy = (session) => session
|
|
406
|
+
? runCommand(processService, config, cwd, ["proxy", "stop", "--session", session.uuid], config.commandTimeoutMs, ambientEnvironment).pipe(Effect.flatMap(() => {
|
|
407
|
+
const verify = (attempt) => runCommand(processService, config, cwd, ["proxy", "status", "--format", "json"], config.commandTimeoutMs, ambientEnvironment).pipe(Effect.flatMap(({ stdout }) => {
|
|
408
|
+
try {
|
|
409
|
+
if (!decodeSessionList(stdout).some((item) => item.uuid === session.uuid && item.endedAt === undefined))
|
|
410
|
+
return Effect.succeed(true);
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
// A malformed/failed status is inconclusive.
|
|
414
|
+
}
|
|
415
|
+
return attempt < 3
|
|
416
|
+
? Effect.sleep(Duration.millis(config.pollIntervalMs)).pipe(Effect.andThen(verify(attempt + 1)))
|
|
417
|
+
: Effect.succeed(false);
|
|
418
|
+
}), Effect.catch(() => Effect.succeed(false)));
|
|
419
|
+
return verify(1);
|
|
420
|
+
}), Effect.catch(() => Effect.sync(() => {
|
|
421
|
+
config.lifecycleLogger?.({
|
|
422
|
+
event: "stop-failed",
|
|
423
|
+
proxySessionId: session.uuid,
|
|
424
|
+
});
|
|
425
|
+
return false;
|
|
426
|
+
})))
|
|
427
|
+
: Effect.succeed(true);
|
|
428
|
+
const stopCapturedSession = (session = state.session) => stopProxy(session).pipe(Effect.tap((stopped) => stopped
|
|
429
|
+
? Effect.tryPromise({
|
|
430
|
+
try: () => removeOwnershipRecord(ownershipHash, ownershipFileOps(config.ownershipDirectory ?? ownershipDirectory())),
|
|
431
|
+
catch: () => undefined,
|
|
432
|
+
}).pipe(Effect.catch(() => Effect.void), Effect.andThen(Effect.sync(() => {
|
|
433
|
+
if (state.session?.uuid === session?.uuid)
|
|
434
|
+
state.session = undefined;
|
|
435
|
+
})))
|
|
436
|
+
: Effect.void));
|
|
437
|
+
const cleanupUncapturedSessions = (handle) => runCommand(processService, config, cwd, ["proxy", "status", "--format", "json"], config.commandTimeoutMs, ambientEnvironment).pipe(Effect.flatMap(({ stdout }) => Effect.try({
|
|
438
|
+
try: () => decodeSessionList(stdout),
|
|
439
|
+
catch: () => [],
|
|
440
|
+
})), Effect.flatMap((records) => Effect.forEach(records.filter((record) => record.ownerPid === handle.pid && record.cwd === cwd), (record) => stopProxy(record), { discard: true })), Effect.catch(() => Effect.void));
|
|
266
441
|
const waitForSession = (handle) => {
|
|
267
442
|
const deadline = Date.now() + config.startupTimeoutMs;
|
|
268
443
|
const poll = () => Effect.gen(function* () {
|
|
@@ -338,10 +513,11 @@ export function makeProjectKeyFerryManager(options, processLayer = ProcessLive,
|
|
|
338
513
|
return { directory, canonical };
|
|
339
514
|
}));
|
|
340
515
|
const interruptedStartCleanup = (handle, directory) => Effect.uninterruptible(Effect.gen(function* () {
|
|
341
|
-
|
|
516
|
+
yield* stopCapturedSession();
|
|
517
|
+
if (handle) {
|
|
342
518
|
yield* handle.stop.pipe(Effect.catch(() => Effect.void));
|
|
519
|
+
}
|
|
343
520
|
state.process = undefined;
|
|
344
|
-
state.session = undefined;
|
|
345
521
|
state.environment = undefined;
|
|
346
522
|
clearCapability("failed", safeError("timeout", true));
|
|
347
523
|
if (directory) {
|
|
@@ -352,6 +528,28 @@ export function makeProjectKeyFerryManager(options, processLayer = ProcessLive,
|
|
|
352
528
|
yield* removeOwnedDirectory(directory);
|
|
353
529
|
}
|
|
354
530
|
}));
|
|
531
|
+
const recoverStartFailure = (error, cleanupDirectory) => Effect.gen(function* () {
|
|
532
|
+
if (error.code !== "disposed")
|
|
533
|
+
updateFailure(error);
|
|
534
|
+
yield* cleanupFailedStart(cleanupDirectory);
|
|
535
|
+
const failure = error;
|
|
536
|
+
return yield* Effect.fail(failure);
|
|
537
|
+
});
|
|
538
|
+
const updateFailure = (failure) => {
|
|
539
|
+
if (state.phase === "failed" && state.session)
|
|
540
|
+
state.error = failure;
|
|
541
|
+
else
|
|
542
|
+
clearCapability("failed", failure);
|
|
543
|
+
};
|
|
544
|
+
const cleanupFailedStart = (directory) => directory && !state.session
|
|
545
|
+
? Effect.gen(function* () {
|
|
546
|
+
if (certDirectory === directory) {
|
|
547
|
+
certDirectory = undefined;
|
|
548
|
+
certDirectoryReal = undefined;
|
|
549
|
+
}
|
|
550
|
+
yield* removeOwnedDirectory(directory);
|
|
551
|
+
})
|
|
552
|
+
: Effect.void;
|
|
355
553
|
const startInternal = () => {
|
|
356
554
|
let acquired;
|
|
357
555
|
let cleanupDirectory;
|
|
@@ -376,26 +574,52 @@ export function makeProjectKeyFerryManager(options, processLayer = ProcessLive,
|
|
|
376
574
|
certDirectory,
|
|
377
575
|
]);
|
|
378
576
|
const handle = yield* Effect.provideService(processService
|
|
379
|
-
.spawn({
|
|
577
|
+
.spawn({
|
|
578
|
+
...parts,
|
|
579
|
+
cwd,
|
|
580
|
+
// Varlock versions that expose inherited metadata in
|
|
581
|
+
// `proxy status` can use this to identify recordless
|
|
582
|
+
// proxies. Older versions simply ignore the variable.
|
|
583
|
+
env: { ...ambientEnvironment, KEY_FERRY_PROXY_MARKER: "key-ferry" },
|
|
584
|
+
terminateGraceMs: 500,
|
|
585
|
+
})
|
|
380
586
|
.pipe(Effect.mapError((error) => fromProcessError(error, "start_failed"))), Scope.Scope, managerScope);
|
|
381
587
|
acquired = handle;
|
|
382
588
|
state.process = handle;
|
|
383
|
-
|
|
589
|
+
let startedSession;
|
|
590
|
+
const result = yield* waitForSession(handle).pipe(Effect.flatMap((session) => Effect.gen(function* () {
|
|
591
|
+
startedSession = session;
|
|
592
|
+
// Capture is the ownership boundary: publish before any
|
|
593
|
+
// environment or policy work can fail.
|
|
594
|
+
state.session = session;
|
|
595
|
+
yield* ownershipRecord(session, handle.pid, [config.command[0], ...parts.args]);
|
|
596
|
+
const environment = yield* readEnvironment(session);
|
|
597
|
+
yield* validateStartupEgress(state, config.requireStrictEgress, session, handle);
|
|
598
|
+
return { session, environment };
|
|
599
|
+
})), Effect.catch((error) => Effect.gen(function* () {
|
|
600
|
+
const captured = startedSession;
|
|
601
|
+
let stopped = true;
|
|
602
|
+
if (captured) {
|
|
603
|
+
stopped = yield* stopCapturedSession(captured);
|
|
604
|
+
}
|
|
605
|
+
else
|
|
606
|
+
yield* cleanupUncapturedSessions(handle);
|
|
384
607
|
yield* handle.stop.pipe(Effect.catch(() => Effect.void));
|
|
385
|
-
|
|
608
|
+
if (stopped) {
|
|
609
|
+
state.process = undefined;
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
state.phase = "failed";
|
|
613
|
+
state.error = error;
|
|
614
|
+
}
|
|
386
615
|
return yield* Effect.fail(error);
|
|
387
616
|
})));
|
|
388
|
-
yield* validateStartupEgress(state, config.requireStrictEgress, result.session, handle);
|
|
389
617
|
state.session = result.session;
|
|
390
618
|
state.environment = result.environment;
|
|
391
619
|
state.phase = "ready";
|
|
392
620
|
state.error = undefined;
|
|
393
621
|
return statusSnapshot(state, cwd);
|
|
394
|
-
}).pipe(Effect.catch((error) => Effect.
|
|
395
|
-
if (error.code !== "disposed")
|
|
396
|
-
clearCapability("failed", error);
|
|
397
|
-
return yield* Effect.fail(error);
|
|
398
|
-
})), Effect.onInterrupt(() => interruptedStartCleanup(acquired, cleanupDirectory)));
|
|
622
|
+
}).pipe(Effect.catch((error) => recoverStartFailure(error, cleanupDirectory)), Effect.onInterrupt(() => interruptedStartCleanup(acquired, cleanupDirectory)));
|
|
399
623
|
});
|
|
400
624
|
// Do not attach ownership cleanup until preflight has completed: a
|
|
401
625
|
// cancelled health check must not tear down a process owned by an
|
|
@@ -414,7 +638,10 @@ export function makeProjectKeyFerryManager(options, processLayer = ProcessLive,
|
|
|
414
638
|
}
|
|
415
639
|
if (!state.environment)
|
|
416
640
|
return yield* Effect.fail(safeError("not_ready", true));
|
|
417
|
-
return {
|
|
641
|
+
return {
|
|
642
|
+
status: statusSnapshot(state, cwd),
|
|
643
|
+
environment: state.environment,
|
|
644
|
+
};
|
|
418
645
|
}));
|
|
419
646
|
const environmentEffect = exclusive(Effect.gen(function* () {
|
|
420
647
|
yield* healthCheck();
|
|
@@ -427,7 +654,11 @@ export function makeProjectKeyFerryManager(options, processLayer = ProcessLive,
|
|
|
427
654
|
return state.environment;
|
|
428
655
|
}));
|
|
429
656
|
const refreshInternal = Effect.gen(function* () {
|
|
430
|
-
yield* ensureRefreshReadiness({
|
|
657
|
+
yield* ensureRefreshReadiness({
|
|
658
|
+
state,
|
|
659
|
+
healthCheck,
|
|
660
|
+
start: startInternal,
|
|
661
|
+
});
|
|
431
662
|
const session = yield* requireRefreshSession(state);
|
|
432
663
|
const reload = runCommand(processService, config, cwd, ["proxy", "reload", "--session", session.uuid], config.reloadTimeoutMs, ambientEnvironment).pipe(Effect.mapError((error) => new VarlockError({
|
|
433
664
|
...error,
|
|
@@ -449,8 +680,10 @@ export function makeProjectKeyFerryManager(options, processLayer = ProcessLive,
|
|
|
449
680
|
}).pipe(Effect.catch((error) => Effect.gen(function* () {
|
|
450
681
|
if (error.code !== "disposed") {
|
|
451
682
|
const handle = state.process;
|
|
452
|
-
|
|
683
|
+
yield* stopCapturedSession();
|
|
684
|
+
if (handle) {
|
|
453
685
|
yield* handle.stop.pipe(Effect.catch(() => Effect.void));
|
|
686
|
+
}
|
|
454
687
|
state.process = undefined;
|
|
455
688
|
clearCapability("failed", error.code === "refresh_failed" ? error : safeError("refresh_failed", true));
|
|
456
689
|
}
|
|
@@ -465,15 +698,18 @@ export function makeProjectKeyFerryManager(options, processLayer = ProcessLive,
|
|
|
465
698
|
return { status, environment: state.environment };
|
|
466
699
|
}));
|
|
467
700
|
const dispose = lock.withPermit(Effect.gen(function* () {
|
|
468
|
-
if (state.disposed)
|
|
701
|
+
if (state.disposed && !state.session)
|
|
469
702
|
return;
|
|
470
703
|
state.disposed = true;
|
|
471
704
|
state.phase = "stopping";
|
|
472
705
|
const handle = state.process;
|
|
706
|
+
const stopped = yield* stopCapturedSession();
|
|
707
|
+
if (!stopped)
|
|
708
|
+
return;
|
|
473
709
|
state.environment = undefined;
|
|
474
|
-
|
|
475
|
-
if (handle)
|
|
710
|
+
if (handle) {
|
|
476
711
|
yield* handle.stop.pipe(Effect.catch(() => Effect.void));
|
|
712
|
+
}
|
|
477
713
|
state.process = undefined;
|
|
478
714
|
state.phase = "stopped";
|
|
479
715
|
state.generation += 1;
|
|
@@ -488,7 +724,9 @@ export function makeProjectKeyFerryManager(options, processLayer = ProcessLive,
|
|
|
488
724
|
dispose,
|
|
489
725
|
withExclusive: (effect) => lock.withPermit(effect),
|
|
490
726
|
};
|
|
491
|
-
yield* Effect.addFinalizer(() => Effect.forEach([...ownedCertDirectories], removeOwnedDirectory, {
|
|
727
|
+
yield* Effect.addFinalizer(() => Effect.forEach([...ownedCertDirectories], removeOwnedDirectory, {
|
|
728
|
+
discard: true,
|
|
729
|
+
}));
|
|
492
730
|
// Finalizers run in reverse registration order, so stop the sidecar
|
|
493
731
|
// before removing the directory that contains its CA.
|
|
494
732
|
yield* Effect.addFinalizer(() => dispose);
|