@blogic-cz/agent-tools 0.14.62 → 0.15.1

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 CHANGED
@@ -178,6 +178,10 @@ bun run agent-tools/example-tool/index.ts ping
178
178
  // auto defaults to true:
179
179
  // darwin -> macos-scutil, linux -> linux-nmcli, win32 -> windows-rasdial
180
180
  name: "ExampleVPN",
181
+ // Reuse package-managed connections for 30 seconds after the last command. Set 0 for immediate cleanup.
182
+ idleDisconnectMs: 30000,
183
+ // Total window for stop plus disconnected-status confirmation.
184
+ disconnectTimeoutMs: 10000,
181
185
  // Optional: pass IPSec shared secret to macOS scutil from env without storing the value in config.
182
186
  secretEnvVar: "EXAMPLE_VPN_IPSEC_SHARED_SECRET",
183
187
  },
@@ -187,8 +191,7 @@ bun run agent-tools/example-tool/index.ts ping
187
191
  clusterId: "your-cluster-id",
188
192
  namespaces: { test: "your-ns-test", prod: "your-ns-prod" },
189
193
  prerequisites: [{ type: "vpn", key: "exampleVpn" }],
190
- // Prerequisites are currently decoded and validated as config metadata;
191
- // automatic VPN connect/disconnect execution is planned for a follow-up release.
194
+ // agent-tools starts disconnected VPNs, shares package-local leases, and disconnects only connections it owns.
192
195
  },
193
196
  },
194
197
  logs: {
@@ -420,7 +423,7 @@ Secrets are **never** stored in the config file. The `db-tool` config references
420
423
  }
421
424
  ```
422
425
 
423
- Database VPN prerequisites can be set at the database profile or environment level. If an environment declares `vpn` or `prerequisites`, that environment config replaces the profile prerequisites; `prerequisites: []` explicitly disables inherited VPN setup. DB commands try the query directly first and only connect VPN prerequisites if direct access fails.
426
+ Database VPN prerequisites can be set at the database profile or environment level. If an environment declares `vpn` or `prerequisites`, that environment config replaces the profile prerequisites; `prerequisites: []` explicitly disables inherited VPN setup. DB commands try the query directly first and only connect VPN prerequisites if direct access fails. Package-managed VPNs remain reusable for `idleDisconnectMs` (default 30000) after the last lease; `0` restores immediate cleanup. Preconnected or `leave-running` connections are treated as external and never stopped automatically. If runtime state is corrupt, unknown, or contains legacy artifacts, first stop all agent-tools processes using the VPN, then remove that VPN state directory under `~/.agent-tools/runtime/vpn-prerequisites`.
424
427
 
425
428
  ```json5
426
429
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.14.62",
3
+ "version": "0.15.1",
4
4
  "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, sessions, and audit",
5
5
  "keywords": [
6
6
  "agent",
@@ -558,16 +558,25 @@
558
558
  "enum": ["leave-running", "stop-if-started"]
559
559
  },
560
560
  "connectTimeoutMs": {
561
- "type": "number"
561
+ "type": "number",
562
+ "minimum": 0,
563
+ "maximum": 2147483647,
564
+ "multipleOf": 1
562
565
  },
563
566
  "disconnectTimeoutMs": {
564
- "type": "number"
565
- },
566
- "cooldownMs": {
567
- "type": "number"
568
- },
569
- "leaseTtlMs": {
570
- "type": "number"
567
+ "type": "number",
568
+ "minimum": 0,
569
+ "maximum": 2147483647,
570
+ "multipleOf": 1,
571
+ "description": "Total bounded stop-and-confirm window in milliseconds."
572
+ },
573
+ "idleDisconnectMs": {
574
+ "type": "number",
575
+ "minimum": 0,
576
+ "maximum": 2147483647,
577
+ "multipleOf": 1,
578
+ "default": 30000,
579
+ "description": "Managed VPN reuse window after the last lease. Set to 0 for immediate cleanup."
571
580
  },
