@blogic-cz/agent-tools 0.14.61 → 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.
@@ -1,3 +1,5 @@
1
+ import { posix } from "node:path";
2
+
1
3
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2
4
  import { Context, Effect, Layer, Option, Ref, Stream } from "effect";
3
5
 
@@ -16,7 +18,7 @@ import { resolveEnvTemplate } from "#shared/env-template";
16
18
  import { isPrerequisiteRunError } from "#shared/prerequisites/errors";
17
19
  import { runWithProfilePrerequisites } from "#shared/prerequisites/runtime";
18
20
  import { buildApiProbeArgs } from "#shared/k8s-probe";
19
- import { isKubectlCommandAllowed } from "./security";
21
+ import { isKubectlCommandAllowed, isSafeLogPath } from "./security";
20
22
 
21
23
  export class K8sService extends Context.Service<
22
24
  K8sService,
@@ -37,6 +39,16 @@ export class K8sService extends Context.Service<
37
39
  CommandResult,
38
40
  K8sContextError | K8sCommandError | K8sTimeoutError | K8sDangerousCommandError
39
41
  >;
42
+ readonly runLogTail: (
43
+ pod: string,
44
+ basePath: string,
45
+ path: string,
46
+ lines: number,
47
+ profile?: string,
48
+ ) => Effect.Effect<
49
+ CommandResult,
50
+ K8sContextError | K8sCommandError | K8sTimeoutError | K8sDangerousCommandError
51
+ >;
40
52
  }
41
53
  >()("@agent-tools/K8sService") {
42
54
  static readonly layer = Layer.effect(
@@ -86,6 +98,10 @@ export class K8sService extends Context.Service<
86
98
 
87
99
  const withKubeconfig = (command: string, kubeconfig: string | undefined) =>
88
100
  kubeconfig ? `KUBECONFIG=${quoteShellArg(kubeconfig)} ${command}` : command;
101
+ const renderArg = (arg: string) =>
102
+ /^[A-Za-z0-9_./:=,@+-]+$/.test(arg) ? arg : quoteShellArg(arg);
103
+ const renderKubectlCommand = (context: string, argv: readonly string[]) =>
104
+ ["kubectl", "--context", context, ...argv].map(renderArg).join(" ");
89
105
 
90
106
  // Cache context by selected profile/cluster instead of a single default profile.
91
107
  const contextRef = yield* Ref.make<Record<string, string>>({});
@@ -253,7 +269,7 @@ export class K8sService extends Context.Service<
253
269
  });
254
270
 
255
271
  const executeCommand = Effect.fn("K8sService.executeCommand")(function* (
256
- cmd: string,
272
+ argv: readonly string[],
257
273
  profile?: string,
258
274
  ) {
259
275
  const k8sConfig = yield* requireK8sConfig(profile);
@@ -275,9 +291,29 @@ export class K8sService extends Context.Service<
275
291
  });
276
292
  }
277
293
 
278
- const fullCommand = withKubeconfig(`kubectl --context ${context} ${cmd}`, kubeconfig);
279
-
280
- const resultOption = yield* runShellCommand(fullCommand, timeoutMs);
294
+ const fullCommand = renderKubectlCommand(context, argv);
295
+ const command = ChildProcess.make("kubectl", ["--context", context, ...argv], {
296
+ stdout: "pipe",
297
+ stderr: "pipe",
298
+ ...(kubeconfig ? { env: { KUBECONFIG: kubeconfig }, extendEnv: true } : {}),
299
+ });
300
+ const resultOption = yield* Effect.scoped(
301
+ Effect.gen(function* () {
302
+ const process = yield* executor.spawn(command);
303
+ return yield* collectProcessOutput(process);
304
+ }),
305
+ ).pipe(
306
+ Effect.timeoutOption(timeoutMs),
307
+ Effect.mapError(
308
+ (platformError) =>
309
+ new K8sCommandError({
310
+ message: `Command execution failed: ${String(platformError)}`,
311
+ command: fullCommand,
312
+ exitCode: -1,
313
+ stderr: undefined,
314
+ }),
315
+ ),
316
+ );
281
317
 
