@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.
Files changed (47) hide show
  1. package/README.md +20 -0
  2. package/dist/broker-registry.d.ts +26 -0
  3. package/dist/broker-registry.d.ts.map +1 -1
  4. package/dist/broker-registry.js +253 -54
  5. package/dist/broker-registry.js.map +1 -1
  6. package/dist/contracts.d.ts +7 -3
  7. package/dist/contracts.d.ts.map +1 -1
  8. package/dist/contracts.js +1 -0
  9. package/dist/contracts.js.map +1 -1
  10. package/dist/options.d.ts +1 -0
  11. package/dist/options.d.ts.map +1 -1
  12. package/dist/options.js +17 -0
  13. package/dist/options.js.map +1 -1
  14. package/dist/plugin.d.ts.map +1 -1
  15. package/dist/plugin.js +13 -4
  16. package/dist/plugin.js.map +1 -1
  17. package/dist/relay/index.d.ts +2 -0
  18. package/dist/relay/index.d.ts.map +1 -1
  19. package/dist/relay/index.js +150 -110
  20. package/dist/relay/index.js.map +1 -1
  21. package/dist/remote.d.ts +12 -1
  22. package/dist/remote.d.ts.map +1 -1
  23. package/dist/remote.js +114 -76
  24. package/dist/remote.js.map +1 -1
  25. package/dist/rpc.d.ts +22 -12
  26. package/dist/rpc.d.ts.map +1 -1
  27. package/dist/rpc.js +27 -27
  28. package/dist/rpc.js.map +1 -1
  29. package/dist/scoped-controller.d.ts +1 -1
  30. package/dist/scoped-controller.d.ts.map +1 -1
  31. package/dist/scoped-controller.js +37 -7
  32. package/dist/scoped-controller.js.map +1 -1
  33. package/dist/scopes/controller-helpers.d.ts.map +1 -1
  34. package/dist/scopes/controller-helpers.js +5 -0
  35. package/dist/scopes/controller-helpers.js.map +1 -1
  36. package/dist/services/process.d.ts +2 -0
  37. package/dist/services/process.d.ts.map +1 -1
  38. package/dist/services/process.js +13 -2
  39. package/dist/services/process.js.map +1 -1
  40. package/dist/varlock/manager.d.ts.map +1 -1
  41. package/dist/varlock/manager.js +312 -74
  42. package/dist/varlock/manager.js.map +1 -1
  43. package/dist/varlock/ownership.d.ts +88 -0
  44. package/dist/varlock/ownership.d.ts.map +1 -0
  45. package/dist/varlock/ownership.js +574 -0
  46. package/dist/varlock/ownership.js.map +1 -0
  47. package/package.json +1 -1
@@ -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({ code, retryable, message: `Varlock ${code.replaceAll("_", " ")}` });
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
- if (!Array.isArray(command) ||
35
- command.length === 0 ||
36
- command.length > 32 ||
37
- command.some((v) => typeof v !== "string" || v.length === 0 || v.length > 1024 || v.includes("\0"))) {
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
- if (!Array.isArray(environmentKeys) ||
41
- environmentKeys.length > 128 ||
42
- environmentKeys.some((v) => typeof v !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(v) || v.length > 256)) {
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
- const numberOption = (name, fallback, min, max) => {
46
- const value = raw[name];
47
- if (value === undefined)
48
- return fallback;
49
- if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max)
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
- command: [...command],
58
- environmentKeys: [...environmentKeys],
59
- startupTimeoutMs: numberOption("startupTimeoutMs", DEFAULT_START_TIMEOUT, 100, MAX_TIMEOUT),
60
- commandTimeoutMs: numberOption("commandTimeoutMs", DEFAULT_COMMAND_TIMEOUT, 100, MAX_TIMEOUT),
61
- reloadTimeoutMs: numberOption("reloadTimeoutMs", DEFAULT_REFRESH_TIMEOUT, 100, MAX_TIMEOUT),
62
- pollIntervalMs: numberOption("pollIntervalMs", DEFAULT_POLL_INTERVAL, 10, 10_000),
63
- requireStrictEgress: strict ?? true,
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
- ...(endpoint ? { endpoint } : {}),
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 { executable: options.command[0], args: [...options.command.slice(1), ...args] };
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({ ...parts, cwd, env: environment, maxBytes: MAX_OUTPUT_BYTES, timeoutMs })
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.map(() => (state.phase === "ready" ? snapshot() : undefined)));
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, handle) {
252
+ function validateStartupEgress(state, requireStrictEgress, session, _handle) {
191
253
  if (!requireStrictEgress || session.egressMode === "strict")
192
254
  return Effect.void;
193
- const error = safeError("invalid_config", false);
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 = { phase: "stopped", generation: 0, disposed: false };
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
- const healthCheck = () => {
256
- if (!state.process)
358
+ let lastHealthCheckAt = 0;
359
+ let healthCheckInFlight;
360
+ const healthCheck = (force = false) => {
361
+ if (!state.process || !state.session)
257
362
  return Effect.void;
258
- return state.process.health.pipe(Effect.flatMap((health) => {
259
- if (health.running)
260
- return Effect.void;
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
- if (handle)
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({ ...parts, cwd, env: ambientEnvironment, terminateGraceMs: 500 })
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
- const result = yield* waitForSession(handle).pipe(Effect.flatMap((session) => readEnvironment(session).pipe(Effect.map((environment) => ({ session, environment })))), Effect.catch((error) => Effect.gen(function* () {
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
- state.process = undefined;
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.gen(function* () {
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 { status: statusSnapshot(state, cwd), environment: state.environment };
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({ state, healthCheck, start: startInternal });
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
- if (handle)
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
- state.session = undefined;
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, { discard: true }));
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);