572
581
  "secretEnvVar": {
573
582
  "type": "string",
@@ -18,6 +18,20 @@ const CredentialGuardConfigSchema = Schema.Struct({
18
18
  });
19
19
 
20
20
  const CleanupPolicySchema = Schema.Literals(["leave-running", "stop-if-started"]);
21
+ const VPN_TIMER_FIELDS = ["connectTimeoutMs", "disconnectTimeoutMs", "idleDisconnectMs"] as const;
22
+ const MAX_TIMER_MS = 2_147_483_647;
23
+
24
+ function validateVpnTimer(key: string, field: (typeof VPN_TIMER_FIELDS)[number], value: unknown) {
25
+ if (
26
+ typeof value !== "number" ||
27
+ !Number.isFinite(value) ||
28
+ !Number.isInteger(value) ||
29
+ value < 0 ||
30
+ value > MAX_TIMER_MS
31
+ ) {
32
+ throw new Error(`VPN "${key}" ${field} must be an integer from 0 to ${MAX_TIMER_MS}.`);
33
+ }
34
+ }
21
35
 
22
36
  const VpnPrerequisiteSchema = Schema.Struct({
23
37
  type: Schema.Literal("vpn"),
@@ -56,8 +70,7 @@ const VpnConfigSchema = Schema.Struct({
56
70
  defaultCleanup: Schema.optionalKey(CleanupPolicySchema),
57
71
  connectTimeoutMs: Schema.optionalKey(Schema.Number),
58
72
  disconnectTimeoutMs: Schema.optionalKey(Schema.Number),
59
- cooldownMs: Schema.optionalKey(Schema.Number),
60
- leaseTtlMs: Schema.optionalKey(Schema.Number),
73
+ idleDisconnectMs: Schema.optionalKey(Schema.Number),
61
74
  secretEnvVar: Schema.optionalKey(Schema.String),
62
75
  drivers: Schema.optionalKey(
63
76
  Schema.Struct({
@@ -215,8 +228,29 @@ export function decodeConfig(
215
228
  const sanitized = stripUnknownTopLevelKeys(parsed);
216
229
 
217
230
  try {
218
- const decoded = Schema.decodeUnknownSync(AgentToolsConfigSchema)(sanitized);
219
- return decoded as AgentToolsConfig;
231
+ if (isRecord(sanitized) && isRecord(sanitized.vpns)) {
232
+ for (const [key, value] of Object.entries(sanitized.vpns)) {
233
+ if (!isRecord(value)) continue;
234
+ if ("cooldownMs" in value || "leaseTtlMs" in value) {
235
+ throw new Error(`VPN "${key}" uses removed cooldownMs or leaseTtlMs configuration.`);
236
+ }
237
+ for (const field of VPN_TIMER_FIELDS) {
238
+ if (value[field] !== undefined) validateVpnTimer(key, field, value[field]);
239
+ }
240
+ }
241
+ }
242
+ const decoded = Schema.decodeUnknownSync(AgentToolsConfigSchema)(sanitized) as AgentToolsConfig;
243
+ return decoded.vpns
244
+ ? {
245
+ ...decoded,
246
+ vpns: Object.fromEntries(
247
+ Object.entries(decoded.vpns).map(([key, vpn]) => [
248
+ key,
249
+ { idleDisconnectMs: 30_000, ...vpn },
250
+ ]),
251
+ ),
252
+ }
253
+ : decoded;
220
254
  } catch (error) {
221
255
  throw new Error(
222
256
  `Invalid agent-tools config at ${configPath}: ${
@@ -43,9 +43,10 @@ export type VpnConfig = {
43
43
  auto?: boolean;
44
44
  defaultCleanup?: CleanupPolicy;
45
45
  connectTimeoutMs?: number;
46
+ /** Total bounded stop-and-confirm window in milliseconds. */
46
47
  disconnectTimeoutMs?: number;
47
- cooldownMs?: number;
48
- leaseTtlMs?: number;
48
+ /** Managed VPN reuse window after the last lease. Defaults to 30000; 0 disconnects immediately. */
49
+ idleDisconnectMs?: number;
49
50
  /** Name of environment variable holding the VPN shared secret for supported drivers. */
50
51
  secretEnvVar?: string;
51
52
  drivers?: {
@@ -0,0 +1,88 @@
1
+ import { ChildProcess } from "effect/unstable/process";
2
+
3
+ import type { ResolvedVpnDriver } from "#shared/prerequisites/types";
4
+ import type { SanitizedVpnDriver } from "#shared/prerequisites/store";
5
+
6
+ export type VpnDriverAction = "status" | "start" | "stop";
7
+ export type VpnCommandSpec = { readonly executable: string; readonly args: readonly string[] };
8
+
9
+ export const sanitizeVpnDriver = (driver: ResolvedVpnDriver): SanitizedVpnDriver => {
10
+ if (driver.type === "macos-scutil") {
11
+ return { type: driver.type, platform: driver.platform, serviceName: driver.serviceName };
12
+ }
13
+ if (driver.type === "linux-nmcli") {
14
+ return { type: driver.type, platform: driver.platform, connectionName: driver.connectionName };
15
+ }
16
+ return { type: driver.type, platform: driver.platform, entryName: driver.entryName };
17
+ };
18
+
19
+ export const vpnCommandSpec = (
20
+ driver: SanitizedVpnDriver,
21
+ action: VpnDriverAction,
22
+ ): VpnCommandSpec => {
23
+ if (driver.type === "macos-scutil") {
24
+ return {
25
+ executable: "scutil",
26
+ args:
27
+ action === "status"
28
+ ? ["--nc", "status", driver.serviceName]
29
+ : ["--nc", action, driver.serviceName],
30
+ };
31
+ }
32
+ if (driver.type === "linux-nmcli") {
33
+ return {
34
+ executable: "nmcli",
35
+ args:
36
+ action === "status"
37
+ ? ["-t", "-e", "no", "-f", "NAME", "connection", "show", "--active"]
38
+ : ["connection", action === "start" ? "up" : "down", driver.connectionName],
39
+ };
40
+ }
41
+ return {
42
+ executable: "rasdial",
43
+ args:
44
+ action === "status"
45
+ ? []
46
+ : action === "start"
47
+ ? [driver.entryName]
48
+ : [driver.entryName, "/disconnect"],
49
+ };
50
+ };
51
+
52
+ export const makeParentVpnCommand = (
53
+ driver: ResolvedVpnDriver,
54
+ action: VpnDriverAction,
55
+ secret?: string,
56
+ ) => {
57
+ const spec = vpnCommandSpec(sanitizeVpnDriver(driver), action);
58
+ const secretArgs = action === "start" && secret ? ["--secret", secret] : [];
59
+ const args = [...spec.args, ...secretArgs];
60
+ const labelArgs = [...spec.args, ...(secretArgs.length > 0 ? ["--secret", "<redacted>"] : [])];
61
+ return {
62
+ command: ChildProcess.make(spec.executable, args, { stdout: "pipe", stderr: "pipe" }),
63
+ label: [spec.executable, ...labelArgs].join(" "),
64
+ };
65
+ };
66
+
67
+ export const parseVpnStatus = (
68
+ driver: SanitizedVpnDriver,
69
+ result: { readonly stdout: string; readonly exitCode: number },
70
+ ): boolean | undefined => {
71
+ if (result.exitCode !== 0) return undefined;
72
+ const lines = result.stdout.split(/\r?\n/);
73
+ if (driver.type === "macos-scutil") {
74
+ if (lines.includes("Connected")) return true;
75
+ if (lines.includes("Disconnected")) return false;
76
+ return undefined;
77
+ }
78
+ if (driver.type === "linux-nmcli") {
79
+ return lines.some((line) => line === driver.connectionName);
80
+ }
81
+ const records = lines.map((line) => line.trim()).filter((line) => line.length > 0);
82
+ const successFooter = "Command completed successfully.";
83
+ if (records.at(-1) !== successFooter) return undefined;
84
+ const body = records.slice(0, -1);
85
+ if (body.length === 1 && body[0] === "No connections") return false;
86
+ if (body[0] !== "Connected to" || body.length === 1) return undefined;
87
+ return body.slice(1).includes(driver.entryName);
88
+ };
@@ -0,0 +1,49 @@
1
+ import type {
2
+ GuardianInboundMessage,
3
+ GuardianOutboundMessage,
4
+ } from "#shared/prerequisites/guardian";
5
+ import { runGuardian } from "#shared/prerequisites/guardian";
6
+
7
+ let release: (() => Promise<void>) | undefined;
8
+ let initialized = false;
9
+ let initializedLeaseId: string | undefined;
10
+ let requestedLeaseId: string | undefined;
11
+ let disconnected = false;
12
+ let releaseStarted = false;
13
+
14
+ const send = (message: GuardianOutboundMessage) => process.send?.(message);
15
+ const fail = (error: unknown) => {
16
+ send({ type: "ERROR", message: error instanceof Error ? error.message : String(error) });
17
+ process.exitCode = 1;
18
+ };
19
+ const releaseIfRequested = () => {
20
+ if (releaseStarted || !release || (!disconnected && requestedLeaseId !== initializedLeaseId)) {
21
+ return;
22
+ }
23
+ releaseStarted = true;
24
+ void release().catch(fail);
25
+ };
26
+
27
+ process.on("message", (message: GuardianInboundMessage) => {
28
+ if (message.type === "INIT" && !initialized) {
29
+ initialized = true;
30
+ initializedLeaseId = message.leaseId;
31
+ void runGuardian(message, send)
32
+ .then((guardian) => {
33
+ release = guardian.release;
34
+ send({ type: "READY", leaseId: message.leaseId, guardianId: message.guardianId });
35
+ return releaseIfRequested();
36
+ })
37
+ .catch(fail);
38
+ return;
39
+ }
40
+ if (message.type === "RELEASE") {
41
+ requestedLeaseId = message.leaseId;
42
+ releaseIfRequested();
43
+ }
44
+ });
45
+
46
+ process.on("disconnect", () => {
47
+ disconnected = true;
48
+ releaseIfRequested();
49
+ });
@@ -0,0 +1,157 @@
1
+ import type { SanitizedVpnDriver } from "#shared/prerequisites/store";
2
+ import { VpnStore } from "#shared/prerequisites/store";
3
+ import { parseVpnStatus, vpnCommandSpec } from "#shared/prerequisites/driver-commands";
4
+ import type { VpnCleanupPolicy } from "#shared/prerequisites/types";
5
+
6
+ export type GuardianInitMessage = {
7
+ readonly type: "INIT";
8
+ readonly driver: SanitizedVpnDriver;
9
+ readonly runtimeRoot: string;
10
+ readonly leaseId: string;
11
+ readonly guardianId: string;
12
+ readonly ownerPid: number;
13
+ readonly cleanup: VpnCleanupPolicy;
14
+ readonly idleDisconnectMs: number;
15
+ readonly disconnectTimeoutMs: number;
16
+ };
17
+ export type GuardianReleaseMessage = { readonly type: "RELEASE"; readonly leaseId: string };
18
+ export type GuardianInboundMessage = GuardianInitMessage | GuardianReleaseMessage;
19
+ export type GuardianOutboundMessage =
20
+ | { readonly type: "READY"; readonly leaseId: string; readonly guardianId: string }
21
+ | { readonly type: "RELEASED"; readonly leaseId: string }
22
+ | { readonly type: "ERROR"; readonly message: string };
23
+
24
+ export type GuardianCommandRunner = (
25
+ action: "status" | "stop",
26
+ timeoutMs: number,
27
+ ) => Promise<{ readonly stdout: string; readonly stderr: string; readonly exitCode: number }>;
28
+
29
+ const safeEnvironment = (): Record<string, string> =>
30
+ Object.fromEntries(
31
+ ["PATH", "HOME", "TMPDIR", "TEMP", "TMP", "SYSTEMROOT", "WINDIR"].flatMap((name) => {
32
+ const value = process.env[name];
33
+ return value === undefined ? [] : [[name, value]];
34
+ }),
35
+ );
36
+
37
+ export const makeGuardianCommandRunner =
38
+ (driver: SanitizedVpnDriver): GuardianCommandRunner =>
39
+ async (action, timeoutMs) => {
40
+ const spec = vpnCommandSpec(driver, action);
41
+ const child = Bun.spawn([spec.executable, ...spec.args], {
42
+ env: safeEnvironment(),
43
+ stdin: "ignore",
44
+ stdout: "pipe",
45
+ stderr: "pipe",
46
+ timeout: timeoutMs,
47
+ killSignal: "SIGKILL",
48
+ });
49
+ const [exitCode, stdout, stderr] = await Promise.all([
50
+ child.exited,
51
+ new Response(child.stdout).text(),
52
+ new Response(child.stderr).text(),
53
+ ]);
54
+ return { stdout, stderr, exitCode };
55
+ };
56
+
57
+ const sleep = (milliseconds: number) =>
58
+ new Promise<void>((resolve) => {
59
+ setTimeout(resolve, milliseconds);
60
+ });
61
+
62
+ export async function stopWhenIdle(
63
+ store: VpnStore,
64
+ init: GuardianInitMessage,
65
+ runCommand: GuardianCommandRunner,
66
+ now: () => number = Date.now,
67
+ ): Promise<void> {
68
+ const snapshot = store.snapshot();
69
+ if (snapshot.lifecycle !== "IDLE" || snapshot.idleDeadline === null) return;
70
+ const delay = snapshot.idleDeadline - now();
71
+ if (delay > 0) await sleep(delay);
72
+
73
+ const operationId = crypto.randomUUID();
74
+ const token = crypto.randomUUID();
75
+ const guard = store.claimStop(operationId, token, process.pid, now());
76
+ if (!guard) return;
77
+
78
+ const deadline = now() + init.disconnectTimeoutMs;
79
+ let evidence = "VPN stop did not produce confirmed disconnected status.";
80
+ try {
81
+ let remaining = deadline - now();
82
+ if (remaining > 0) {
83
+ const stop = await runCommand("stop", remaining);
84
+ remaining = deadline - now();
85
+ if (stop.exitCode !== 0) {
86
+ evidence = "VPN stop command failed; ownership is unknown and stop will not be retried.";
87
+ } else {
88
+ const confirmDisconnected = async (): Promise<boolean> => {
89
+ const statusRemaining = deadline - now();
90
+ if (statusRemaining <= 0) return false;
91
+ const status = await runCommand("status", statusRemaining);
92
+ remaining = deadline - now();
93
+ const connected = parseVpnStatus(init.driver, status);
94
+ if (connected === false) return true;
95
+ if (connected === undefined || remaining <= 0) return false;
96
+ const sleepRemaining = deadline - now();
97
+ if (sleepRemaining <= 0) return false;
98
+ await sleep(Math.min(250, sleepRemaining));
99
+ remaining = deadline - now();
100
+ return remaining > 0 && confirmDisconnected();
101
+ };
102
+ if (await confirmDisconnected()) {
103
+ store.commitStop(guard, true, "VPN stop confirmed disconnected.", now());
104
+ return;
105
+ }
106
+ evidence = "VPN status failed, stayed connected, or was unparseable after stop.";
107
+ }
108
+ } else {
109
+ evidence = "VPN disconnect deadline expired before a command could safely start.";
110
+ }
111
+ } catch {
112
+ evidence = "VPN stop or confirmation timed out or failed; ownership is unknown.";
113
+ }
114
+ store.commitStop(guard, false, evidence, now());
115
+ }
116
+
117
+ export function runGuardian(
118
+ init: GuardianInitMessage,
119
+ send: (message: GuardianOutboundMessage) => void,
120
+ runCommand: GuardianCommandRunner = makeGuardianCommandRunner(init.driver),
121
+ ): Promise<{ release: () => Promise<void> }> {
122
+ return new Promise((resolve) => {
123
+ const store = VpnStore.open(init.driver, { root: init.runtimeRoot });
124
+ let released = false;
125
+ store.reserveLease({
126
+ leaseId: init.leaseId,
127
+ guardianId: init.guardianId,
128
+ ownerPid: init.ownerPid,
129
+ cleanup: init.cleanup,
130
+ now: Date.now(),
131
+ });
132
+
133
+ const release = async () => {
134
+ if (released) return;
135
+ released = true;
136
+ try {
137
+ const result = store.releaseLease({
138
+ leaseId: init.leaseId,
139
+ guardianId: init.guardianId,
140
+ idleDisconnectMs: init.idleDisconnectMs,
141
+ now: Date.now(),
142
+ });
143
+ const stop =
144
+ result.released && result.deadline !== null
145
+ ? stopWhenIdle(store, init, runCommand)
146
+ : undefined;
147
+ if (stop && init.idleDisconnectMs === 0) await stop;
148
+ send({ type: "RELEASED", leaseId: init.leaseId });
149
+ if (stop && init.idleDisconnectMs > 0) await stop;
150
+ } finally {
151
+ store.close();
152
+ }
153
+ };
154
+
155
+ resolve({ release });
156
+ });
157
+ }