@blogic-cz/agent-tools 0.14.15 → 0.14.21
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 +33 -0
- package/package.json +1 -1
- package/schemas/agent-tools.schema.json +11 -0
- package/src/config/loader.ts +2 -0
- package/src/config/types.ts +1 -1
- package/src/db-tool/service.ts +7 -1
- package/src/db-tool/types.ts +2 -1
- package/src/gh-tool/pr/commands.ts +3 -1
- package/src/gh-tool/pr/core.ts +7 -3
- package/src/session-tool/codex.ts +237 -0
- package/src/session-tool/config.ts +4 -1
- package/src/session-tool/index.ts +1 -1
- package/src/session-tool/service.ts +100 -62
- package/src/session-tool/types.ts +4 -1
- package/src/shared/path.ts +15 -0
- package/src/shared/prerequisites/config.ts +19 -0
- package/src/shared/prerequisites/runtime.ts +468 -75
- package/src/shared/prerequisites/types.ts +29 -1
|
@@ -1,15 +1,364 @@
|
|
|
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";
|
|
4
|
+
|
|
1
5
|
import { Clock, Duration, Effect, Result } from "effect";
|
|
2
6
|
import { ChildProcess } from "effect/unstable/process";
|
|
3
7
|
|
|
4
8
|
import type { AgentToolsConfig, ProfilePrerequisites } from "#config/types";
|
|
5
|
-
import type {
|
|
6
|
-
|
|
9
|
+
import type {
|
|
10
|
+
PrerequisiteCommandRunner,
|
|
11
|
+
ResolvedVpnDriver,
|
|
12
|
+
VpnCleanupPolicy,
|
|
13
|
+
VpnLease,
|
|
14
|
+
VpnLeaseHandle,
|
|
15
|
+
VpnLockOwner,
|
|
16
|
+
VpnStartState,
|
|
17
|
+
} from "#shared/prerequisites/types";
|
|
18
|
+
|
|
19
|
+
import { joinPath } from "#shared/path";
|
|
7
20
|
import { normalizeProfilePrerequisites } from "#shared/prerequisites/config";
|
|
8
21
|
import { PrerequisiteRunError } from "#shared/prerequisites/errors";
|
|
9
22
|
import { missingVpnToolHint, resolveVpnDriverConfig } from "#shared/prerequisites/vpn";
|
|
10
23
|
|
|
11
24
|
const readEnv = (name: string) => Bun.env[name];
|
|
12
25
|
|
|
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
|
+
|
|
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,
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const getErrorMessage = (error: unknown) =>
|
|
68
|
+
error instanceof Error ? error.message : String(error);
|
|
69
|
+
|
|
70
|
+
const hasErrorCode = (error: unknown, code: string) =>
|
|
71
|
+
typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
72
|
+
|
|
73
|
+
const fsError = (message: string, error: unknown) =>
|
|
74
|
+
new PrerequisiteRunError({
|
|
75
|
+
message: `${message}: ${getErrorMessage(error)}`,
|
|
76
|
+
hint: "Retry the command. If this repeats, remove stale files under the agent-tools runtime directory.",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const syncFs = <A>(message: string, operation: () => A) =>
|
|
80
|
+
Effect.try({
|
|
81
|
+
try: operation,
|
|
82
|
+
catch: (error) => fsError(message, error),
|
|
83
|
+
});
|
|
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
|
+
const isPidLive = (pid: number) => {
|
|
131
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
process.kill(pid, 0);
|
|
137
|
+
return true;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return error instanceof Error && "code" in error && error.code === "EPERM";
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
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;
|
|
153
|
+
};
|
|
154
|
+
|
|
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
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const hasOtherLiveLeases = (handle: VpnLeaseHandle, now: number) =>
|
|
171
|
+
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));
|
|
205
|
+
});
|
|
206
|
+
|
|
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
|
+
});
|
|
212
|
+
|
|
213
|
+
const readStartState = (handle: VpnLeaseHandle) =>
|
|
214
|
+
syncFs("Failed to read VPN prerequisite start state", () => readVpnStartState(handle.statePath));
|
|
215
|
+
|
|
216
|
+
const removeOwnLease = (handle: VpnLeaseHandle) =>
|
|
217
|
+
syncFs("Failed to remove VPN prerequisite lease", () => {
|
|
218
|
+
rmSync(handle.leasePath, { force: true });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const removeStartState = (handle: VpnLeaseHandle) =>
|
|
222
|
+
syncFs("Failed to remove VPN prerequisite start state", () => {
|
|
223
|
+
rmSync(handle.statePath, { force: true });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const getLockDirectoryAgeMs = (handle: VpnLeaseHandle, now: number) =>
|
|
227
|
+
syncFs("Failed to inspect VPN prerequisite lease lock", () => {
|
|
228
|
+
let stats: ReturnType<typeof statSync>;
|
|
229
|
+
try {
|
|
230
|
+
stats = statSync(handle.lockPath);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return now - stats.mtimeMs;
|
|
240
|
+
});
|
|
241
|
+
|
|
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;
|
|
250
|
+
}
|
|
251
|
+
|
|
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;
|
|
265
|
+
try {
|
|
266
|
+
mkdirSync(handle.lockPath);
|
|
267
|
+
writeFileSync(
|
|
268
|
+
joinPath(handle.lockPath, "owner.json"),
|
|
269
|
+
`{"pid":${process.pid},"createdAt":${Number(now)}}`,
|
|
270
|
+
);
|
|
271
|
+
return;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
lockError = error;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!hasErrorCode(lockError, "EEXIST")) {
|
|
277
|
+
return yield* fsError("Failed to acquire VPN prerequisite lease lock", lockError);
|
|
278
|
+
}
|
|
279
|
+
|
|
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;
|
|
323
|
+
};
|
|
324
|
+
|
|
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
|
+
|
|
13
362
|
const makeVpnCommand = (driver: ResolvedVpnDriver, action: "status" | "start" | "stop") => {
|
|
14
363
|
if (driver.type === "macos-scutil") {
|
|
15
364
|
const secret = driver.secretEnvVar ? readEnv(driver.secretEnvVar) : undefined;
|
|
@@ -145,87 +494,131 @@ export const runWithProfilePrerequisites = <A, E, CommandError>(
|
|
|
145
494
|
}
|
|
146
495
|
|
|
147
496
|
const prerequisiteResult = yield* Effect.gen(function* () {
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const driverResolution = resolveVpnDriverConfig(vpnConfig);
|
|
160
|
-
if (!driverResolution.success) {
|
|
161
|
-
return yield* new PrerequisiteRunError({
|
|
162
|
-
message: driverResolution.error,
|
|
163
|
-
hint: driverResolution.hint,
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const driver = driverResolution.driver;
|
|
168
|
-
const wasConnected = yield* isVpnConnected(driver, runCommand);
|
|
169
|
-
if (wasConnected) {
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
497
|
+
const heldLeases: HeldVpnLease[] = [];
|
|
498
|
+
const acquirePrerequisites = Effect.gen(function* () {
|
|
499
|
+
for (const prerequisite of vpnPrerequisites) {
|
|
500
|
+
const vpnConfig = config.vpns?.[prerequisite.key];
|
|
501
|
+
if (!vpnConfig) {
|
|
502
|
+
return yield* new PrerequisiteRunError({
|
|
503
|
+
message: `VPN prerequisite "${prerequisite.key}" is not defined.`,
|
|
504
|
+
hint: `Add vpns.${prerequisite.key} to agent-tools.json5 or remove the prerequisite.`,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
172
507
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
hint: `Set ${driver.secretEnvVar} before running this tool or remove secretEnvVar from the VPN config.`,
|
|
181
|
-
});
|
|
182
|
-
}
|
|
508
|
+
const driverResolution = resolveVpnDriverConfig(vpnConfig);
|
|
509
|
+
if (!driverResolution.success) {
|
|
510
|
+
return yield* new PrerequisiteRunError({
|
|
511
|
+
message: driverResolution.error,
|
|
512
|
+
hint: driverResolution.hint,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
183
515
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
)
|
|
193
|
-
|
|
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,
|
|
524
|
+
);
|
|
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
|
+
}
|
|
194
609
|
|
|
195
|
-
|
|
196
|
-
const stderr = startResult.stderr.trim();
|
|
197
|
-
return yield* new PrerequisiteRunError({
|
|
198
|
-
message:
|
|
199
|
-
stderr !== "" ? stderr : `Failed to start VPN prerequisite "${prerequisite.key}".`,
|
|
200
|
-
hint: missingVpnToolHint(driver),
|
|
201
|
-
});
|
|
610
|
+
heldLeases.push({ handle, driver, cleanup, cooldownMs: vpnConfig.cooldownMs ?? 0 });
|
|
202
611
|
}
|
|
612
|
+
});
|
|
203
613
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
return yield* new PrerequisiteRunError({
|
|
207
|
-
message: `VPN prerequisite "${prerequisite.key}" did not connect within timeout.`,
|
|
208
|
-
hint: missingVpnToolHint(driver),
|
|
209
|
-
});
|
|
210
|
-
}
|
|
614
|
+
const acquireResult = yield* acquirePrerequisites.pipe(Effect.result);
|
|
615
|
+
const cleanup = cleanupHeldLeases(heldLeases, runCommand);
|
|
211
616
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
617
|
+
if (Result.isFailure(acquireResult)) {
|
|
618
|
+
yield* cleanup.pipe(Effect.ignore);
|
|
619
|
+
return yield* Effect.fail(acquireResult.failure);
|
|
216
620
|
}
|
|
217
621
|
|
|
218
|
-
const cleanup = Effect.gen(function* () {
|
|
219
|
-
for (const started of startedDrivers.toReversed()) {
|
|
220
|
-
if (started.cooldownMs > 0) {
|
|
221
|
-
yield* Effect.sleep(Duration.millis(started.cooldownMs));
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const stopCommand = makeVpnCommand(started.driver, "stop");
|
|
225
|
-
yield* runCommand(stopCommand.command, stopCommand.label).pipe(Effect.ignore);
|
|
226
|
-
}
|
|
227
|
-
});
|
|
228
|
-
|
|
229
622
|
return yield* effect.pipe(Effect.ensuring(cleanup));
|
|
230
623
|
}).pipe(Effect.result);
|
|
231
624
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { Schema, type Effect } from "effect";
|
|
2
2
|
import type { ChildProcess } from "effect/unstable/process";
|
|
3
3
|
|
|
4
4
|
import type {
|
|
@@ -23,6 +23,9 @@ export type VpnDriverResolution =
|
|
|
23
23
|
| { success: true; driver: ResolvedVpnDriver }
|
|
24
24
|
| { success: false; error: string; hint: string };
|
|
25
25
|
|
|
26
|
+
export const VpnCleanupPolicy = Schema.Literals(["leave-running", "stop-if-started"]);
|
|
27
|
+
export type VpnCleanupPolicy = Schema.Schema.Type<typeof VpnCleanupPolicy>;
|
|
28
|
+
|
|
26
29
|
export type PrerequisiteCommandResult = {
|
|
27
30
|
readonly stdout: string;
|
|
28
31
|
readonly stderr: string;
|
|
@@ -33,3 +36,28 @@ export type PrerequisiteCommandRunner<E> = (
|
|
|
33
36
|
command: ChildProcess.Command,
|
|
34
37
|
label: string,
|
|
35
38
|
) => Effect.Effect<PrerequisiteCommandResult, E, never>;
|
|
39
|
+
|
|
40
|
+
export type VpnLease = {
|
|
41
|
+
readonly pid: number;
|
|
42
|
+
readonly createdAt: number;
|
|
43
|
+
readonly updatedAt: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type VpnStartState = {
|
|
47
|
+
readonly pid: number;
|
|
48
|
+
readonly startedAt: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type VpnLockOwner = {
|
|
52
|
+
readonly pid: number;
|
|
53
|
+
readonly createdAt: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type VpnLeaseHandle = {
|
|
57
|
+
readonly directory: string;
|
|
58
|
+
readonly leasePath: string;
|
|
59
|
+
readonly statePath: string;
|
|
60
|
+
readonly lockPath: string;
|
|
61
|
+
readonly ttlMs: number;
|
|
62
|
+
readonly lockTimeoutMs: number;
|
|
63
|
+
};
|