@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
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { Database } from "bun:sqlite";
|
|
7
|
+
|
|
8
|
+
import type { ResolvedVpnDriver, VpnCleanupPolicy } from "#shared/prerequisites/types";
|
|
9
|
+
|
|
10
|
+
const SCHEMA_VERSION = 2;
|
|
11
|
+
const DATABASE_NAME = "state.sqlite";
|
|
12
|
+
const ALLOWED_FILES = new Set([DATABASE_NAME, `${DATABASE_NAME}-wal`, `${DATABASE_NAME}-shm`]);
|
|
13
|
+
const QUIESCENCE_HINT =
|
|
14
|
+
"Stop all agent-tools processes, then remove this VPN state directory before retrying.";
|
|
15
|
+
|
|
16
|
+
export type VpnLifecycle =
|
|
17
|
+
| "DOWN"
|
|
18
|
+
| "EXTERNAL"
|
|
19
|
+
| "CHECKING"
|
|
20
|
+
| "STARTING"
|
|
21
|
+
| "ACTIVE"
|
|
22
|
+
| "IDLE"
|
|
23
|
+
| "STOPPING"
|
|
24
|
+
| "UNKNOWN";
|
|
25
|
+
export type VpnLeaseStatus = "PENDING" | "ACTIVE";
|
|
26
|
+
export type VpnOperationKind = "CHECK" | "START" | "STOP";
|
|
27
|
+
|
|
28
|
+
export type SanitizedVpnDriver =
|
|
29
|
+
| { readonly type: "macos-scutil"; readonly platform: "darwin"; readonly serviceName: string }
|
|
30
|
+
| { readonly type: "linux-nmcli"; readonly platform: "linux"; readonly connectionName: string }
|
|
31
|
+
| { readonly type: "windows-rasdial"; readonly platform: "win32"; readonly entryName: string };
|
|
32
|
+
|
|
33
|
+
export type VpnStateSnapshot = {
|
|
34
|
+
readonly lifecycle: VpnLifecycle;
|
|
35
|
+
readonly managedEpochId: string | null;
|
|
36
|
+
readonly operationId: string | null;
|
|
37
|
+
readonly operationKind: VpnOperationKind | null;
|
|
38
|
+
readonly operationToken: string | null;
|
|
39
|
+
readonly operationPid: number | null;
|
|
40
|
+
readonly revision: number;
|
|
41
|
+
readonly idleDeadline: number | null;
|
|
42
|
+
readonly evidence: string | null;
|
|
43
|
+
readonly adoptExternalAfterStart: boolean;
|
|
44
|
+
readonly updatedAt: number;
|
|
45
|
+
readonly pendingLeases: number;
|
|
46
|
+
readonly activeLeases: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type OperationGuard = {
|
|
50
|
+
readonly operationId: string;
|
|
51
|
+
readonly token: string;
|
|
52
|
+
readonly revision: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type StateRow = {
|
|
56
|
+
lifecycle: VpnLifecycle;
|
|
57
|
+
managed_epoch_id: string | null;
|
|
58
|
+
operation_id: string | null;
|
|
59
|
+
operation_kind: VpnOperationKind | null;
|
|
60
|
+
operation_token: string | null;
|
|
61
|
+
operation_pid: number | null;
|
|
62
|
+
revision: number;
|
|
63
|
+
idle_deadline: number | null;
|
|
64
|
+
evidence: string | null;
|
|
65
|
+
adopt_external_after_start: number;
|
|
66
|
+
updated_at: number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type CountRow = { pending: number; active: number };
|
|
70
|
+
type MetadataRow = { driver_identity: string };
|
|
71
|
+
|
|
72
|
+
export class VpnStoreError extends Error {
|
|
73
|
+
readonly hint = QUIESCENCE_HINT;
|
|
74
|
+
|
|
75
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
76
|
+
super(`${message} ${QUIESCENCE_HINT}`, options);
|
|
77
|
+
this.name = "VpnStoreError";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const sanitizeVpnDriver = (driver: ResolvedVpnDriver): SanitizedVpnDriver => {
|
|
82
|
+
if (driver.type === "macos-scutil") {
|
|
83
|
+
return { type: driver.type, platform: driver.platform, serviceName: driver.serviceName };
|
|
84
|
+
}
|
|
85
|
+
if (driver.type === "linux-nmcli") {
|
|
86
|
+
return { type: driver.type, platform: driver.platform, connectionName: driver.connectionName };
|
|
87
|
+
}
|
|
88
|
+
return { type: driver.type, platform: driver.platform, entryName: driver.entryName };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const canonicalDriverIdentity = (driver: SanitizedVpnDriver): string => {
|
|
92
|
+
if (driver.type === "macos-scutil") {
|
|
93
|
+
return JSON.stringify({
|
|
94
|
+
platform: driver.platform,
|
|
95
|
+
serviceName: driver.serviceName,
|
|
96
|
+
type: driver.type,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (driver.type === "linux-nmcli") {
|
|
100
|
+
return JSON.stringify({
|
|
101
|
+
connectionName: driver.connectionName,
|
|
102
|
+
platform: driver.platform,
|
|
103
|
+
type: driver.type,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return JSON.stringify({
|
|
107
|
+
entryName: driver.entryName,
|
|
108
|
+
platform: driver.platform,
|
|
109
|
+
type: driver.type,
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const runtimeRoot = (override?: string) =>
|
|
114
|
+
resolve(override ?? process.env.AGENT_TOOLS_RUNTIME_DIR ?? `${homedir()}/.agent-tools/runtime`);
|
|
115
|
+
|
|
116
|
+
export const getVpnStoreLocation = (driver: SanitizedVpnDriver, root?: string) => {
|
|
117
|
+
const identity = canonicalDriverIdentity(driver);
|
|
118
|
+
const key = createHash("sha256").update(identity).digest("hex");
|
|
119
|
+
const base = runtimeRoot(root);
|
|
120
|
+
return {
|
|
121
|
+
identity,
|
|
122
|
+
root: base,
|
|
123
|
+
directory: resolve(base, "vpn-prerequisites", key),
|
|
124
|
+
databasePath: resolve(base, "vpn-prerequisites", key, DATABASE_NAME),
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const ensurePrivateDirectory = (path: string) => {
|
|
129
|
+
mkdirSync(path, { mode: 0o700, recursive: true });
|
|
130
|
+
if (process.platform !== "win32") {
|
|
131
|
+
chmodSync(path, 0o700);
|
|
132
|
+
const stats = statSync(path);
|
|
133
|
+
if (typeof process.getuid === "function" && stats.uid !== process.getuid()) {
|
|
134
|
+
throw new VpnStoreError(`VPN runtime directory is owned by another user: ${path}.`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return realpathSync(path);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const assertNoLegacyArtifacts = (vpnRoot: string) => {
|
|
141
|
+
const legacy = readdirSync(vpnRoot, { withFileTypes: true }).flatMap((entry) => {
|
|
142
|
+
const directory = resolve(vpnRoot, entry.name);
|
|
143
|
+
if (!entry.isDirectory() && !statSync(directory).isDirectory()) return [];
|
|
144
|
+
return readdirSync(directory)
|
|
145
|
+
.filter((name) => name === "started.json" || name === "lock" || /^lease-.*\.json$/.test(name))
|
|
146
|
+
.map((name) => `${entry.name}/${name}`);
|
|
147
|
+
});
|
|
148
|
+
if (legacy.length > 0) {
|
|
149
|
+
throw new VpnStoreError(
|
|
150
|
+
`Legacy or mixed VPN runtime artifacts found in ${vpnRoot}: ${legacy.join(", ")}.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const secureDatabaseFiles = (directory: string) => {
|
|
156
|
+
if (process.platform === "win32") return;
|
|
157
|
+
for (const name of ALLOWED_FILES) {
|
|
158
|
+
const path = resolve(directory, name);
|
|
159
|
+
if (existsSync(path)) chmodSync(path, 0o600);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const getUserVersion = (db: Database) =>
|
|
164
|
+
db.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version ?? -1;
|
|
165
|
+
|
|
166
|
+
const initializeSchema = (db: Database, identity: string, now: number) => {
|
|
167
|
+
db.exec(`
|
|
168
|
+
CREATE TABLE metadata (
|
|
169
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
170
|
+
driver_identity TEXT NOT NULL
|
|
171
|
+
) STRICT;
|
|
172
|
+
CREATE TABLE vpn_state (
|
|
173
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
174
|
+
lifecycle TEXT NOT NULL CHECK (lifecycle IN ('DOWN','EXTERNAL','CHECKING','STARTING','ACTIVE','IDLE','STOPPING','UNKNOWN')),
|
|
175
|
+
managed_epoch_id TEXT,
|
|
176
|
+
operation_id TEXT,
|
|
177
|
+
operation_kind TEXT CHECK (operation_kind IS NULL OR operation_kind IN ('CHECK','START','STOP')),
|
|
178
|
+
operation_token TEXT,
|
|
179
|
+
operation_pid INTEGER CHECK (operation_pid IS NULL OR operation_pid > 0),
|
|
180
|
+
revision INTEGER NOT NULL CHECK (revision >= 0),
|
|
181
|
+
idle_deadline INTEGER CHECK (idle_deadline IS NULL OR idle_deadline >= 0),
|
|
182
|
+
evidence TEXT,
|
|
183
|
+
adopt_external_after_start INTEGER NOT NULL DEFAULT 0 CHECK (adopt_external_after_start IN (0, 1)),
|
|
184
|
+
updated_at INTEGER NOT NULL CHECK (updated_at >= 0),
|
|
185
|
+
CHECK ((lifecycle IN ('CHECKING','STARTING','STOPPING')) = (operation_id IS NOT NULL)),
|
|
186
|
+
CHECK ((operation_id IS NULL) = (operation_kind IS NULL)),
|
|
187
|
+
CHECK ((operation_id IS NULL) = (operation_token IS NULL)),
|
|
188
|
+
CHECK ((operation_id IS NULL) = (operation_pid IS NULL)),
|
|
189
|
+
CHECK ((lifecycle IN ('ACTIVE','IDLE','STOPPING')) = (managed_epoch_id IS NOT NULL)),
|
|
190
|
+
CHECK ((lifecycle = 'IDLE') = (idle_deadline IS NOT NULL)),
|
|
191
|
+
CHECK (adopt_external_after_start = 0 OR lifecycle = 'STARTING')
|
|
192
|
+
) STRICT;
|
|
193
|
+
CREATE TABLE leases (
|
|
194
|
+
lease_id TEXT PRIMARY KEY,
|
|
195
|
+
guardian_id TEXT NOT NULL,
|
|
196
|
+
owner_pid INTEGER NOT NULL CHECK (owner_pid > 0),
|
|
197
|
+
status TEXT NOT NULL CHECK (status IN ('PENDING','ACTIVE')),
|
|
198
|
+
cleanup TEXT NOT NULL CHECK (cleanup IN ('leave-running','stop-if-started')),
|
|
199
|
+
created_at INTEGER NOT NULL CHECK (created_at >= 0),
|
|
200
|
+
updated_at INTEGER NOT NULL CHECK (updated_at >= created_at)
|
|
201
|
+
) STRICT;
|
|
202
|
+
`);
|
|
203
|
+
db.query("INSERT INTO metadata(singleton, driver_identity) VALUES (1, ?)").run(identity);
|
|
204
|
+
db.query(
|
|
205
|
+
"INSERT INTO vpn_state(singleton, lifecycle, revision, updated_at) VALUES (1, 'DOWN', 0, ?)",
|
|
206
|
+
).run(now);
|
|
207
|
+
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export class VpnStore {
|
|
211
|
+
private constructor(
|
|
212
|
+
private readonly db: Database,
|
|
213
|
+
readonly directory: string,
|
|
214
|
+
readonly databasePath: string,
|
|
215
|
+
) {}
|
|
216
|
+
|
|
217
|
+
static open(driver: SanitizedVpnDriver, options?: { root?: string; now?: number }): VpnStore {
|
|
218
|
+
const location = getVpnStoreLocation(driver, options?.root);
|
|
219
|
+
try {
|
|
220
|
+
ensurePrivateDirectory(location.root);
|
|
221
|
+
const vpnRoot = ensurePrivateDirectory(resolve(location.root, "vpn-prerequisites"));
|
|
222
|
+
assertNoLegacyArtifacts(vpnRoot);
|
|
223
|
+
const directory = ensurePrivateDirectory(location.directory);
|
|
224
|
+
const entries = readdirSync(directory);
|
|
225
|
+
const legacy = entries.filter((entry) => !ALLOWED_FILES.has(entry));
|
|
226
|
+
if (legacy.length > 0) {
|
|
227
|
+
throw new VpnStoreError(
|
|
228
|
+
`Legacy or mixed VPN runtime artifacts found in ${directory}: ${legacy.join(", ")}.`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const existed = existsSync(location.databasePath);
|
|
233
|
+
const db = new Database(location.databasePath, {
|
|
234
|
+
create: true,
|
|
235
|
+
readwrite: true,
|
|
236
|
+
strict: true,
|
|
237
|
+
});
|
|
238
|
+
secureDatabaseFiles(directory);
|
|
239
|
+
try {
|
|
240
|
+
db.exec("PRAGMA busy_timeout = 1000");
|
|
241
|
+
if (existed) {
|
|
242
|
+
const integrity = db.query<{ quick_check: string }, []>("PRAGMA quick_check").get();
|
|
243
|
+
if (integrity?.quick_check !== "ok") {
|
|
244
|
+
throw new VpnStoreError(`VPN state database is corrupt: ${location.databasePath}.`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const version = getUserVersion(db);
|
|
248
|
+
if (version === 0 && !existed) {
|
|
249
|
+
initializeSchema(db, location.identity, options?.now ?? Date.now());
|
|
250
|
+
} else if (version !== SCHEMA_VERSION) {
|
|
251
|
+
throw new VpnStoreError(
|
|
252
|
+
`Unsupported VPN state schema version ${version} at ${location.databasePath}.`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
256
|
+
db.exec("PRAGMA synchronous = FULL");
|
|
257
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
258
|
+
const metadata = db
|
|
259
|
+
.query<MetadataRow, []>("SELECT driver_identity FROM metadata WHERE singleton = 1")
|
|
260
|
+
.get();
|
|
261
|
+
if (!metadata || metadata.driver_identity !== location.identity) {
|
|
262
|
+
throw new VpnStoreError(
|
|
263
|
+
`VPN state driver identity mismatch at ${location.databasePath}.`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
secureDatabaseFiles(directory);
|
|
267
|
+
return new VpnStore(db, directory, location.databasePath);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
db.close(false);
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (error instanceof VpnStoreError) throw error;
|
|
274
|
+
throw new VpnStoreError(`Cannot open VPN state database at ${location.databasePath}.`, {
|
|
275
|
+
cause: error,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
close() {
|
|
281
|
+
secureDatabaseFiles(this.directory);
|
|
282
|
+
this.db.close(false);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
settings() {
|
|
286
|
+
return {
|
|
287
|
+
journalMode: this.db.query<{ journal_mode: string }, []>("PRAGMA journal_mode").get()
|
|
288
|
+
?.journal_mode,
|
|
289
|
+
synchronous: this.db.query<{ synchronous: number }, []>("PRAGMA synchronous").get()
|
|
290
|
+
?.synchronous,
|
|
291
|
+
userVersion: this.db.query<{ user_version: number }, []>("PRAGMA user_version").get()
|
|
292
|
+
?.user_version,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private immediate<A>(operation: () => A): A {
|
|
297
|
+
try {
|
|
298
|
+
return this.db.transaction(operation).immediate();
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (error instanceof VpnStoreError) throw error;
|
|
301
|
+
throw new VpnStoreError(`VPN state transaction failed at ${this.databasePath}.`, {
|
|
302
|
+
cause: error,
|
|
303
|
+
});
|
|
304
|
+
} finally {
|
|
305
|
+
secureDatabaseFiles(this.directory);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
snapshot(): VpnStateSnapshot {
|
|
310
|
+
try {
|
|
311
|
+
const state = this.db
|
|
312
|
+
.query<StateRow, []>("SELECT * FROM vpn_state WHERE singleton = 1")
|
|
313
|
+
.get();
|
|
314
|
+
const counts = this.db
|
|
315
|
+
.query<CountRow, []>(
|
|
316
|
+
"SELECT sum(status = 'PENDING') AS pending, sum(status = 'ACTIVE') AS active FROM leases",
|
|
317
|
+
)
|
|
318
|
+
.get();
|
|
319
|
+
if (!state) throw new VpnStoreError(`VPN state row is missing at ${this.databasePath}.`);
|
|
320
|
+
return {
|
|
321
|
+
lifecycle: state.lifecycle,
|
|
322
|
+
managedEpochId: state.managed_epoch_id,
|
|
323
|
+
operationId: state.operation_id,
|
|
324
|
+
operationKind: state.operation_kind,
|
|
325
|
+
operationToken: state.operation_token,
|
|
326
|
+
operationPid: state.operation_pid,
|
|
327
|
+
revision: state.revision,
|
|
328
|
+
idleDeadline: state.idle_deadline,
|
|
329
|
+
evidence: state.evidence,
|
|
330
|
+
adoptExternalAfterStart: state.adopt_external_after_start === 1,
|
|
331
|
+
updatedAt: state.updated_at,
|
|
332
|
+
pendingLeases: counts?.pending ?? 0,
|
|
333
|
+
activeLeases: counts?.active ?? 0,
|
|
334
|
+
};
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (error instanceof VpnStoreError) throw error;
|
|
337
|
+
throw new VpnStoreError(`Cannot read VPN state at ${this.databasePath}.`, { cause: error });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
reserveLease(input: {
|
|
342
|
+
leaseId: string;
|
|
343
|
+
guardianId: string;
|
|
344
|
+
ownerPid: number;
|
|
345
|
+
cleanup: VpnCleanupPolicy;
|
|
346
|
+
now: number;
|
|
347
|
+
}): VpnStateSnapshot {
|
|
348
|
+
return this.immediate(() => {
|
|
349
|
+
this.db
|
|
350
|
+
.query(
|
|
351
|
+
`INSERT INTO leases(lease_id, guardian_id, owner_pid, status, cleanup, created_at, updated_at)
|
|
352
|
+
VALUES (?, ?, ?, 'PENDING', ?, ?, ?)
|
|
353
|
+
ON CONFLICT(lease_id) DO UPDATE SET guardian_id=excluded.guardian_id,
|
|
354
|
+
owner_pid=excluded.owner_pid, cleanup=excluded.cleanup, updated_at=excluded.updated_at`,
|
|
355
|
+
)
|
|
356
|
+
.run(input.leaseId, input.guardianId, input.ownerPid, input.cleanup, input.now, input.now);
|
|
357
|
+
this.db
|
|
358
|
+
.query(
|
|
359
|
+
`UPDATE vpn_state SET lifecycle='ACTIVE', idle_deadline=NULL,
|
|
360
|
+
revision=revision+1, updated_at=? WHERE singleton=1 AND lifecycle='IDLE'`,
|
|
361
|
+
)
|
|
362
|
+
.run(input.now);
|
|
363
|
+
return this.snapshot();
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
activateLease(leaseId: string, guardianId: string, now: number): boolean {
|
|
368
|
+
return this.immediate(
|
|
369
|
+
() =>
|
|
370
|
+
this.db
|
|
371
|
+
.query(
|
|
372
|
+
`UPDATE leases SET status='ACTIVE', updated_at=?
|
|
373
|
+
WHERE lease_id=? AND guardian_id=? AND status='PENDING'`,
|
|
374
|
+
)
|
|
375
|
+
.run(now, leaseId, guardianId).changes === 1,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
claimCheck(
|
|
380
|
+
operationId: string,
|
|
381
|
+
token: string,
|
|
382
|
+
ownerPid: number,
|
|
383
|
+
now: number,
|
|
384
|
+
): OperationGuard | undefined {
|
|
385
|
+
return this.claimOperation("DOWN", "CHECKING", "CHECK", operationId, token, ownerPid, now);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
claimExternalCheck(
|
|
389
|
+
operationId: string,
|
|
390
|
+
token: string,
|
|
391
|
+
ownerPid: number,
|
|
392
|
+
now: number,
|
|
393
|
+
): OperationGuard | undefined {
|
|
394
|
+
return this.claimOperation("EXTERNAL", "CHECKING", "CHECK", operationId, token, ownerPid, now);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
claimStart(
|
|
398
|
+
operationId: string,
|
|
399
|
+
token: string,
|
|
400
|
+
ownerPid: number,
|
|
401
|
+
now: number,
|
|
402
|
+
): OperationGuard | undefined {
|
|
403
|
+
return this.claimOperation("DOWN", "STARTING", "START", operationId, token, ownerPid, now);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private claimOperation(
|
|
407
|
+
from: VpnLifecycle,
|
|
408
|
+
to: VpnLifecycle,
|
|
409
|
+
kind: VpnOperationKind,
|
|
410
|
+
operationId: string,
|
|
411
|
+
token: string,
|
|
412
|
+
ownerPid: number,
|
|
413
|
+
now: number,
|
|
414
|
+
): OperationGuard | undefined {
|
|
415
|
+
return this.immediate(() => {
|
|
416
|
+
const result = this.db
|
|
417
|
+
.query(
|
|
418
|
+
`UPDATE vpn_state SET lifecycle=?, operation_id=?, operation_kind=?, operation_token=?,
|
|
419
|
+
operation_pid=?, revision=revision+1, idle_deadline=NULL, updated_at=?
|
|
420
|
+
WHERE singleton=1 AND lifecycle=? AND operation_id IS NULL
|
|
421
|
+
AND EXISTS (SELECT 1 FROM leases WHERE status='PENDING')`,
|
|
422
|
+
)
|
|
423
|
+
.run(to, operationId, kind, token, ownerPid, now, from);
|
|
424
|
+
if (result.changes !== 1) return undefined;
|
|
425
|
+
const state = this.snapshot();
|
|
426
|
+
return { operationId, token, revision: state.revision };
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
claimStop(
|
|
431
|
+
operationId: string,
|
|
432
|
+
token: string,
|
|
433
|
+
ownerPid: number,
|
|
434
|
+
now: number,
|
|
435
|
+
): OperationGuard | undefined {
|
|
436
|
+
return this.immediate(() => {
|
|
437
|
+
const result = this.db
|
|
438
|
+
.query(
|
|
439
|
+
`UPDATE vpn_state SET lifecycle='STOPPING', operation_id=?, operation_kind='STOP',
|
|
440
|
+
operation_token=?, operation_pid=?, revision=revision+1, idle_deadline=NULL, updated_at=?
|
|
441
|
+
WHERE singleton=1 AND lifecycle='IDLE' AND idle_deadline <= ?
|
|
442
|
+
AND NOT EXISTS (SELECT 1 FROM leases)`,
|
|
443
|
+
)
|
|
444
|
+
.run(operationId, token, ownerPid, now, now);
|
|
445
|
+
if (result.changes !== 1) return undefined;
|
|
446
|
+
const state = this.snapshot();
|
|
447
|
+
return { operationId, token, revision: state.revision };
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
commitCheck(guard: OperationGuard, connected: boolean, now: number): boolean {
|
|
452
|
+
return this.commitOperation(
|
|
453
|
+
guard,
|
|
454
|
+
"CHECKING",
|
|
455
|
+
connected ? "EXTERNAL" : "DOWN",
|
|
456
|
+
null,
|
|
457
|
+
connected
|
|
458
|
+
? "Connected before agent-tools ownership was established."
|
|
459
|
+
: "Confirmed disconnected.",
|
|
460
|
+
now,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
commitStart(
|
|
465
|
+
guard: OperationGuard,
|
|
466
|
+
result: "managed" | "external" | "down" | "unknown",
|
|
467
|
+
managedEpochId: string,
|
|
468
|
+
evidence: string,
|
|
469
|
+
now: number,
|
|
470
|
+
): boolean {
|
|
471
|
+
const lifecycle =
|
|
472
|
+
result === "managed"
|
|
473
|
+
? "ACTIVE"
|
|
474
|
+
: result === "external"
|
|
475
|
+
? "EXTERNAL"
|
|
476
|
+
: result === "down"
|
|
477
|
+
? "DOWN"
|
|
478
|
+
: "UNKNOWN";
|
|
479
|
+
return this.commitOperation(
|
|
480
|
+
guard,
|
|
481
|
+
"STARTING",
|
|
482
|
+
lifecycle,
|
|
483
|
+
result === "managed" ? managedEpochId : null,
|
|
484
|
+
evidence,
|
|
485
|
+
now,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
commitStop(guard: OperationGuard, disconnected: boolean, evidence: string, now: number): boolean {
|
|
490
|
+
return this.commitOperation(
|
|
491
|
+
guard,
|
|
492
|
+
"STOPPING",
|
|
493
|
+
disconnected ? "DOWN" : "UNKNOWN",
|
|
494
|
+
null,
|
|
495
|
+
evidence,
|
|
496
|
+
now,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
reconcileOperation(
|
|
501
|
+
snapshot: VpnStateSnapshot,
|
|
502
|
+
connected: boolean | undefined,
|
|
503
|
+
now: number,
|
|
504
|
+
): boolean {
|
|
505
|
+
if (!snapshot.operationId || !snapshot.operationToken) return false;
|
|
506
|
+
const guard = {
|
|
507
|
+
operationId: snapshot.operationId,
|
|
508
|
+
token: snapshot.operationToken,
|
|
509
|
+
revision: snapshot.revision,
|
|
510
|
+
};
|
|
511
|
+
if (snapshot.lifecycle === "CHECKING") {
|
|
512
|
+
if (connected === undefined) {
|
|
513
|
+
return this.commitOperation(
|
|
514
|
+
guard,
|
|
515
|
+
"CHECKING",
|
|
516
|
+
"UNKNOWN",
|
|
517
|
+
null,
|
|
518
|
+
"Stale status check was unparseable.",
|
|
519
|
+
now,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
return this.commitCheck(guard, connected, now);
|
|
523
|
+
}
|
|
524
|
+
if (snapshot.lifecycle === "STARTING" || snapshot.lifecycle === "STOPPING") {
|
|
525
|
+
return this.commitOperation(
|
|
526
|
+
guard,
|
|
527
|
+
snapshot.lifecycle,
|
|
528
|
+
"UNKNOWN",
|
|
529
|
+
null,
|
|
530
|
+
`Stale ${snapshot.operationKind?.toLowerCase()} command completion and ownership are ambiguous; no retry is authorized.`,
|
|
531
|
+
now,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private commitOperation(
|
|
538
|
+
guard: OperationGuard,
|
|
539
|
+
from: VpnLifecycle,
|
|
540
|
+
to: VpnLifecycle,
|
|
541
|
+
managedEpochId: string | null,
|
|
542
|
+
evidence: string,
|
|
543
|
+
now: number,
|
|
544
|
+
): boolean {
|
|
545
|
+
return this.immediate(
|
|
546
|
+
() =>
|
|
547
|
+
this.db
|
|
548
|
+
.query(
|
|
549
|
+
`UPDATE vpn_state SET
|
|
550
|
+
lifecycle=CASE WHEN ?='ACTIVE' AND adopt_external_after_start=1 THEN 'EXTERNAL' ELSE ? END,
|
|
551
|
+
managed_epoch_id=CASE WHEN ?='ACTIVE' AND adopt_external_after_start=1 THEN NULL ELSE ? END,
|
|
552
|
+
operation_id=NULL, operation_kind=NULL, operation_token=NULL, operation_pid=NULL,
|
|
553
|
+
revision=revision+1, idle_deadline=NULL, evidence=?, adopt_external_after_start=0,
|
|
554
|
+
updated_at=?
|
|
555
|
+
WHERE singleton=1 AND lifecycle=? AND operation_id=? AND operation_token=? AND revision=?`,
|
|
556
|
+
)
|
|
557
|
+
.run(
|
|
558
|
+
to,
|
|
559
|
+
to,
|
|
560
|
+
to,
|
|
561
|
+
managedEpochId,
|
|
562
|
+
evidence,
|
|
563
|
+
now,
|
|
564
|
+
from,
|
|
565
|
+
guard.operationId,
|
|
566
|
+
guard.token,
|
|
567
|
+
guard.revision,
|
|
568
|
+
).changes === 1,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
releaseLease(input: {
|
|
573
|
+
leaseId: string;
|
|
574
|
+
guardianId: string;
|
|
575
|
+
idleDisconnectMs: number;
|
|
576
|
+
now: number;
|
|
577
|
+
}): { released: boolean; deadline: number | null } {
|
|
578
|
+
return this.immediate(() => {
|
|
579
|
+
const lease = this.db
|
|
580
|
+
.query<{ cleanup: VpnCleanupPolicy }, [string, string]>(
|
|
581
|
+
"SELECT cleanup FROM leases WHERE lease_id=? AND guardian_id=?",
|
|
582
|
+
)
|
|
583
|
+
.get(input.leaseId, input.guardianId);
|
|
584
|
+
if (!lease) return { released: false, deadline: this.snapshot().idleDeadline };
|
|
585
|
+
this.db
|
|
586
|
+
.query("DELETE FROM leases WHERE lease_id=? AND guardian_id=?")
|
|
587
|
+
.run(input.leaseId, input.guardianId);
|
|
588
|
+
const remaining =
|
|
589
|
+
this.db.query<{ count: number }, []>("SELECT count(*) AS count FROM leases").get()?.count ??
|
|
590
|
+
0;
|
|
591
|
+
if (lease.cleanup === "leave-running") {
|
|
592
|
+
this.db
|
|
593
|
+
.query(
|
|
594
|
+
`UPDATE vpn_state SET adopt_external_after_start=1,
|
|
595
|
+
evidence='Leave-running adoption retained during managed VPN start.', updated_at=?
|
|
596
|
+
WHERE singleton=1 AND lifecycle='STARTING'`,
|
|
597
|
+
)
|
|
598
|
+
.run(input.now);
|
|
599
|
+
this.db
|
|
600
|
+
.query(
|
|
601
|
+
`UPDATE vpn_state SET lifecycle='EXTERNAL', managed_epoch_id=NULL, idle_deadline=NULL,
|
|
602
|
+
operation_id=NULL, operation_kind=NULL, operation_token=NULL, operation_pid=NULL,
|
|
603
|
+
adopt_external_after_start=0, revision=revision+1,
|
|
604
|
+
evidence='Managed VPN adopted by leave-running lease.', updated_at=?
|
|
605
|
+
WHERE singleton=1 AND lifecycle IN ('ACTIVE','IDLE')`,
|
|
606
|
+
)
|
|
607
|
+
.run(input.now);
|
|
608
|
+
} else if (remaining === 0) {
|
|
609
|
+
this.db
|
|
610
|
+
.query(
|
|
611
|
+
`UPDATE vpn_state SET lifecycle='IDLE', idle_deadline=?, revision=revision+1, updated_at=?
|
|
612
|
+
WHERE singleton=1 AND lifecycle='ACTIVE'`,
|
|
613
|
+
)
|
|
614
|
+
.run(input.now + input.idleDisconnectMs, input.now);
|
|
615
|
+
}
|
|
616
|
+
return { released: true, deadline: this.snapshot().idleDeadline };
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
abandonLease(leaseId: string, now: number): boolean {
|
|
621
|
+
return this.immediate(() => {
|
|
622
|
+
const lease = this.db
|
|
623
|
+
.query<{ cleanup: VpnCleanupPolicy }, [string]>(
|
|
624
|
+
"SELECT cleanup FROM leases WHERE lease_id=?",
|
|
625
|
+
)
|
|
626
|
+
.get(leaseId);
|
|
627
|
+
if (!lease) return false;
|
|
628
|
+
const deleted = this.db.query("DELETE FROM leases WHERE lease_id=?").run(leaseId).changes;
|
|
629
|
+
if (deleted !== 1) return false;
|
|
630
|
+
const remaining =
|
|
631
|
+
this.db.query<{ count: number }, []>("SELECT count(*) AS count FROM leases").get()?.count ??
|
|
632
|
+
0;
|
|
633
|
+
if (lease.cleanup === "leave-running") {
|
|
634
|
+
this.db
|
|
635
|
+
.query(
|
|
636
|
+
`UPDATE vpn_state SET adopt_external_after_start=1,
|
|
637
|
+
evidence='Leave-running adoption retained after guardian generation failure.',
|
|
638
|
+
updated_at=? WHERE singleton=1 AND lifecycle='STARTING'`,
|
|
639
|
+
)
|
|
640
|
+
.run(now);
|
|
641
|
+
this.db
|
|
642
|
+
.query(
|
|
643
|
+
`UPDATE vpn_state SET lifecycle='EXTERNAL', managed_epoch_id=NULL, idle_deadline=NULL,
|
|
644
|
+
operation_id=NULL, operation_kind=NULL, operation_token=NULL, operation_pid=NULL,
|
|
645
|
+
adopt_external_after_start=0, revision=revision+1,
|
|
646
|
+
evidence='Managed VPN adopted after guardian generation failure.', updated_at=?
|
|
647
|
+
WHERE singleton=1 AND lifecycle IN ('ACTIVE','IDLE')`,
|
|
648
|
+
)
|
|
649
|
+
.run(now);
|
|
650
|
+
} else if (remaining === 0) {
|
|
651
|
+
this.db
|
|
652
|
+
.query(
|
|
653
|
+
`UPDATE vpn_state SET lifecycle='UNKNOWN', managed_epoch_id=NULL, idle_deadline=NULL,
|
|
654
|
+
revision=revision+1,
|
|
655
|
+
evidence='VPN guardian generation failed; ownership is ambiguous and no stop is authorized.',
|
|
656
|
+
updated_at=? WHERE singleton=1 AND lifecycle IN ('ACTIVE','IDLE')`,
|
|
657
|
+
)
|
|
658
|
+
.run(now);
|
|
659
|
+
}
|
|
660
|
+
return true;
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
deleteDeadLeases(
|
|
665
|
+
isPidLive: (pid: number) => boolean,
|
|
666
|
+
idleDisconnectMs: number,
|
|
667
|
+
now: number,
|
|
668
|
+
): number {
|
|
669
|
+
return this.immediate(() => {
|
|
670
|
+
const rows = this.db
|
|
671
|
+
.query<{ lease_id: string; owner_pid: number; cleanup: VpnCleanupPolicy }, []>(
|
|
672
|
+
"SELECT lease_id, owner_pid, cleanup FROM leases",
|
|
673
|
+
)
|
|
674
|
+
.all();
|
|
675
|
+
let deleted = 0;
|
|
676
|
+
let adoptExternal = false;
|
|
677
|
+
for (const row of rows) {
|
|
678
|
+
if (!isPidLive(row.owner_pid)) {
|
|
679
|
+
deleted += this.db.query("DELETE FROM leases WHERE lease_id=?").run(row.lease_id).changes;
|
|
680
|
+
adoptExternal ||= row.cleanup === "leave-running";
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
const remaining =
|
|
684
|
+
this.db.query<{ count: number }, []>("SELECT count(*) AS count FROM leases").get()?.count ??
|
|
685
|
+
0;
|
|
686
|
+
if (deleted > 0 && adoptExternal) {
|
|
687
|
+
this.db
|
|
688
|
+
.query(
|
|
689
|
+
`UPDATE vpn_state SET adopt_external_after_start=1,
|
|
690
|
+
evidence='Dead leave-running adoption retained during managed VPN start.', updated_at=?
|
|
691
|
+
WHERE singleton=1 AND lifecycle='STARTING'`,
|
|
692
|
+
)
|
|
693
|
+
.run(now);
|
|
694
|
+
this.db
|
|
695
|
+
.query(
|
|
696
|
+
`UPDATE vpn_state SET lifecycle='EXTERNAL', managed_epoch_id=NULL, idle_deadline=NULL,
|
|
697
|
+
operation_id=NULL, operation_kind=NULL, operation_token=NULL, operation_pid=NULL,
|
|
698
|
+
adopt_external_after_start=0, revision=revision+1,
|
|
699
|
+
evidence='Managed VPN adopted after dead leave-running lease reconciliation.', updated_at=?
|
|
700
|
+
WHERE singleton=1 AND lifecycle IN ('ACTIVE','IDLE')`,
|
|
701
|
+
)
|
|
702
|
+
.run(now);
|
|
703
|
+
} else if (deleted > 0 && remaining === 0) {
|
|
704
|
+
this.db
|
|
705
|
+
.query(
|
|
706
|
+
`UPDATE vpn_state SET lifecycle='IDLE', idle_deadline=?, revision=revision+1,
|
|
707
|
+
evidence='Dead stop-if-started lease owner reconciled on next invocation.', updated_at=?
|
|
708
|
+
WHERE singleton=1 AND lifecycle='ACTIVE'`,
|
|
709
|
+
)
|
|
710
|
+
.run(now + idleDisconnectMs, now);
|
|
711
|
+
}
|
|
712
|
+
return deleted;
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
}
|