@blogic-cz/agent-tools 0.14.62 → 0.15.2

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.
@@ -1,137 +1,89 @@
1
- // Synchronous node:fs calls keep the cross-process lock/lease critical section atomic;
2
- // Bun does not provide equivalent synchronous directory primitives for this use case.
3
- import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { fileURLToPath } from "node:url";
4
2
 
5
- import { Clock, Duration, Effect, Result } from "effect";
6
- import { ChildProcess } from "effect/unstable/process";
3
+ import { Duration, Effect, Option, Result } from "effect";
7
4
 
8
- import type { AgentToolsConfig, ProfilePrerequisites } from "#config/types";
5
+ import type { AgentToolsConfig, ProfilePrerequisites, VpnConfig } from "#config/types";
9
6
  import type {
10
7
  PrerequisiteCommandRunner,
11
8
  ResolvedVpnDriver,
12
9
  VpnCleanupPolicy,
13
- VpnLease,
14
- VpnLeaseHandle,
15
- VpnLockOwner,
16
- VpnStartState,
17
10
  } from "#shared/prerequisites/types";
18
11
 
19
- import { joinPath } from "#shared/path";
12
+ import {
13
+ makeParentVpnCommand,
14
+ parseVpnStatus,
15
+ sanitizeVpnDriver,
16
+ } from "#shared/prerequisites/driver-commands";
17
+ import type { GuardianInitMessage, GuardianOutboundMessage } from "#shared/prerequisites/guardian";
20
18
  import { normalizeProfilePrerequisites } from "#shared/prerequisites/config";
21
19
  import { PrerequisiteRunError } from "#shared/prerequisites/errors";
20
+ import type { SanitizedVpnDriver, VpnStore as VpnStoreType } from "#shared/prerequisites/store";
22
21
  import { missingVpnToolHint, resolveVpnDriverConfig } from "#shared/prerequisites/vpn";
23
22
 
24
- const readEnv = (name: string) => Bun.env[name];
23
+ export const DEFAULT_VPN_IDLE_DISCONNECT_MS = 30_000;
24
+ const DEFAULT_CONNECT_TIMEOUT_MS = 30_000;
25
+ const DEFAULT_DISCONNECT_TIMEOUT_MS = 10_000;
26
+ const COORDINATION_POLL_MS = 25;
27
+ const GUARDIAN_HANDOFF_TIMEOUT_MS = 5_000;
28
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
25
29
 
26
- const DEFAULT_LEASE_TTL_MS = 10 * 60 * 1000;
27
- const LOCK_STALE_MS = 30_000;
28
- const LOCK_RETRY_MS = 25;
29
- const LOCK_TIMEOUT_BUFFER_MS = 5_000;
30
+ type TimeoutScheduler = (callback: () => void, delayMs: number) => () => void;
30
31
 