282
318
  if (Option.isNone(resultOption)) {
283
319
  return yield* new K8sTimeoutError({
@@ -316,16 +352,18 @@ export class K8sService extends Context.Service<
316
352
  ) {
317
353
  // Security: block dangerous commands before execution
318
354
  const securityCheck = isKubectlCommandAllowed(cmd);
319
- if (!securityCheck.allowed) {
355
+ if (!securityCheck.allowed || !securityCheck.argv) {
320
356
  return yield* new K8sDangerousCommandError({
321
357
  message: securityCheck.reason ?? "Command not allowed",
322
358
  command: cmd,
323
- verb: securityCheck.verb,
324
- hint: "AI agents can only run read-only kubectl commands. For mutating operations, use kubectl directly or ask a human operator.",
359
+ ...(securityCheck.verb ? { verb: securityCheck.verb } : {}),
360
+ hint:
361
+ securityCheck.hint ??
362
+ "AI agents can only run read-only kubectl commands. For mutating operations, use kubectl directly or ask a human operator.",
325
363
  });
326
364
  }
327
365
 
328
- const result = yield* executeCommand(cmd, profile);
366
+ const result = yield* executeCommand(securityCheck.argv, profile);
329
367
  if (result.exitCode !== 0) {
330
368
  return yield* new K8sCommandError({
331
369
  message: result.stderr ?? `kubectl exited with code ${result.exitCode}`,
@@ -345,20 +383,22 @@ export class K8sService extends Context.Service<
345
383
  ) {
346
384
  // Security: block dangerous commands before execution (even dry-run)
347
385
  const securityCheck = isKubectlCommandAllowed(cmd);
348
- if (!securityCheck.allowed) {
386
+ if (!securityCheck.allowed || !securityCheck.argv) {
349
387
  return yield* new K8sDangerousCommandError({
350
388
  message: securityCheck.reason ?? "Command not allowed",
351
389
  command: cmd,
352
- verb: securityCheck.verb,
353
- hint: "AI agents can only run read-only kubectl commands. For mutating operations, use kubectl directly or ask a human operator.",
390
+ ...(securityCheck.verb ? { verb: securityCheck.verb } : {}),
391
+ hint:
392
+ securityCheck.hint ??
393
+ "AI agents can only run read-only kubectl commands. For mutating operations, use kubectl directly or ask a human operator.",
354
394
  });
355
395
  }
356
396
 
357
397
  const startTime = Date.now();
358
398
  if (dryRun) {
359
399
  const k8sConfig = yield* requireK8sConfig(profile);
360
- const { context, kubeconfig } = yield* resolveContext(profile, k8sConfig);
361
- const fullCommand = withKubeconfig(`kubectl --context ${context} ${cmd}`, kubeconfig);
400
+ const { context } = yield* resolveContext(profile, k8sConfig);
401
+ const fullCommand = renderKubectlCommand(context, securityCheck.argv);
362
402
  return {
363
403
  success: true,
364
404
  command: fullCommand,
@@ -367,8 +407,88 @@ export class K8sService extends Context.Service<
367
407
  };
368
408
  }
369
409
 
370
- const result = yield* executeCommand(cmd, profile);
410
+ const result = yield* executeCommand(securityCheck.argv, profile);
411
+
412
+ if (result.exitCode !== 0) {
413
+ return yield* new K8sCommandError({
414
+ message: result.stderr ?? `kubectl exited with code ${result.exitCode}`,
415
+ command: result.command,
416
+ exitCode: result.exitCode,
417
+ stderr: result.stderr ?? undefined,
418
+ });
419
+ }
420
+
421
+ return {
422
+ success: true,
423
+ output: result.stdout.trim(),
424
+ command: result.command,
425
+ executionTimeMs: Date.now() - startTime,
426
+ };
427
+ });
428
+
429
+ const runLogTail = Effect.fn("K8sService.runLogTail")(function* (
430
+ pod: string,
431
+ basePath: string,
432
+ path: string,
433
+ lines: number,
434
+ profile?: string,
435
+ ) {
436
+ const normalizedBase = posix.resolve(basePath);
437
+ const normalizedPath = posix.resolve(path);
438
+ const lexicalRelative = posix.relative(normalizedBase, normalizedPath);
439
+ if (
440
+ !/^[a-z0-9](?:[-a-z0-9.]*[a-z0-9])?$/.test(pod) ||
441
+ !Number.isInteger(lines) ||
442
+ lines < 1 ||
443
+ lexicalRelative === ".." ||
444
+ lexicalRelative.startsWith("../") ||
445
+ posix.isAbsolute(lexicalRelative) ||
446
+ !isSafeLogPath(normalizedPath)
447
+ ) {
448
+ return yield* new K8sDangerousCommandError({
449
+ message: "Invalid internal log-tail request.",
450
+ command: "exec tail",
451
+ verb: "exec",
452
+ hint: "Use logs-tool with a log file inside its configured remote directory.",
453
+ });
454
+ }
455
+
456
+ const realpathArgv = ["exec", pod, "--", "realpath", normalizedBase, normalizedPath];
457
+ const realpathResult = yield* executeCommand(realpathArgv, profile);
458
+ if (realpathResult.exitCode !== 0) {
459
+ return yield* new K8sCommandError({
460
+ message:
461
+ realpathResult.stderr ||
462
+ `Remote realpath exited with code ${realpathResult.exitCode}`,
463
+ command: realpathResult.command,
464
+ exitCode: realpathResult.exitCode,
465
+ stderr: realpathResult.stderr || undefined,
466
+ });
467
+ }
468
+ const [canonicalBase, canonicalPath] = realpathResult.stdout.trim().split("\n");
469
+ const canonicalRelative =
470
+ canonicalBase === undefined || canonicalPath === undefined
471
+ ? ".."
472
+ : posix.relative(canonicalBase, canonicalPath);
473
+ if (
474
+ canonicalBase === undefined ||
475
+ canonicalPath === undefined ||
476
+ canonicalRelative === ".." ||
477
+ canonicalRelative.startsWith("../") ||
478
+ posix.isAbsolute(canonicalRelative) ||
479
+ !isSafeLogPath(canonicalPath)
480
+ ) {
481
+ return yield* new K8sDangerousCommandError({
482
+ message: "Canonical log path escapes the configured remote directory.",
483
+ command: realpathResult.command,
484
+ verb: "exec",
485
+ hint: "Remove symlinks that point outside the configured remote log directory.",
486
+ });
487
+ }
371
488
 
489
+ const argv = ["exec", pod, "--", "tail", "-n", String(lines), canonicalPath];
490
+ const startTime = Date.now();
491
+ const result = yield* executeCommand(argv, profile);
372
492
  if (result.exitCode !== 0) {
373
493
  return yield* new K8sCommandError({
374
494
  message: result.stderr ?? `kubectl exited with code ${result.exitCode}`,
@@ -386,7 +506,7 @@ export class K8sService extends Context.Service<
386
506
  };
387
507
  });
388
508
 
389
- return { runCommand, runKubectl };
509
+ return { runCommand, runKubectl, runLogTail };
390
510
  }),
391
511
  ),
392
512
  );
@@ -158,7 +158,7 @@ const readCommand = Command.make(
158
158
  ),
159
159
  format: formatOption,
160
160
  grep: Flag.string("grep").pipe(
161
- Flag.withDescription("Filter lines containing pattern"),
161
+ Flag.withDescription("Filter lines containing case-insensitive literal text"),
162
162
  Flag.optional,
163
163
  ),
164
164
  pretty: Flag.boolean("pretty").pipe(
@@ -1,13 +1,15 @@
1
+ import { isAbsolute, posix, relative, resolve } from "node:path";
2
+
1
3
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2
- import { Context, Effect, Layer, Result, Stream } from "effect";
4
+ import { Context, Effect, Layer, Result } from "effect";
3
5
 
4
6
  import type { Environment, LogFile, ReadOptions } from "./types";
5
7
 
6
- import { K8sCommandError } from "#k8s/errors";
7
8
  import { K8sService, K8sServiceLayer } from "#k8s/service";
8
9
  import { ConfigService, ConfigServiceLayer, getToolConfig } from "#config/loader";
9
10
  import type { LogsConfig } from "#config/types";
10
11
  import { LogsNotFoundError, LogsReadError, type LogsError } from "./errors";
12
+ import { collectProcessOutput } from "#shared/exec";
11
13
  import { transformLogOutput } from "./transformers";
12
14
 
13
15
  export const parseLogFiles = (output: string): LogFile[] => {
@@ -46,6 +48,33 @@ export const formatPrettyOutput = (output: string): string => {
46
48
  export const sanitizeShellArg = (input: string): string => `'${input.replace(/'/g, "'\\''")}'`;
47
49
 
48
50
  const readCommandOutput = (output: unknown): string => (typeof output === "string" ? output : "");
51
+ const filterLogLines = (output: string, grep: string | undefined): string => {
52
+ if (!grep) return output;
53
+ const needle = grep.toLowerCase();
54
+ return output
55
+ .split("\n")
56
+ .filter((line) => line.toLowerCase().includes(needle))
57
+ .join("\n");
58
+ };
59
+ const resolveLocalLogPath = (base: string, file: string): string | undefined => {
60
+ const resolvedBase = resolve(base);
61
+ const resolvedPath = resolve(resolvedBase, file);
62
+ const relativePath = relative(resolvedBase, resolvedPath);
63
+ return relativePath === ".." ||
64
+ relativePath.startsWith(`..${pathSeparator}`) ||
65
+ isAbsolute(relativePath)
66
+ ? undefined
67
+ : resolvedPath;
68
+ };
69
+ const pathSeparator = process.platform === "win32" ? "\\" : "/";
70
+ const resolveRemoteLogPath = (base: string, file: string): string | undefined => {
71
+ const resolvedBase = posix.resolve(base);
72
+ const resolvedPath = posix.resolve(resolvedBase, file);
73
+ const relativePath = posix.relative(resolvedBase, resolvedPath);
74
+ return relativePath === ".." || relativePath.startsWith("../") || posix.isAbsolute(relativePath)
75
+ ? undefined
76
+ : resolvedPath;
77
+ };
49
78
 
50
79
  export class LogsService extends Context.Service<
51
80
  LogsService,
@@ -73,15 +102,7 @@ export class LogsService extends Context.Service<
73
102
  stderr: "pipe",
74
103
  });
75
104
  const process = yield* executor.spawn(command);
76
-
77
- const stdoutChunk = yield* process.stdout.pipe(Stream.decodeText(), Stream.runCollect);
78
- const stderrChunk = yield* process.stderr.pipe(Stream.decodeText(), Stream.runCollect);
79
-
80
- const stdout = stdoutChunk.join("");
81
- const stderr = stderrChunk.join("");
82
- const exitCode = yield* process.exitCode;
83
-
84
- return { stdout, stderr, exitCode };
105
+ return yield* collectProcessOutput(process);
85
106
  }),
86
107
  ).pipe(
87
108
  Effect.catch((platformError) =>
@@ -93,6 +114,22 @@ export class LogsService extends Context.Service<
93
114
  ),
94
115
  );
95
116
 
117
+ const runDirectCommand = (executable: string, args: readonly string[]) =>
118
+ Effect.scoped(
119
+ Effect.gen(function* () {
120
+ const command = ChildProcess.make(executable, args, {
121
+ stdout: "pipe",
122
+ stderr: "pipe",
123
+ });
124
+ const process = yield* executor.spawn(command);
125
+ return yield* collectProcessOutput(process);
126
+ }),
127
+ ).pipe(
128
+ Effect.catch((platformError) =>
129
+ Effect.succeed({ stdout: "", stderr: String(platformError), exitCode: -1 }),
130
+ ),
131
+ );
132
+
96
133
  const getLogsConfig = (profile?: string): LogsConfig | undefined =>
97
134
  getToolConfig<LogsConfig>(config, "logs", profile);
98
135
 
@@ -196,27 +233,45 @@ export class LogsService extends Context.Service<
196
233
  logFile = latestPath.split("/").pop() ?? latestPath;
197
234
  }
198
235
 
199
- const fullPath = `${localDir}/${logFile}`;
200
- let command = `tail -${options.tail} ${sanitizeShellArg(fullPath)}`;
201
-
202
- if (options.grep) {
203
- command += ` | grep -i ${sanitizeShellArg(options.grep)}`;
236
+ const lexicalPath = resolveLocalLogPath(localDir, logFile);
237
+ if (lexicalPath === undefined) {
238
+ return yield* new LogsReadError({
239
+ message: "Log file must stay within the configured local log directory.",
240
+ source: localDir,
241
+ });
204
242
  }
205
-
243
+ const realpathResult = yield* runDirectCommand("realpath", [localDir, lexicalPath]);
244
+ if (realpathResult.exitCode !== 0) {
245
+ return yield* new LogsReadError({
246
+ message:
247
+ realpathResult.stderr.trim() ||
248
+ `realpath failed with exit code ${realpathResult.exitCode}`,
249
+ source: lexicalPath,
250
+ });
251
+ }
252
+ const [canonicalBase, canonicalPath] = realpathResult.stdout.trim().split("\n");
253
+ if (
254
+ canonicalBase === undefined ||
255
+ canonicalPath === undefined ||
256
+ resolveLocalLogPath(canonicalBase, canonicalPath) !== canonicalPath ||
257
+ !canonicalPath.endsWith(".log")
258
+ ) {
259
+ return yield* new LogsReadError({
260
+ message: "Canonical log path escapes the configured local log directory.",
261
+ source: localDir,
262
+ });
263
+ }
264
+ const command = `tail -${options.tail} ${sanitizeShellArg(canonicalPath)}`;
206
265
  const result = yield* runShellCommand(command);
207
266
 
208
- if (result.exitCode !== 0 && result.exitCode !== 1) {
267
+ if (result.exitCode !== 0) {
209
268
  return yield* new LogsReadError({
210
269
  message: result.stderr.trim() || `Command failed with exit code ${result.exitCode}`,
211
- source: fullPath,
270
+ source: canonicalPath,
212
271
  });
213
272
  }
214
273
 
215
- if (result.exitCode === 1 && options.grep) {
216
- return "(no matching lines)";
217
- }
218
-
219
- const output = result.stdout.trim();
274
+ const output = filterLogLines(result.stdout, options.grep).trim();
220
275
  if (!output) {
221
276
  return "(no matching lines)";
222
277
  }
@@ -231,6 +286,14 @@ export class LogsService extends Context.Service<
231
286
  ) {
232
287
  const remotePath = logsConfig.remotePath;
233
288
  const kubernetesProfile = logsConfig.kubernetesProfile;
289
+ const logFile = options.file ?? "app.log";
290
+ const logPath = resolveRemoteLogPath(remotePath, logFile);
291
+ if (logPath === undefined) {
292
+ return yield* new LogsReadError({
293
+ message: "Log file must stay within the configured remote log directory.",
294
+ source: remotePath,
295
+ });
296
+ }
234
297
 
235
298
  const podResult = yield* k8s
236
299
  .runKubectl(
@@ -249,38 +312,22 @@ export class LogsService extends Context.Service<
249
312
  );
250
313
 
251
314
  const pod = readCommandOutput(podResult.output).replace(/'/g, "");
252
- const logFile = options.file ?? "app.log";
253
- const logPath = `${remotePath}/${logFile}`;
254
- let command = `tail -${options.tail} ${sanitizeShellArg(logPath)}`;
255
-
256
- if (options.grep) {
257
- command += ` | grep -i ${sanitizeShellArg(options.grep)}`;
258
- }
259
-
260
315
  const execResult = yield* k8s
261
- .runKubectl(`exec ${pod} -- sh -c "${command}"`, false, kubernetesProfile)
316
+ .runLogTail(pod, remotePath, logPath, options.tail, kubernetesProfile)
262
317
  .pipe(Effect.result);
263
318
 
264
319
  return yield* Result.match(execResult, {
265
- onFailure: (error) => {
266
- if (error instanceof K8sCommandError && error.exitCode === 1 && options.grep) {
267
- return Effect.succeed("(no matching lines)");
268
- }
269
-
270
- return Effect.fail(
320
+ onFailure: (error) =>
321
+ Effect.fail(
271
322
  new LogsReadError({
272
323
  message: error instanceof Error ? error.message : "Failed to read remote logs",
273
324
  source: `${pod}:${logPath}`,
274
325
  }),
275
- );
276
- },
326
+ ),
277
327
  onSuccess: (result) => {
278
- const trimmed = readCommandOutput(result.output).trim();
279
- if (!trimmed) {
280
- return Effect.succeed("(no matching lines)");
281
- }
282
-
283
- return Effect.succeed(transformLogOutput(trimmed));
328
+ const output = readCommandOutput(result.output);
329
+ const trimmed = filterLogLines(output, options.grep).trim();
330
+ return Effect.succeed(trimmed ? transformLogOutput(trimmed) : "(no matching lines)");
284
331
  },
285
332
  });
286
333
  });
@@ -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
+ });