@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.
- package/README.md +14 -4
- package/package.json +1 -1
- package/schemas/agent-tools.schema.json +17 -8
- package/src/config/loader.ts +38 -4
- package/src/config/types.ts +3 -2
- package/src/gh-tool/index.ts +3 -1
- package/src/gh-tool/pr/commands.ts +194 -7
- package/src/gh-tool/pr/core.ts +92 -6
- package/src/gh-tool/pr/index.ts +1 -0
- package/src/gh-tool/service.ts +38 -0
- package/src/gh-tool/workflow.ts +80 -2
- package/src/shared/prerequisites/driver-commands.ts +88 -0
- package/src/shared/prerequisites/guardian-entry.ts +49 -0
- package/src/shared/prerequisites/guardian.ts +157 -0
- package/src/shared/prerequisites/runtime.ts +703 -531
- package/src/shared/prerequisites/store.ts +715 -0
- package/src/shared/prerequisites/types.ts +0 -25
|
@@ -1,137 +1,89 @@
|
|
|
1
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
68
|
-
|
|
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
|
|
71
|
-
|
|
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
|
|
70
|
+
export const missingVpnSecretError = (key: string) =>
|
|
74
71
|
new PrerequisiteRunError({
|
|
75
|
-
message:
|
|
76
|
-
hint: "
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
121
|
+
const waitForConnected = <E>(
|
|
122
|
+
driver: ResolvedVpnDriver,
|
|
123
|
+
deadline: number,
|
|
124
|
+
runCommand: PrerequisiteCommandRunner<E>,
|
|
125
|
+
) =>
|
|
171
126
|
Effect.gen(function* () {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
137
|
+
type GuardianHandle = {
|
|
138
|
+
readonly leaseId: string;
|
|
139
|
+
readonly stableGuardianId: () => Promise<string>;
|
|
140
|
+
readonly release: () => Promise<void>;
|
|
141
|
+
};
|
|
212
142
|
|
|
213
|
-
|
|
214
|
-
syncFs("Failed to read VPN prerequisite start state", () => readVpnStartState(handle.statePath));
|
|
143
|
+
type GuardianSpawner = typeof Bun.spawn;
|
|
215
144
|
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
)
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
404
|
-
|
|
460
|
+
leaseId,
|
|
461
|
+
stableGuardianId: () => Promise.resolve(guardianId),
|
|
462
|
+
release: guardian.release,
|
|
405
463
|
};
|
|
406
464
|
};
|
|
407
465
|
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
};
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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?: {
|
|
744
|
+
options?: {
|
|
745
|
+
tryWithoutPrerequisites?: boolean;
|
|
746
|
+
runGuardianInProcess?: boolean;
|
|
747
|
+
guardianSpawn?: GuardianSpawner;
|
|
748
|
+
},
|
|
476
749
|
): Effect.Effect<A, E | PrerequisiteRunError, never> => {
|
|
477
|
-
const
|
|
478
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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
|
|
498
|
-
const
|
|
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
|
-
|
|
509
|
-
if (!driverResolution.success) {
|
|
773
|
+
const resolution = resolveVpnDriverConfig(vpnConfig);
|
|
774
|
+
if (!resolution.success) {
|
|
510
775
|
return yield* new PrerequisiteRunError({
|
|
511
|
-
message:
|
|
512
|
-
hint:
|
|
776
|
+
message: resolution.error,
|
|
777
|
+
hint: resolution.hint,
|
|
513
778
|
});
|
|
514
779
|
}
|
|
515
|
-
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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(
|
|
618
|
-
yield*
|
|
619
|
-
return yield* Effect.fail(
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
const
|
|
631
|
-
if (Result.isSuccess(
|
|
632
|
-
|
|
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
|
};
|