31
- const getRuntimeRoot = () =>
32
- readEnv("AGENT_TOOLS_RUNTIME_DIR") ??
33
- joinPath(readEnv("TMPDIR") ?? readEnv("TEMP") ?? readEnv("TMP") ?? "/tmp", "agent-tools");
34
-
35
- const getDriverIdentity = (driver: ResolvedVpnDriver) => {
36
- if (driver.type === "macos-scutil") {
37
- return { type: driver.type, platform: driver.platform, serviceName: driver.serviceName };
38
- }
39
-
40
- if (driver.type === "linux-nmcli") {
41
- return { type: driver.type, platform: driver.platform, connectionName: driver.connectionName };
42
- }
43
-
44
- return { type: driver.type, platform: driver.platform, entryName: driver.entryName };
45
- };
46
-
47
- const getDriverLeaseKey = (driver: ResolvedVpnDriver) =>
48
- Bun.hash(JSON.stringify(getDriverIdentity(driver))).toString(16);
49
-
50
- const makeLeaseHandle = (
51
- driver: ResolvedVpnDriver,
52
- ttlMs: number,
53
- lockTimeoutMs: number,
54
- ): VpnLeaseHandle => {
55
- const key = getDriverLeaseKey(driver);
56
- const directory = joinPath(getRuntimeRoot(), "vpn-prerequisites", key);
57
- return {
58
- directory,
59
- leasePath: joinPath(directory, `lease-${process.pid}.json`),
60
- statePath: joinPath(directory, "started.json"),
61
- lockPath: joinPath(directory, "lock"),
62
- ttlMs,
63
- lockTimeoutMs,
32
+ export const scheduleLongTimeout = (
33
+ callback: () => void,
34
+ timeoutMs: number,
35
+ now: () => number = Date.now,
36
+ schedule: TimeoutScheduler = (scheduled, delayMs) => {
37
+ const handle = setTimeout(scheduled, delayMs);
38
+ return () => clearTimeout(handle);
39
+ },
40
+ ) => {
41
+ const deadline = now() + timeoutMs;
42
+ let cancelled = false;
43
+ let cancelActive: (() => void) | undefined;
44
+ const scheduleNext = () => {
45
+ if (cancelled) return;
46
+ const remainingMs = deadline - now();
47
+ if (remainingMs <= 0) {
48
+ cancelled = true;
49
+ callback();
50
+ return;
51
+ }
52
+ cancelActive = schedule(scheduleNext, Math.min(remainingMs, MAX_TIMER_DELAY_MS));
53
+ };
54
+ scheduleNext();
55
+ return () => {
56
+ cancelled = true;
57
+ cancelActive?.();
64
58
  };
65
59
  };
66
60
 
67
- const getErrorMessage = (error: unknown) =>
68
- error instanceof Error ? error.message : String(error);
61
+ const noop = () => undefined;
62
+ const readEnv = (name: string) => process.env[name];
63
+ const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
69
64
 
70
- const hasErrorCode = (error: unknown, code: string) =>
71
- typeof error === "object" && error !== null && "code" in error && error.code === code;
65
+ export const vpnStartFailureMessage = (key: string, stderr: string, redactStderr: boolean) => {
66
+ const generic = `Failed to start VPN prerequisite "${key}".`;
67
+ return redactStderr ? generic : stderr.trim() || generic;
68
+ };
72
69
 
73
- const fsError = (message: string, error: unknown) =>
70
+ export const missingVpnSecretError = (key: string) =>
74
71
  new PrerequisiteRunError({
75
- message: `${message}: ${getErrorMessage(error)}`,
76
- hint: "Retry the command. If this repeats, remove stale files under the agent-tools runtime directory.",
72
+ message: `VPN prerequisite "${key}" requires configured credentials.`,
73
+ hint: "Set the configured VPN secret before retrying, or remove secretEnvVar from the VPN config.",
77
74
  });
78
75
 
79
- const syncFs = <A>(message: string, operation: () => A) =>
80
- Effect.try({
81
- try: operation,
82
- catch: (error) => fsError(message, error),
76
+ const coordinationError = (key: string, error: unknown) =>
77
+ new PrerequisiteRunError({
78
+ message: `Failed to coordinate VPN prerequisite "${key}": ${errorMessage(error)}`,
79
+ hint:
80
+ error instanceof Error && "hint" in error && typeof error.hint === "string"
81
+ ? error.hint
82
+ : "Retry after all agent-tools processes using this VPN have quiesced.",
83
83
  });
84
84
 
85
- const readJsonFile = (path: string): unknown | undefined => {
86
- try {
87
- const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
88
- return parsed;
89
- } catch (error) {
90
- void error;
91
- return undefined;
92
- }
93
- };
94
-
95
- type JsonObject = { readonly [key: string]: unknown };
96
-
97
- const isJsonObject = (value: unknown): value is JsonObject =>
98
- typeof value === "object" && value !== null;
99
-
100
- const isFiniteNumber = (value: unknown): value is number =>
101
- typeof value === "number" && Number.isFinite(value);
102
-
103
- const isVpnLease = (value: unknown): value is VpnLease =>
104
- isJsonObject(value) &&
105
- isFiniteNumber(value.pid) &&
106
- isFiniteNumber(value.createdAt) &&
107
- isFiniteNumber(value.updatedAt);
108
-
109
- const isVpnStartState = (value: unknown): value is VpnStartState =>
110
- isJsonObject(value) && isFiniteNumber(value.pid) && isFiniteNumber(value.startedAt);
111
-
112
- const isVpnLockOwner = (value: unknown): value is VpnLockOwner =>
113
- isJsonObject(value) && isFiniteNumber(value.pid) && isFiniteNumber(value.createdAt);
114
-
115
- const readVpnLease = (path: string) => {
116
- const parsed = readJsonFile(path);
117
- return isVpnLease(parsed) ? parsed : undefined;
118
- };
119
-
120
- const readVpnStartState = (path: string) => {
121
- const parsed = readJsonFile(path);
122
- return isVpnStartState(parsed) ? parsed : undefined;
123
- };
124
-
125
- const readVpnLockOwner = (path: string) => {
126
- const parsed = readJsonFile(path);
127
- return isVpnLockOwner(parsed) ? parsed : undefined;
128
- };
129
-
130
85
  const isPidLive = (pid: number) => {
131
- if (!Number.isInteger(pid) || pid <= 0) {
132
- return false;
133
- }
134
-
86
+ if (!Number.isInteger(pid) || pid <= 0) return false;
135
87
  try {
136
88
  process.kill(pid, 0);
137
89
  return true;
@@ -140,331 +92,648 @@ const isPidLive = (pid: number) => {
140
92
  }
141
93
  };
142
94
 
143
- const isLeaseLive = (lease: VpnLease | undefined, now: number, ttlMs: number) => {
144
- if (!lease) {
145
- return false;
146
- }
147
-
148
- if (isPidLive(lease.pid)) {
149
- return true;
150
- }
151
-
152
- return now - lease.updatedAt <= ttlMs;
95
+ const runStatus = <E>(driver: ResolvedVpnDriver, runCommand: PrerequisiteCommandRunner<E>) => {
96
+ const command = makeParentVpnCommand(driver, "status");
97
+ return runCommand(command.command, command.label).pipe(
98
+ Effect.result,
99
+ Effect.map((result) =>
100
+ Result.isSuccess(result)
101
+ ? parseVpnStatus(sanitizeVpnDriver(driver), result.success)
102
+ : undefined,
103
+ ),
104
+ );
153
105
  };
154
106
 
155
- const pruneStaleLeases = (handle: VpnLeaseHandle, now: number) =>
156
- syncFs("Failed to prune VPN prerequisite lease files", () => {
157
- for (const entry of readdirSync(handle.directory, { withFileTypes: true })) {
158
- if (!entry.isFile() || !entry.name.startsWith("lease-") || !entry.name.endsWith(".json")) {
159
- continue;
160
- }
161
-
162
- const leasePath = joinPath(handle.directory, entry.name);
163
- const lease = readVpnLease(leasePath);
164
- if (!isLeaseLive(lease, now, handle.ttlMs)) {
165
- rmSync(leasePath, { force: true });
166
- }
167
- }
107
+ const runStatusBefore = <E>(
108
+ driver: ResolvedVpnDriver,
109
+ deadline: number,
110
+ runCommand: PrerequisiteCommandRunner<E>,
111
+ ) =>
112
+ Effect.suspend(() => {
113
+ const remainingMs = deadline - Date.now();
114
+ if (remainingMs <= 0) return Effect.succeed(undefined);
115
+ return runStatus(driver, runCommand).pipe(
116
+ Effect.timeoutOption(Duration.millis(remainingMs)),
117
+ Effect.map(Option.getOrUndefined),
118
+ );
168
119
  });
169
120
 
170
- const hasOtherLiveLeases = (handle: VpnLeaseHandle, now: number) =>
121
+ const waitForConnected = <E>(
122
+ driver: ResolvedVpnDriver,
123
+ deadline: number,
124
+ runCommand: PrerequisiteCommandRunner<E>,
125
+ ) =>
171
126
  Effect.gen(function* () {
172
- yield* pruneStaleLeases(handle, now);
173
-
174
- return yield* syncFs("Failed to inspect VPN prerequisite lease files", () => {
175
- for (const entry of readdirSync(handle.directory, { withFileTypes: true })) {
176
- if (!entry.isFile() || !entry.name.startsWith("lease-") || !entry.name.endsWith(".json")) {
177
- continue;
178
- }
179
-
180
- const leasePath = joinPath(handle.directory, entry.name);
181
- if (leasePath === handle.leasePath) {
182
- continue;
183
- }
184
-
185
- const lease = readVpnLease(leasePath);
186
- if (isLeaseLive(lease, now, handle.ttlMs)) {
187
- return true;
188
- }
189
- }
190
-
191
- return false;
192
- });
193
- });
194
-
195
- const writeLease = (handle: VpnLeaseHandle, now: number) =>
196
- syncFs("Failed to write VPN prerequisite lease", () => {
197
- mkdirSync(handle.directory, { recursive: true });
198
- const existingLease = readVpnLease(handle.leasePath);
199
- const lease: VpnLease = {
200
- pid: process.pid,
201
- createdAt: existingLease?.createdAt ?? now,
202
- updatedAt: now,
203
- };
204
- writeFileSync(handle.leasePath, JSON.stringify(lease));
127
+ let last: boolean | undefined;
128
+ while (true) {
129
+ last = yield* runStatusBefore(driver, deadline, runCommand);
130
+ if (last === true) return true as const;
131
+ const remainingMs = deadline - Date.now();
132
+ if (remainingMs <= 0) return last;
133
+ yield* Effect.sleep(Duration.millis(Math.min(250, remainingMs)));
134
+ }
205
135
  });
206
136
 
207
- const writeStartState = (handle: VpnLeaseHandle, now: number) =>
208
- syncFs("Failed to write VPN prerequisite start state", () => {
209
- const state: VpnStartState = { pid: process.pid, startedAt: now };
210
- writeFileSync(handle.statePath, JSON.stringify(state));
211
- });
137
+ type GuardianHandle = {
138
+ readonly leaseId: string;
139
+ readonly stableGuardianId: () => Promise<string>;
140
+ readonly release: () => Promise<void>;
141
+ };
212
142
 
213
- const readStartState = (handle: VpnLeaseHandle) =>
214
- syncFs("Failed to read VPN prerequisite start state", () => readVpnStartState(handle.statePath));
143
+ type GuardianSpawner = typeof Bun.spawn;
215
144
 
216
- const removeOwnLease = (handle: VpnLeaseHandle) =>
217
- syncFs("Failed to remove VPN prerequisite lease", () => {
218
- rmSync(handle.leasePath, { force: true });
219
- });
145
+ const safeGuardianEnvironment = (excludedName?: string) =>
146
+ Object.fromEntries(
147
+ ["PATH", "HOME", "TMPDIR", "TEMP", "TMP", "SYSTEMROOT", "WINDIR"].flatMap((name) => {
148
+ const value = process.env[name];
149
+ return value === undefined || name === excludedName ? [] : [[name, value]];
150
+ }),
151
+ );
220
152
 
221
- const removeStartState = (handle: VpnLeaseHandle) =>
222
- syncFs("Failed to remove VPN prerequisite start state", () => {
223
- rmSync(handle.statePath, { force: true });
224
- });
153
+ const guardianInit = (
154
+ driver: SanitizedVpnDriver,
155
+ runtimeRoot: string,
156
+ config: VpnConfig,
157
+ cleanup: VpnCleanupPolicy,
158
+ leaseId: string,
159
+ guardianId: string,
160
+ ): GuardianInitMessage => {
161
+ return {
162
+ type: "INIT",
163
+ driver,
164
+ runtimeRoot,
165
+ leaseId,
166
+ guardianId,
167
+ ownerPid: process.pid,
168
+ cleanup,
169
+ idleDisconnectMs: config.idleDisconnectMs ?? DEFAULT_VPN_IDLE_DISCONNECT_MS,
170
+ disconnectTimeoutMs: config.disconnectTimeoutMs ?? DEFAULT_DISCONNECT_TIMEOUT_MS,
171
+ };
172
+ };
225
173
 
226
- const getLockDirectoryAgeMs = (handle: VpnLeaseHandle, now: number) =>
227
- syncFs("Failed to inspect VPN prerequisite lease lock", () => {
228
- let stats: ReturnType<typeof statSync>;
174
+ const spawnDetachedGuardian = async (
175
+ driver: ResolvedVpnDriver,
176
+ config: VpnConfig,
177
+ cleanup: VpnCleanupPolicy,
178
+ spawn: GuardianSpawner = Bun.spawn,
179
+ ): Promise<GuardianHandle> => {
180
+ type Generation = {
181
+ readonly guardianId: string;
182
+ readonly child: ReturnType<typeof Bun.spawn>;
183
+ };
184
+ type Phase = "starting" | "ready" | "replacing" | "releasing" | "released" | "failed";
185
+
186
+ const { getVpnStoreLocation, VpnStore } = await import("#shared/prerequisites/store");
187
+ const sanitizedDriver = sanitizeVpnDriver(driver);
188
+ const runtimeRoot = getVpnStoreLocation(sanitizedDriver).root;
189
+ const leaseId = crypto.randomUUID();
190
+ let phase: Phase = "starting";
191
+ let active: Generation | undefined;
192
+ let candidate: Generation | undefined;
193
+ let replacements = 0;
194
+ let replacementFailure: Error | undefined;
195
+ let replacementInFlight: Promise<void> | undefined;
196
+ let failedCleanup: Promise<void> | undefined;
197
+ let releaseResolve: (() => void) | undefined;
198
+ let releaseReject: ((error: Error) => void) | undefined;
199
+ let releaseInFlight: Promise<void> | undefined;
200
+
201
+ const cleanupLease = () => {
202
+ let store: VpnStoreType | undefined;
229
203
  try {
230
- stats = statSync(handle.lockPath);
231
- } catch (error) {
232
- if (hasErrorCode(error, "ENOENT")) {
233
- return undefined;
204
+ store = VpnStore.open(sanitizedDriver, { root: runtimeRoot });
205
+ store.abandonLease(leaseId, Date.now());
206
+ } catch {
207
+ noop();
208
+ } finally {
209
+ try {
210
+ store?.close();
211
+ } catch {
212
+ noop();
234
213
  }
214
+ }
215
+ };
216
+ const cleanupFailedLease = () => (failedCleanup ??= Promise.resolve().then(cleanupLease));
217
+ const failReplacement = (error: unknown) => {
218
+ replacementFailure = error instanceof Error ? error : new Error(errorMessage(error));
219
+ phase = "failed";
220
+ releaseReject?.(replacementFailure);
221
+ };
235
222
 
223
+ const spawnGeneration = async (): Promise<Generation> => {
224
+ const guardianId = crypto.randomUUID();
225
+ let readyResolve!: () => void;
226
+ let readyReject!: (error: Error) => void;
227
+ const ready = new Promise<void>((resolve, reject) => {
228
+ readyResolve = resolve;
229
+ readyReject = reject;
230
+ });
231
+ const entry = fileURLToPath(new URL("./guardian-entry.ts", import.meta.url));
232
+ const child = spawn([process.execPath, entry], {
233
+ detached: true,
234
+ env: safeGuardianEnvironment(
235
+ driver.type === "macos-scutil" ? driver.secretEnvVar : undefined,
236
+ ),
237
+ stdin: "ignore",
238
+ stdout: "ignore",
239
+ stderr: "ignore",
240
+ ipc(message: GuardianOutboundMessage) {
241
+ if (
242
+ message.type === "READY" &&
243
+ message.leaseId === leaseId &&
244
+ message.guardianId === guardianId
245
+ ) {
246
+ readyResolve();
247
+ } else if (message.type === "RELEASED" && message.leaseId === leaseId) {
248
+ releaseResolve?.();
249
+ } else if (message.type === "ERROR") {
250
+ const error = new Error(message.message);
251
+ readyReject(error);
252
+ releaseReject?.(error);
253
+ }
254
+ },
255
+ });
256
+ const generation = { guardianId, child };
257
+ candidate = generation;
258
+ child.send(guardianInit(sanitizedDriver, runtimeRoot, config, cleanup, leaseId, guardianId));
259
+ const timeout = setTimeout(
260
+ () => readyReject(new Error("Timed out waiting for VPN lease guardian readiness.")),
261
+ GUARDIAN_HANDOFF_TIMEOUT_MS,
262
+ );
263
+ try {
264
+ await Promise.race([
265
+ ready,
266
+ child.exited.then((code) => {
267
+ throw new Error(`VPN lease guardian exited before readiness (${code}).`);
268
+ }),
269
+ ]);
270
+ } catch (error) {
271
+ if (candidate === generation) candidate = undefined;
272
+ child.kill();
273
+ await child.exited.catch(noop);
236
274
  throw error;
275
+ } finally {
276
+ clearTimeout(timeout);
237
277
  }
278
+ child.unref();
279
+ active = generation;
280
+ candidate = undefined;
281
+ return generation;
282
+ };
238
283
 
239
- return now - stats.mtimeMs;
240
- });
284
+ const observe = (generation: Generation) => {
285
+ void generation.child.exited.then(
286
+ (code) => {
287
+ if (active !== generation) return;
288
+ active = undefined;
289
+ if (phase !== "ready") return;
290
+ if (replacements >= 1) {
291
+ failReplacement(new Error(`VPN lease guardian exited (${code}).`));
292
+ return;
293
+ }
294
+ replacements += 1;
295
+ phase = "replacing";
296
+ replacementInFlight = spawnGeneration()
297
+ .then((replacement) => {
298
+ if (phase === "releasing") {
299
+ replacement.child.send({ type: "RELEASE", leaseId });
300
+ } else if (phase === "replacing") {
301
+ phase = "ready";
302
+ observe(replacement);
303
+ }
304
+ return undefined;
305
+ })
306
+ .catch((error) => {
307
+ failReplacement(error);
308
+ throw replacementFailure;
309
+ });
310
+ void replacementInFlight.catch(noop);
311
+ return undefined;
312
+ },
313
+ (error) => {
314
+ if (active === generation) active = undefined;
315
+ failReplacement(error);
316
+ return undefined;
317
+ },
318
+ );
319
+ };
241
320
 
242
- const isLockOwnerStale = (
243
- owner: VpnLockOwner | undefined,
244
- lockAgeMs: number,
245
- timeoutMs: number,
246
- ) => {
247
- const staleThresholdMs = Math.max(LOCK_STALE_MS, timeoutMs);
248
- if (!owner) {
249
- return lockAgeMs > staleThresholdMs;
321
+ let generation: Generation;
322
+ try {
323
+ generation = await spawnGeneration();
324
+ } catch (error) {
325
+ await cleanupLease();
326
+ replacements += 1;
327
+ try {
328
+ generation = await spawnGeneration();
329
+ } catch (replacementError) {
330
+ await cleanupLease();
331
+ throw replacementError instanceof Error
332
+ ? replacementError
333
+ : new Error(errorMessage(replacementError), { cause: error });
334
+ }
250
335
  }
336
+ phase = "ready";
337
+ observe(generation);
338
+
339
+ const stableGuardianId = async () => {
340
+ if (phase === "replacing") await replacementInFlight;
341
+ if (phase === "failed") {
342
+ await cleanupFailedLease();
343
+ throw replacementFailure ?? new Error("VPN lease guardian replacement failed.");
344
+ }
345
+ const current = active;
346
+ if (phase !== "ready" || !current) {
347
+ throw replacementFailure ?? new Error("VPN lease guardian is unavailable.");
348
+ }
349
+ if (current.child.exitCode !== null) {
350
+ await Promise.resolve();
351
+ return stableGuardianId();
352
+ }
353
+ return current.guardianId;
354
+ };
251
355
 
252
- return !isPidLive(owner.pid) && lockAgeMs > staleThresholdMs;
253
- };
254
-
255
- const acquireFileLock = (handle: VpnLeaseHandle) =>
256
- Effect.gen(function* () {
257
- yield* syncFs("Failed to create VPN prerequisite lease directory", () => {
258
- mkdirSync(handle.directory, { recursive: true });
259
- });
260
- const start = yield* Clock.currentTimeMillis;
261
-
262
- while (true) {
263
- const now = yield* Clock.currentTimeMillis;
264
- let lockError: unknown;
356
+ const release = (): Promise<void> => {
357
+ if (phase === "released") return Promise.resolve();
358
+ if (releaseInFlight) return releaseInFlight;
359
+ if (phase === "failed") {
360
+ return cleanupFailedLease().then(() => {
361
+ throw replacementFailure ?? new Error("VPN lease guardian replacement failed.");
362
+ });
363
+ }
364
+ if (phase === "replacing" && !candidate) {
365
+ return (replacementInFlight ?? Promise.resolve()).then(release, async (error: unknown) => {
366
+ await cleanupFailedLease();
367
+ throw error;
368
+ });
369
+ }
370
+ phase = "releasing";
371
+ const target = candidate ?? active;
372
+ if (!target) return Promise.reject(new Error("VPN lease guardian is unavailable."));
373
+ const releaseTimeoutMs =
374
+ config.idleDisconnectMs === 0
375
+ ? (config.disconnectTimeoutMs ?? DEFAULT_DISCONNECT_TIMEOUT_MS) +
376
+ GUARDIAN_HANDOFF_TIMEOUT_MS
377
+ : GUARDIAN_HANDOFF_TIMEOUT_MS;
378
+ releaseInFlight = new Promise<void>((resolve, reject) => {
379
+ let settled = false;
380
+ let cancelTimeout: () => void = noop;
381
+ const settleResolve = () => {
382
+ if (settled) return;
383
+ settled = true;
384
+ cancelTimeout();
385
+ releaseResolve = undefined;
386
+ releaseReject = undefined;
387
+ resolve();
388
+ };
389
+ const settleReject = (error: Error) => {
390
+ if (settled) return;
391
+ settled = true;
392
+ cancelTimeout();
393
+ releaseResolve = undefined;
394
+ releaseReject = undefined;
395
+ reject(error);
396
+ };
397
+ cancelTimeout = scheduleLongTimeout(
398
+ () => settleReject(new Error("Timed out waiting for VPN lease guardian release.")),
399
+ releaseTimeoutMs,
400
+ );
401
+ releaseResolve = settleResolve;
402
+ releaseReject = settleReject;
265
403
  try {
266
- mkdirSync(handle.lockPath);
267
- writeFileSync(
268
- joinPath(handle.lockPath, "owner.json"),
269
- `{"pid":${process.pid},"createdAt":${Number(now)}}`,
404
+ target.child.send({ type: "RELEASE", leaseId });
405
+ void target.child.exited.then(
406
+ (code) => {
407
+ if (phase === "releasing") {
408
+ settleReject(new Error(`VPN lease guardian exited during release (${code}).`));
409
+ }
410
+ return undefined;
411
+ },
412
+ (error) => {
413
+ settleReject(error instanceof Error ? error : new Error(errorMessage(error)));
414
+ },
270
415
  );
271
- return;
272
416
  } catch (error) {
273
- lockError = error;
274
- }
275
-
276
- if (!hasErrorCode(lockError, "EEXIST")) {
277
- return yield* fsError("Failed to acquire VPN prerequisite lease lock", lockError);
417
+ settleReject(error instanceof Error ? error : new Error(errorMessage(error)));
278
418
  }
419
+ }).then(() => {
420
+ phase = "released";
421
+ target.child.disconnect();
422
+ return undefined;
423
+ });
424
+ return releaseInFlight;
425
+ };
279
426
 
280
- const lockOwner = readVpnLockOwner(joinPath(handle.lockPath, "owner.json"));
281
- const lockAgeMs = yield* getLockDirectoryAgeMs(handle, Number(now));
282
- if (lockAgeMs === undefined) {
283
- continue;
284
- }
285
-
286
- if (isLockOwnerStale(lockOwner, lockAgeMs, handle.lockTimeoutMs)) {
287
- yield* syncFs("Failed to remove stale VPN prerequisite lease lock", () => {
288
- rmSync(handle.lockPath, { recursive: true, force: true });
289
- });
290
- continue;
291
- }
292
-
293
- if (Number(now) - Number(start) > handle.lockTimeoutMs) {
294
- return yield* new PrerequisiteRunError({
295
- message: "Timed out while waiting for VPN prerequisite lease lock.",
296
- hint: "Retry the command. If this repeats, remove stale files under the agent-tools runtime directory.",
297
- });
298
- }
299
-
300
- yield* Effect.sleep(Duration.millis(LOCK_RETRY_MS));
301
- }
302
- });
303
-
304
- const releaseFileLock = (handle: VpnLeaseHandle) =>
305
- syncFs("Failed to release VPN prerequisite lease lock", () => {
306
- rmSync(handle.lockPath, { recursive: true, force: true });
307
- }).pipe(Effect.ignore);
308
-
309
- const withVpnLeaseLock = <A, E>(
310
- handle: VpnLeaseHandle,
311
- effect: Effect.Effect<A, E, never>,
312
- ): Effect.Effect<A, E | PrerequisiteRunError, never> =>
313
- Effect.acquireRelease(acquireFileLock(handle), () => releaseFileLock(handle)).pipe(
314
- Effect.flatMap(() => effect),
315
- Effect.scoped,
316
- );
317
-
318
- type HeldVpnLease = {
319
- readonly handle: VpnLeaseHandle;
320
- readonly driver: ResolvedVpnDriver;
321
- readonly cleanup: VpnCleanupPolicy;
322
- readonly cooldownMs: number;
427
+ return { leaseId, stableGuardianId, release };
323
428
  };
324
429
 
325
- const cleanupHeldLeases = <CommandError>(
326
- heldLeases: readonly HeldVpnLease[],
327
- runCommand: PrerequisiteCommandRunner<CommandError>,
328
- ) =>
329
- Effect.gen(function* () {
330
- for (const held of heldLeases.toReversed()) {
331
- if (held.cooldownMs > 0) {
332
- yield* Effect.sleep(Duration.millis(held.cooldownMs));
333
- }
334
-
335
- yield* withVpnLeaseLock(
336
- held.handle,
337
- Effect.gen(function* () {
338
- const now = yield* Clock.currentTimeMillis;
339
- yield* removeOwnLease(held.handle);
340
- if (held.cleanup === "leave-running") {
341
- // Treat the agent-started VPN as intentionally adopted so later default runs do not stop it.
342
- yield* removeStartState(held.handle);
343
- return;
344
- }
345
-
346
- const state = yield* readStartState(held.handle);
347
- const hasOtherLeases = yield* hasOtherLiveLeases(held.handle, Number(now));
348
- const shouldStop = state !== undefined && !hasOtherLeases;
349
-
350
- if (!shouldStop) {
351
- return;
352
- }
353
-
354
- const stopCommand = makeVpnCommand(held.driver, "stop");
355
- yield* runCommand(stopCommand.command, stopCommand.label).pipe(Effect.ignore);
356
- yield* removeStartState(held.handle);
357
- }),
358
- ).pipe(Effect.ignore);
359
- }
360
- });
361
-
362
- const makeVpnCommand = (driver: ResolvedVpnDriver, action: "status" | "start" | "stop") => {
363
- if (driver.type === "macos-scutil") {
364
- const secret = driver.secretEnvVar ? readEnv(driver.secretEnvVar) : undefined;
365
- const secretArgs = action === "start" && secret ? ["--secret", secret] : [];
366
- const redactedSecretArgs = secretArgs.length > 0 ? ["--secret", "<redacted>"] : [];
367
- const args =
368
- action === "status"
369
- ? ["--nc", "status", driver.serviceName]
370
- : action === "start"
371
- ? ["--nc", "start", driver.serviceName, ...secretArgs]
372
- : ["--nc", "stop", driver.serviceName];
373
- const labelArgs =
374
- action === "start" ? ["--nc", "start", driver.serviceName, ...redactedSecretArgs] : args;
375
-
376
- return {
377
- command: ChildProcess.make("scutil", args, { stdout: "pipe", stderr: "pipe" }),
378
- label: ["scutil", ...labelArgs].join(" "),
379
- };
380
- }
381
-
382
- if (driver.type === "linux-nmcli") {
383
- const args =
384
- action === "status"
385
- ? ["-t", "-f", "NAME", "connection", "show", "--active"]
386
- : action === "start"
387
- ? ["connection", "up", driver.connectionName]
388
- : ["connection", "down", driver.connectionName];
389
-
390
- return {
391
- command: ChildProcess.make("nmcli", args, { stdout: "pipe", stderr: "pipe" }),
392
- label: ["nmcli", ...args].join(" "),
393
- };
394
- }
395
-
396
- const args =
397
- action === "stop"
398
- ? [driver.entryName, "/disconnect"]
399
- : action === "start"
400
- ? [driver.entryName]
401
- : [];
430
+ const makeInlineGuardian = async <E>(
431
+ driver: ResolvedVpnDriver,
432
+ config: VpnConfig,
433
+ cleanup: VpnCleanupPolicy,
434
+ runCommand: PrerequisiteCommandRunner<E>,
435
+ ): Promise<GuardianHandle> => {
436
+ const [{ runGuardian }, { getVpnStoreLocation }] = await Promise.all([
437
+ import("#shared/prerequisites/guardian"),
438
+ import("#shared/prerequisites/store"),
439
+ ]);
440
+ const leaseId = crypto.randomUUID();
441
+ const guardianId = crypto.randomUUID();
442
+ const sanitizedDriver = sanitizeVpnDriver(driver);
443
+ const init = guardianInit(
444
+ sanitizedDriver,
445
+ getVpnStoreLocation(sanitizedDriver).root,
446
+ config,
447
+ cleanup,
448
+ leaseId,
449
+ guardianId,
450
+ );
451
+ const guardian = await runGuardian(
452
+ init,
453
+ () => undefined,
454
+ (action) => {
455
+ const command = makeParentVpnCommand(driver, action);
456
+ return Effect.runPromise(runCommand(command.command, command.label));
457
+ },
458
+ );
402
459
  return {
403
- command: ChildProcess.make("rasdial", args, { stdout: "pipe", stderr: "pipe" }),
404
- label: ["rasdial", ...args].join(" "),
460
+ leaseId,
461
+ stableGuardianId: () => Promise.resolve(guardianId),
462
+ release: guardian.release,
405
463
  };
406
464
  };
407
465
 
408
- const isVpnConnectedOutput = (driver: ResolvedVpnDriver, stdout: string) => {
409
- if (driver.type === "macos-scutil") {
410
- return stdout.includes("Connected");
411
- }
412
-
413
- if (driver.type === "linux-nmcli") {
414
- return stdout
415
- .trim()
416
- .split("\n")
417
- .some((line) => line.trim() === driver.connectionName);
418
- }
419
-
420
- return stdout.includes(driver.entryName);
421
- };
422
-
423
- const isVpnConnected = <E>(driver: ResolvedVpnDriver, runCommand: PrerequisiteCommandRunner<E>) => {
424
- const statusCommand = makeVpnCommand(driver, "status");
425
- return runCommand(statusCommand.command, statusCommand.label).pipe(
426
- Effect.result,
427
- Effect.map((result) => {
428
- if (Result.isFailure(result)) {
429
- return false;
430
- }
431
-
432
- return result.success.exitCode === 0 && isVpnConnectedOutput(driver, result.success.stdout);
433
- }),
466
+ const startGuardian = <E>(
467
+ driver: ResolvedVpnDriver,
468
+ config: VpnConfig,
469
+ cleanup: VpnCleanupPolicy,
470
+ runCommand: PrerequisiteCommandRunner<E>,
471
+ runGuardianInProcess: boolean,
472
+ spawn?: GuardianSpawner,
473
+ ) =>
474
+ runGuardianInProcess
475
+ ? makeInlineGuardian(driver, config, cleanup, runCommand)
476
+ : spawnDetachedGuardian(driver, config, cleanup, spawn);
477
+
478
+ type ReleasableGuardian = { readonly release: () => Promise<void> };
479
+ type HeldVpnLease = { readonly guardian: GuardianHandle };
480
+
481
+ const safelyReleaseGuardian = (guardian: ReleasableGuardian) =>
482
+ Effect.promise(() =>
483
+ Promise.resolve()
484
+ .then(() => guardian.release())
485
+ .catch(noop),
434
486
  );
435
- };
436
487
 
437
- const waitForVpn = <E>(
488
+ export const releaseHeldLeases = (leases: readonly { readonly guardian: ReleasableGuardian }[]) =>
489
+ leases
490
+ .toReversed()
491
+ .reduce(
492
+ (released, held) => released.pipe(Effect.andThen(safelyReleaseGuardian(held.guardian))),
493
+ Effect.void,
494
+ );
495
+
496
+ export const closeVpnStoreAfter = <A, E>(
497
+ key: string,
498
+ guardian: ReleasableGuardian,
499
+ close: () => void,
500
+ body: Effect.Effect<A, E, never>,
501
+ ): Effect.Effect<A, E | PrerequisiteRunError, never> =>
502
+ Effect.matchCauseEffect(body, {
503
+ onFailure: (cause) =>
504
+ Effect.exit(Effect.sync(close)).pipe(Effect.andThen(Effect.failCause(cause))),
505
+ onSuccess: (value) =>
506
+ Effect.try({
507
+ try: close,
508
+ catch: (error) => coordinationError(key, error),
509
+ }).pipe(
510
+ Effect.catch((error) =>
511
+ safelyReleaseGuardian(guardian).pipe(Effect.andThen(Effect.fail(error))),
512
+ ),
513
+ Effect.map(() => value),
514
+ ),
515
+ });
516
+
517
+ const acquireVpn = <E>(
518
+ key: string,
438
519
  driver: ResolvedVpnDriver,
439
- timeoutMs: number,
520
+ config: VpnConfig,
521
+ cleanup: VpnCleanupPolicy,
440
522
  runCommand: PrerequisiteCommandRunner<E>,
523
+ runGuardianInProcess: boolean,
524
+ spawn?: GuardianSpawner,
441
525
  ) =>
442
526
  Effect.gen(function* () {
443
- const startTime = yield* Clock.currentTimeMillis;
444
- const deadline = Number(startTime) + timeoutMs;
445
- let result: boolean | undefined;
446
-
447
- yield* Effect.whileLoop({
448
- while: () => result === undefined,
449
- body: () =>
450
- Effect.gen(function* () {
451
- if (yield* isVpnConnected(driver, runCommand)) {
452
- result = true;
453
- return;
527
+ const guardian = yield* Effect.tryPromise({
528
+ try: () => startGuardian(driver, config, cleanup, runCommand, runGuardianInProcess, spawn),
529
+ catch: (error) => coordinationError(key, error),
530
+ });
531
+ const releaseGuardian = safelyReleaseGuardian(guardian);
532
+ const fail = (error: PrerequisiteRunError) =>
533
+ releaseGuardian.pipe(Effect.andThen(Effect.fail(error)));
534
+ const store = yield* Effect.tryPromise({
535
+ try: async () => {
536
+ const { VpnStore } = await import("#shared/prerequisites/store");
537
+ return VpnStore.open(sanitizeVpnDriver(driver));
538
+ },
539
+ catch: (error) => coordinationError(key, error),
540
+ }).pipe(Effect.catch(fail));
541
+ const connectTimeoutMs = config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
542
+ const idleDisconnectMs = config.idleDisconnectMs ?? DEFAULT_VPN_IDLE_DISCONNECT_MS;
543
+ const connectDeadline = Date.now() + connectTimeoutMs;
544
+ const stoppingCoordinationDeadline =
545
+ connectDeadline + (config.disconnectTimeoutMs ?? DEFAULT_DISCONNECT_TIMEOUT_MS);
546
+ const activateGuardianLease = Effect.tryPromise({
547
+ try: async () => {
548
+ const guardianId = await guardian.stableGuardianId();
549
+ return store.activateLease(guardian.leaseId, guardianId, Date.now());
550
+ },
551
+ catch: (error) => coordinationError(key, error),
552
+ }).pipe(Effect.catch(fail));
553
+
554
+ const acquisition = Effect.gen(function* () {
555
+ try {
556
+ store.deleteDeadLeases(isPidLive, idleDisconnectMs, Date.now());
557
+ while (true) {
558
+ const snapshot = store.snapshot();
559
+ if (snapshot.lifecycle === "UNKNOWN") {
560
+ return yield* fail(
561
+ new PrerequisiteRunError({
562
+ message: `VPN prerequisite "${key}" has unknown ownership: ${snapshot.evidence ?? "no evidence"}`,
563
+ hint: "Quiesce all agent-tools processes and remove its VPN runtime state before retrying.",
564
+ }),
565
+ );
566
+ }
567
+ if (snapshot.lifecycle === "ACTIVE" || snapshot.lifecycle === "IDLE") {
568
+ if (yield* activateGuardianLease) return guardian;
569
+ const remainingMs = connectDeadline - Date.now();
570
+ if (remainingMs <= 0) {
571
+ return yield* fail(coordinationError(key, "Guardian lease reservation was lost."));
572
+ }
573
+ yield* Effect.sleep(Duration.millis(Math.min(COORDINATION_POLL_MS, remainingMs)));
574
+ continue;
575
+ }
576
+ if (snapshot.lifecycle === "EXTERNAL") {
577
+ const guard = store.claimExternalCheck(
578
+ crypto.randomUUID(),
579
+ crypto.randomUUID(),
580
+ process.pid,
581
+ Date.now(),
582
+ );
583
+ if (!guard) continue;
584
+ const claimed = store.snapshot();
585
+ const connected = yield* runStatusBefore(driver, connectDeadline, runCommand);
586
+ if (connected === undefined) {
587
+ store.reconcileOperation(claimed, undefined, Date.now());
588
+ return yield* fail(
589
+ new PrerequisiteRunError({
590
+ message: `Could not confirm external VPN prerequisite "${key}" status.`,
591
+ hint: missingVpnToolHint(driver),
592
+ }),
593
+ );
594
+ }
595
+ if (!store.commitCheck(guard, connected, Date.now())) continue;
596
+ if (!connected) continue;
597
+ if (yield* activateGuardianLease) return guardian;
598
+ continue;
599
+ }
600
+ if (
601
+ snapshot.lifecycle === "CHECKING" ||
602
+ snapshot.lifecycle === "STARTING" ||
603
+ snapshot.lifecycle === "STOPPING"
604
+ ) {
605
+ const operationDeadline =
606
+ snapshot.lifecycle === "STOPPING" ? stoppingCoordinationDeadline : connectDeadline;
607
+ const remainingMs = operationDeadline - Date.now();
608
+ if (remainingMs <= 0) {
609
+ store.reconcileOperation(snapshot, undefined, Date.now());
610
+ return yield* fail(
611
+ coordinationError(key, `Timed out waiting for ${snapshot.lifecycle}.`),
612
+ );
613
+ }
614
+ if (snapshot.operationPid !== null && !isPidLive(snapshot.operationPid)) {
615
+ if (snapshot.lifecycle !== "CHECKING") {
616
+ store.reconcileOperation(snapshot, undefined, Date.now());
617
+ continue;
618
+ }
619
+ const connected = yield* runStatusBefore(driver, connectDeadline, runCommand);
620
+ store.reconcileOperation(snapshot, connected, Date.now());
621
+ continue;
622
+ }
623
+ const sleepRemainingMs = operationDeadline - Date.now();
624
+ if (sleepRemainingMs <= 0) continue;
625
+ yield* Effect.sleep(Duration.millis(Math.min(COORDINATION_POLL_MS, sleepRemainingMs)));
626
+ continue;
454
627
  }
455
628
 
456
- const now = yield* Clock.currentTimeMillis;
457
- if (Number(now) >= deadline) {
458
- result = false;
459
- return;
629
+ const checkGuard = store.claimCheck(
630
+ crypto.randomUUID(),
631
+ crypto.randomUUID(),
632
+ process.pid,
633
+ Date.now(),
634
+ );
635
+ if (!checkGuard) continue;
636
+ const claimedCheck = store.snapshot();
637
+ const connected = yield* runStatusBefore(driver, connectDeadline, runCommand);
638
+ if (connected === undefined) {
639
+ store.reconcileOperation(claimedCheck, undefined, Date.now());
640
+ return yield* fail(
641
+ new PrerequisiteRunError({
642
+ message: `Could not determine VPN prerequisite "${key}" status.`,
643
+ hint: missingVpnToolHint(driver),
644
+ }),
645
+ );
646
+ }
647
+ if (!store.commitCheck(checkGuard, connected, Date.now())) continue;
648
+ if (connected) {
649
+ if (yield* activateGuardianLease) return guardian;
650
+ continue;
460
651
  }
461
652
 
462
- yield* Effect.sleep(Duration.millis(500));
463
- }),
464
- step: () => undefined,
653
+ if (
654
+ driver.type === "macos-scutil" &&
655
+ driver.secretEnvVar &&
656
+ !readEnv(driver.secretEnvVar)
657
+ ) {
658
+ return yield* fail(missingVpnSecretError(key));
659
+ }
660
+ const startGuard = store.claimStart(
661
+ crypto.randomUUID(),
662
+ crypto.randomUUID(),
663
+ process.pid,
664
+ Date.now(),
665
+ );
666
+ if (!startGuard) continue;
667
+ const startSecret =
668
+ driver.type === "macos-scutil" && driver.secretEnvVar
669
+ ? readEnv(driver.secretEnvVar)
670
+ : undefined;
671
+ const startCommand = makeParentVpnCommand(driver, "start", startSecret);
672
+ const startRemainingMs = connectDeadline - Date.now();
673
+ const startResult =
674
+ startRemainingMs <= 0
675
+ ? Option.none()
676
+ : yield* runCommand(startCommand.command, startCommand.label).pipe(
677
+ Effect.result,
678
+ Effect.timeoutOption(Duration.millis(startRemainingMs)),
679
+ );
680
+ if (Option.isNone(startResult) || Result.isFailure(startResult.value)) {
681
+ const message = `VPN prerequisite "${key}" start timed out or failed after dispatch.`;
682
+ store.commitStart(startGuard, "unknown", crypto.randomUUID(), message, Date.now());
683
+ return yield* fail(
684
+ new PrerequisiteRunError({
685
+ message,
686
+ hint: missingVpnToolHint(driver),
687
+ }),
688
+ );
689
+ }
690
+ if (startResult.value.success.exitCode !== 0) {
691
+ const message = vpnStartFailureMessage(
692
+ key,
693
+ startResult.value.success.stderr,
694
+ startSecret !== undefined,
695
+ );
696
+ store.commitStart(startGuard, "down", crypto.randomUUID(), message, Date.now());
697
+ return yield* fail(
698
+ new PrerequisiteRunError({
699
+ message,
700
+ hint: missingVpnToolHint(driver),
701
+ }),
702
+ );
703
+ }
704
+ const ready = yield* waitForConnected(driver, connectDeadline, runCommand);
705
+ if (ready !== true) {
706
+ store.commitStart(
707
+ startGuard,
708
+ "unknown",
709
+ crypto.randomUUID(),
710
+ ready === false
711
+ ? "VPN start completed but was not confirmed connected before the deadline."
712
+ : "VPN start confirmation failed, timed out, or was unparseable.",
713
+ Date.now(),
714
+ );
715
+ return yield* fail(
716
+ new PrerequisiteRunError({
717
+ message: `VPN prerequisite "${key}" did not connect within timeout.`,
718
+ hint: missingVpnToolHint(driver),
719
+ }),
720
+ );
721
+ }
722
+ store.commitStart(
723
+ startGuard,
724
+ "managed",
725
+ crypto.randomUUID(),
726
+ "VPN start confirmed connected.",
727
+ Date.now(),
728
+ );
729
+ }
730
+ } catch (error) {
731
+ return yield* fail(
732
+ error instanceof PrerequisiteRunError ? error : coordinationError(key, error),
733
+ );
734
+ }
465
735
  });
466
-
467
- return result === true;
736
+ return yield* closeVpnStoreAfter(key, guardian, () => store.close(), acquisition);
468
737
  });
469
738
 
470
739
  export const runWithProfilePrerequisites = <A, E, CommandError>(
@@ -472,30 +741,27 @@ export const runWithProfilePrerequisites = <A, E, CommandError>(
472
741
  profile: ProfilePrerequisites,
473
742
  runCommand: PrerequisiteCommandRunner<CommandError>,
474
743
  effect: Effect.Effect<A, E, never>,
475
- options?: { tryWithoutPrerequisites?: boolean },
744
+ options?: {
745
+ tryWithoutPrerequisites?: boolean;
746
+ runGuardianInProcess?: boolean;
747
+ guardianSpawn?: GuardianSpawner;
748
+ },
476
749
  ): Effect.Effect<A, E | PrerequisiteRunError, never> => {
477
- const prerequisites = normalizeProfilePrerequisites(profile);
478
- const vpnPrerequisites = prerequisites.filter((prerequisite) => prerequisite.type === "vpn");
479
-
480
- if (vpnPrerequisites.length === 0) {
481
- return effect;
482
- }
750
+ const vpnPrerequisites = normalizeProfilePrerequisites(profile).filter(
751
+ (prerequisite) => prerequisite.type === "vpn",
752
+ );
753
+ if (vpnPrerequisites.length === 0) return effect;
483
754
 
484
755
  return Effect.gen(function* () {
485
- const shouldTryDirect = options?.tryWithoutPrerequisites === true;
486
-
487
756
  const tryDirect = () => effect.pipe(Effect.result);
488
-
489
- if (shouldTryDirect) {
490
- const directResult = yield* tryDirect();
491
- if (Result.isSuccess(directResult)) {
492
- return directResult.success;
493
- }
757
+ if (options?.tryWithoutPrerequisites) {
758
+ const direct = yield* tryDirect();
759
+ if (Result.isSuccess(direct)) return direct.success;
494
760
  }
495
761
 
496
762
  const prerequisiteResult = yield* Effect.gen(function* () {
497
- const heldLeases: HeldVpnLease[] = [];
498
- const acquirePrerequisites = Effect.gen(function* () {
763
+ const held: HeldVpnLease[] = [];
764
+ const acquired = yield* Effect.gen(function* () {
499
765
  for (const prerequisite of vpnPrerequisites) {
500
766
  const vpnConfig = config.vpns?.[prerequisite.key];
501
767
  if (!vpnConfig) {
@@ -504,138 +770,44 @@ export const runWithProfilePrerequisites = <A, E, CommandError>(
504
770
  hint: `Add vpns.${prerequisite.key} to agent-tools.json5 or remove the prerequisite.`,
505
771
  });
506
772
  }
507
-
508
- const driverResolution = resolveVpnDriverConfig(vpnConfig);
509
- if (!driverResolution.success) {
773
+ const resolution = resolveVpnDriverConfig(vpnConfig);
774
+ if (!resolution.success) {
510
775
  return yield* new PrerequisiteRunError({
511
- message: driverResolution.error,
512
- hint: driverResolution.hint,
776
+ message: resolution.error,
777
+ hint: resolution.hint,
513
778
  });
514
779
  }
515
-
516
- const driver = driverResolution.driver;
517
- const cleanup: VpnCleanupPolicy =
518
- prerequisite.cleanup ?? vpnConfig.defaultCleanup ?? "stop-if-started";
519
- const connectTimeoutMs = vpnConfig.connectTimeoutMs ?? 30000;
520
- const handle = makeLeaseHandle(
521
- driver,
522
- vpnConfig.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS,
523
- connectTimeoutMs + LOCK_TIMEOUT_BUFFER_MS,
780
+ const cleanup = prerequisite.cleanup ?? vpnConfig.defaultCleanup ?? "stop-if-started";
781
+ const guardian = yield* acquireVpn(
782
+ prerequisite.key,
783
+ resolution.driver,
784
+ vpnConfig,
785
+ cleanup,
786
+ runCommand,
787
+ options?.runGuardianInProcess === true,
788
+ options?.guardianSpawn,
524
789
  );
525
-
526
- const acquisitionResult = yield* withVpnLeaseLock(
527
- handle,
528
- Effect.gen(function* () {
529
- const result = yield* Effect.gen(function* () {
530
- const now = yield* Clock.currentTimeMillis;
531
- yield* writeLease(handle, Number(now));
532
- yield* pruneStaleLeases(handle, Number(now));
533
-
534
- const wasConnected = yield* isVpnConnected(driver, runCommand);
535
- if (wasConnected) {
536
- return;
537
- }
538
-
539
- if (
540
- driver.type === "macos-scutil" &&
541
- driver.secretEnvVar &&
542
- !readEnv(driver.secretEnvVar)
543
- ) {
544
- return yield* new PrerequisiteRunError({
545
- message: `VPN secret environment variable "${driver.secretEnvVar}" is not set.`,
546
- hint: `Set ${driver.secretEnvVar} before running this tool or remove secretEnvVar from the VPN config.`,
547
- });
548
- }
549
-
550
- const startCommand = makeVpnCommand(driver, "start");
551
- const startResult = yield* runCommand(
552
- startCommand.command,
553
- startCommand.label,
554
- ).pipe(
555
- Effect.mapError(
556
- () =>
557
- new PrerequisiteRunError({
558
- message: `Failed to start VPN prerequisite "${prerequisite.key}".`,
559
- hint: missingVpnToolHint(driver),
560
- }),
561
- ),
562
- );
563
-
564
- if (startResult.exitCode !== 0) {
565
- const stderr = startResult.stderr.trim();
566
- return yield* new PrerequisiteRunError({
567
- message:
568
- stderr !== ""
569
- ? stderr
570
- : `Failed to start VPN prerequisite "${prerequisite.key}".`,
571
- hint: missingVpnToolHint(driver),
572
- });
573
- }
574
-
575
- const ready = yield* waitForVpn(driver, connectTimeoutMs, runCommand);
576
- if (!ready) {
577
- return yield* new PrerequisiteRunError({
578
- message: `VPN prerequisite "${prerequisite.key}" did not connect within timeout.`,
579
- hint: missingVpnToolHint(driver),
580
- });
581
- }
582
-
583
- if (cleanup === "stop-if-started") {
584
- const connectedAt = yield* Clock.currentTimeMillis;
585
- yield* writeStartState(handle, Number(connectedAt));
586
- }
587
- }).pipe(Effect.result);
588
-
589
- if (Result.isFailure(result)) {
590
- yield* removeOwnLease(handle).pipe(Effect.ignore);
591
- }
592
-
593
- return result;
594
- }),
595
- ).pipe(
596
- Effect.mapError((error) =>
597
- error instanceof PrerequisiteRunError
598
- ? error
599
- : new PrerequisiteRunError({
600
- message: `Failed to coordinate VPN prerequisite "${prerequisite.key}".`,
601
- hint: missingVpnToolHint(driver),
602
- }),
603
- ),
604
- );
605
-
606
- if (Result.isFailure(acquisitionResult)) {
607
- return yield* Effect.fail(acquisitionResult.failure);
608
- }
609
-
610
- heldLeases.push({ handle, driver, cleanup, cooldownMs: vpnConfig.cooldownMs ?? 0 });
790
+ held.push({ guardian });
611
791
  }
612
- });
613
-
614
- const acquireResult = yield* acquirePrerequisites.pipe(Effect.result);
615
- const cleanup = cleanupHeldLeases(heldLeases, runCommand);
792
+ }).pipe(Effect.result);
616
793
 
617
- if (Result.isFailure(acquireResult)) {
618
- yield* cleanup.pipe(Effect.ignore);
619
- return yield* Effect.fail(acquireResult.failure);
794
+ if (Result.isFailure(acquired)) {
795
+ yield* releaseHeldLeases(held);
796
+ return yield* Effect.fail(acquired.failure);
620
797
  }
621
-
622
- return yield* effect.pipe(Effect.ensuring(cleanup));
798
+ return yield* effect.pipe(Effect.ensuring(releaseHeldLeases(held)));
623
799
  }).pipe(Effect.result);
624
800
 
625
- if (Result.isSuccess(prerequisiteResult)) {
626
- return prerequisiteResult.success;
627
- }
628
-
629
- if (shouldTryDirect && prerequisiteResult.failure instanceof PrerequisiteRunError) {
630
- const directRetryResult = yield* tryDirect();
631
- if (Result.isSuccess(directRetryResult)) {
632
- return directRetryResult.success;
633
- }
634
- if (!(directRetryResult.failure instanceof PrerequisiteRunError)) {
635
- return yield* Effect.fail(directRetryResult.failure);
636
- }
801
+ if (Result.isSuccess(prerequisiteResult)) return prerequisiteResult.success;
802
+ if (
803
+ options?.tryWithoutPrerequisites &&
804
+ prerequisiteResult.failure instanceof PrerequisiteRunError
805
+ ) {
806
+ const retry = yield* tryDirect();
807
+ if (Result.isSuccess(retry)) return retry.success;
808
+ if (!(retry.failure instanceof PrerequisiteRunError))
809
+ return yield* Effect.fail(retry.failure);
637
810
  }
638
-
639
811
  return yield* Effect.fail(prerequisiteResult.failure);
640
812
  });
641
813
  };