matchlock-sdk 0.1.22

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/dist/client.js ADDED
@@ -0,0 +1,966 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Client = void 0;
4
+ exports.defaultConfig = defaultConfig;
5
+ const node_events_1 = require("node:events");
6
+ const node_child_process_1 = require("node:child_process");
7
+ const node_net_1 = require("node:net");
8
+ const node_util_1 = require("node:util");
9
+ const minimatch_1 = require("minimatch");
10
+ const builder_1 = require("./builder");
11
+ const errors_1 = require("./errors");
12
+ const types_1 = require("./types");
13
+ const DEFAULT_CPUS = 1;
14
+ const DEFAULT_MEMORY_MB = 512;
15
+ const DEFAULT_DISK_SIZE_MB = 5120;
16
+ const DEFAULT_TIMEOUT_SECONDS = 300;
17
+ const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
18
+ function defaultConfig(config = {}) {
19
+ return {
20
+ binaryPath: config.binaryPath ?? process.env.MATCHLOCK_BIN ?? "matchlock",
21
+ useSudo: config.useSudo ?? false,
22
+ };
23
+ }
24
+ function toError(value) {
25
+ if (value instanceof Error) {
26
+ return value;
27
+ }
28
+ return new Error(String(value));
29
+ }
30
+ function toBuffer(content) {
31
+ if (typeof content === "string") {
32
+ return Buffer.from(content, "utf8");
33
+ }
34
+ if (Buffer.isBuffer(content)) {
35
+ return content;
36
+ }
37
+ if (content instanceof Uint8Array) {
38
+ return Buffer.from(content);
39
+ }
40
+ if (content instanceof ArrayBuffer) {
41
+ return Buffer.from(content);
42
+ }
43
+ throw new errors_1.MatchlockError("unsupported content type");
44
+ }
45
+ function lowerSet(values) {
46
+ return new Set((values ?? []).map((value) => value.toLowerCase()));
47
+ }
48
+ function asObject(value) {
49
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
50
+ return {};
51
+ }
52
+ return value;
53
+ }
54
+ function asNumber(value, fallback = 0) {
55
+ if (typeof value === "number" && Number.isFinite(value)) {
56
+ return value;
57
+ }
58
+ return fallback;
59
+ }
60
+ function asString(value, fallback = "") {
61
+ return typeof value === "string" ? value : fallback;
62
+ }
63
+ function getUID() {
64
+ return typeof process.geteuid === "function" ? process.geteuid() : 0;
65
+ }
66
+ function getGID() {
67
+ return typeof process.getegid === "function" ? process.getegid() : 0;
68
+ }
69
+ class Client {
70
+ config;
71
+ process;
72
+ requestID = 0;
73
+ pending = new Map();
74
+ writeLock = Promise.resolve();
75
+ readBuffer = "";
76
+ vmIDValue = "";
77
+ lastVMID = "";
78
+ closed = false;
79
+ closing = false;
80
+ vfsHooks = [];
81
+ vfsMutateHooks = [];
82
+ vfsActionHooks = [];
83
+ vfsHookActive = false;
84
+ constructor(config = {}) {
85
+ this.config = defaultConfig(config);
86
+ }
87
+ get vmId() {
88
+ return this.vmIDValue;
89
+ }
90
+ async start() {
91
+ if (this.closed) {
92
+ throw new errors_1.MatchlockError("client is closed");
93
+ }
94
+ if (this.process && this.process.exitCode === null && !this.process.killed) {
95
+ return;
96
+ }
97
+ const command = this.config.useSudo ? "sudo" : this.config.binaryPath;
98
+ const args = this.config.useSudo
99
+ ? [this.config.binaryPath, "rpc"]
100
+ : ["rpc"];
101
+ const child = (0, node_child_process_1.spawn)(command, args, {
102
+ stdio: ["pipe", "pipe", "pipe"],
103
+ });
104
+ child.stderr.on("data", () => {
105
+ // Drain stderr so the child cannot block on full pipes.
106
+ });
107
+ child.stdout.on("data", (chunk) => {
108
+ this.readBuffer += chunk.toString("utf8");
109
+ this.processReadBuffer();
110
+ });
111
+ child.on("close", () => {
112
+ this.handleProcessClosed();
113
+ });
114
+ child.on("error", (error) => {
115
+ this.handleProcessClosed(error);
116
+ });
117
+ this.process = child;
118
+ }
119
+ async close(timeoutSeconds = 0) {
120
+ if (this.closed || this.closing) {
121
+ return;
122
+ }
123
+ this.closing = true;
124
+ this.lastVMID = this.vmIDValue;
125
+ this.setLocalVFSHooks([], [], []);
126
+ try {
127
+ if (!this.isRunning()) {
128
+ return;
129
+ }
130
+ const effectiveTimeout = timeoutSeconds > 0 ? timeoutSeconds : 2;
131
+ try {
132
+ await this.sendRequest("close", { timeout_seconds: effectiveTimeout }, {
133
+ timeoutMs: (effectiveTimeout + 5) * 1000,
134
+ });
135
+ }
136
+ catch {
137
+ // Best effort shutdown.
138
+ }
139
+ if (this.process?.stdin.writable) {
140
+ this.process.stdin.end();
141
+ }
142
+ await this.waitForProcessExit(effectiveTimeout * 1000);
143
+ }
144
+ finally {
145
+ this.closed = true;
146
+ this.closing = false;
147
+ }
148
+ }
149
+ async remove() {
150
+ const vmID = this.vmIDValue || this.lastVMID;
151
+ if (!vmID) {
152
+ return;
153
+ }
154
+ try {
155
+ await execFileAsync(this.config.binaryPath, ["rm", vmID]);
156
+ }
157
+ catch (error) {
158
+ const err = toError(error);
159
+ throw new errors_1.MatchlockError(`matchlock rm ${vmID}: ${err.message}`);
160
+ }
161
+ }
162
+ async launch(sandbox) {
163
+ return this.create(sandbox.options());
164
+ }
165
+ async create(opts = {}) {
166
+ const options = (0, builder_1.cloneCreateOptions)(opts);
167
+ if (!options.image) {
168
+ throw new errors_1.MatchlockError("image is required (e.g., alpine:latest)");
169
+ }
170
+ if ((options.networkMtu ?? 0) < 0) {
171
+ throw new errors_1.MatchlockError("network mtu must be > 0");
172
+ }
173
+ for (const mapping of options.addHosts ?? []) {
174
+ this.validateAddHost(mapping);
175
+ }
176
+ const [wireVFS, localHooks, localMutateHooks, localActionHooks] = this.compileVFSHooks(options.vfsInterception);
177
+ const resources = {
178
+ cpus: options.cpus || DEFAULT_CPUS,
179
+ memory_mb: options.memoryMb || DEFAULT_MEMORY_MB,
180
+ disk_size_mb: options.diskSizeMb || DEFAULT_DISK_SIZE_MB,
181
+ timeout_seconds: options.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS,
182
+ };
183
+ const params = {
184
+ image: options.image,
185
+ resources,
186
+ };
187
+ if (options.privileged) {
188
+ params.privileged = true;
189
+ }
190
+ const network = this.buildCreateNetworkParams(options);
191
+ if (network) {
192
+ params.network = network;
193
+ }
194
+ if ((options.mounts && Object.keys(options.mounts).length > 0) ||
195
+ options.workspace ||
196
+ wireVFS) {
197
+ const vfs = {};
198
+ if (options.mounts && Object.keys(options.mounts).length > 0) {
199
+ const mounts = {};
200
+ for (const [guestPath, config] of Object.entries(options.mounts)) {
201
+ const mount = {
202
+ type: config.type ?? "memory",
203
+ };
204
+ if (config.hostPath) {
205
+ mount.host_path = config.hostPath;
206
+ }
207
+ if (config.readonly) {
208
+ mount.readonly = true;
209
+ }
210
+ mounts[guestPath] = mount;
211
+ }
212
+ vfs.mounts = mounts;
213
+ }
214
+ if (options.workspace) {
215
+ vfs.workspace = options.workspace;
216
+ }
217
+ if (wireVFS) {
218
+ vfs.interception = wireVFS;
219
+ }
220
+ params.vfs = vfs;
221
+ }
222
+ if (options.env && Object.keys(options.env).length > 0) {
223
+ params.env = options.env;
224
+ }
225
+ if (options.imageConfig) {
226
+ const imageConfig = {};
227
+ if (options.imageConfig.user) {
228
+ imageConfig.user = options.imageConfig.user;
229
+ }
230
+ if (options.imageConfig.workingDir) {
231
+ imageConfig.working_dir = options.imageConfig.workingDir;
232
+ }
233
+ if (options.imageConfig.entrypoint) {
234
+ imageConfig.entrypoint = [...options.imageConfig.entrypoint];
235
+ }
236
+ if (options.imageConfig.cmd) {
237
+ imageConfig.cmd = [...options.imageConfig.cmd];
238
+ }
239
+ if (options.imageConfig.env) {
240
+ imageConfig.env = { ...options.imageConfig.env };
241
+ }
242
+ params.image_config = imageConfig;
243
+ }
244
+ const result = asObject(await this.sendRequest("create", params));
245
+ const id = asString(result.id);
246
+ if (!id) {
247
+ throw new errors_1.MatchlockError("invalid create response: missing id");
248
+ }
249
+ this.vmIDValue = id;
250
+ this.setLocalVFSHooks(localHooks, localMutateHooks, localActionHooks);
251
+ if ((options.portForwards ?? []).length > 0) {
252
+ await this.portForwardMappings(options.portForwardAddresses, options.portForwards ?? []);
253
+ }
254
+ return this.vmIDValue;
255
+ }
256
+ resolveCreateBlockPrivateIPs(opts) {
257
+ if (opts.blockPrivateIPsSet) {
258
+ return { value: !!opts.blockPrivateIPs, hasOverride: true };
259
+ }
260
+ if (opts.blockPrivateIPs) {
261
+ return { value: true, hasOverride: true };
262
+ }
263
+ return { value: false, hasOverride: false };
264
+ }
265
+ buildCreateNetworkParams(opts) {
266
+ const hasAllowedHosts = (opts.allowedHosts?.length ?? 0) > 0;
267
+ const hasAddHosts = (opts.addHosts?.length ?? 0) > 0;
268
+ const hasSecrets = (opts.secrets?.length ?? 0) > 0;
269
+ const hasDNSServers = (opts.dnsServers?.length ?? 0) > 0;
270
+ const hasHostname = (opts.hostname?.length ?? 0) > 0;
271
+ const hasMTU = (opts.networkMtu ?? 0) > 0;
272
+ const blockPrivate = this.resolveCreateBlockPrivateIPs(opts);
273
+ const includeNetwork = hasAllowedHosts ||
274
+ hasAddHosts ||
275
+ hasSecrets ||
276
+ hasDNSServers ||
277
+ hasHostname ||
278
+ hasMTU ||
279
+ blockPrivate.hasOverride;
280
+ if (!includeNetwork) {
281
+ return undefined;
282
+ }
283
+ const network = {
284
+ allowed_hosts: opts.allowedHosts ?? [],
285
+ block_private_ips: blockPrivate.hasOverride ? blockPrivate.value : true,
286
+ };
287
+ if (hasAddHosts) {
288
+ network.add_hosts = (opts.addHosts ?? []).map((mapping) => ({
289
+ host: mapping.host,
290
+ ip: mapping.ip,
291
+ }));
292
+ }
293
+ if (hasSecrets) {
294
+ const secrets = {};
295
+ for (const secret of opts.secrets ?? []) {
296
+ secrets[secret.name] = {
297
+ value: secret.value,
298
+ hosts: secret.hosts ?? [],
299
+ };
300
+ }
301
+ network.secrets = secrets;
302
+ }
303
+ if (hasDNSServers) {
304
+ network.dns_servers = opts.dnsServers ?? [];
305
+ }
306
+ if (hasHostname) {
307
+ network.hostname = opts.hostname ?? "";
308
+ }
309
+ if (hasMTU) {
310
+ network.mtu = opts.networkMtu ?? 0;
311
+ }
312
+ return network;
313
+ }
314
+ async exec(command, options = {}) {
315
+ return this.execWithDir(command, options.workingDir ?? "", options);
316
+ }
317
+ async execWithDir(command, workingDir = "", options = {}) {
318
+ const params = { command };
319
+ if (workingDir) {
320
+ params.working_dir = workingDir;
321
+ }
322
+ const result = asObject(await this.sendRequest("exec", params, options));
323
+ return {
324
+ exitCode: asNumber(result.exit_code),
325
+ stdout: Buffer.from(asString(result.stdout), "base64").toString("utf8"),
326
+ stderr: Buffer.from(asString(result.stderr), "base64").toString("utf8"),
327
+ durationMs: asNumber(result.duration_ms),
328
+ };
329
+ }
330
+ async execStream(command, options = {}) {
331
+ return this.execStreamWithDir(command, options.workingDir ?? "", options.stdout, options.stderr, options);
332
+ }
333
+ async execStreamWithDir(command, workingDir = "", stdout, stderr, options = {}) {
334
+ const params = { command };
335
+ if (workingDir) {
336
+ params.working_dir = workingDir;
337
+ }
338
+ const onNotification = (method, payload) => {
339
+ const data = asString(payload.data);
340
+ if (!data) {
341
+ return;
342
+ }
343
+ let decoded;
344
+ try {
345
+ decoded = Buffer.from(data, "base64");
346
+ }
347
+ catch {
348
+ return;
349
+ }
350
+ if (method === "exec_stream.stdout") {
351
+ this.writeStreamChunk(stdout, decoded);
352
+ }
353
+ else if (method === "exec_stream.stderr") {
354
+ this.writeStreamChunk(stderr, decoded);
355
+ }
356
+ };
357
+ const result = asObject(await this.sendRequest("exec_stream", params, options, onNotification));
358
+ return {
359
+ exitCode: asNumber(result.exit_code),
360
+ durationMs: asNumber(result.duration_ms),
361
+ };
362
+ }
363
+ async writeFile(path, content, options = {}) {
364
+ await this.writeFileMode(path, content, 0o644, options);
365
+ }
366
+ async writeFileMode(path, content, mode, options = {}) {
367
+ const original = toBuffer(content);
368
+ await this.applyLocalActionHooks(types_1.VFS_HOOK_OP_WRITE, path, original.length, mode);
369
+ const mutated = await this.applyLocalWriteMutations(path, original, mode);
370
+ await this.sendRequest("write_file", {
371
+ path,
372
+ content: mutated.toString("base64"),
373
+ mode,
374
+ }, options);
375
+ }
376
+ async readFile(path, options = {}) {
377
+ await this.applyLocalActionHooks(types_1.VFS_HOOK_OP_READ, path, 0, 0);
378
+ const result = asObject(await this.sendRequest("read_file", { path }, options));
379
+ return Buffer.from(asString(result.content), "base64");
380
+ }
381
+ async listFiles(path, options = {}) {
382
+ await this.applyLocalActionHooks(types_1.VFS_HOOK_OP_READDIR, path, 0, 0);
383
+ const result = asObject(await this.sendRequest("list_files", { path }, options));
384
+ const files = Array.isArray(result.files) ? result.files : [];
385
+ return files.map((entry) => {
386
+ const file = asObject(entry);
387
+ return {
388
+ name: asString(file.name),
389
+ size: asNumber(file.size),
390
+ mode: asNumber(file.mode),
391
+ isDir: Boolean(file.is_dir),
392
+ };
393
+ });
394
+ }
395
+ async portForward(...specs) {
396
+ return this.portForwardWithAddresses(undefined, ...specs);
397
+ }
398
+ async portForwardWithAddresses(addresses, ...specs) {
399
+ const forwards = this.parsePortForwards(specs);
400
+ return this.portForwardMappings(addresses, forwards);
401
+ }
402
+ async portForwardMappings(addresses, forwards) {
403
+ if (forwards.length === 0) {
404
+ return [];
405
+ }
406
+ const wireForwards = forwards.map((forward) => ({
407
+ local_port: forward.localPort,
408
+ remote_port: forward.remotePort,
409
+ }));
410
+ const result = asObject(await this.sendRequest("port_forward", {
411
+ forwards: wireForwards,
412
+ addresses: addresses && addresses.length > 0 ? [...addresses] : ["127.0.0.1"],
413
+ }));
414
+ const bindings = Array.isArray(result.bindings) ? result.bindings : [];
415
+ return bindings.map((entry) => {
416
+ const binding = asObject(entry);
417
+ return {
418
+ address: asString(binding.address),
419
+ localPort: asNumber(binding.local_port),
420
+ remotePort: asNumber(binding.remote_port),
421
+ };
422
+ });
423
+ }
424
+ parsePortForwards(specs) {
425
+ return specs.map((spec) => this.parsePortForward(spec));
426
+ }
427
+ parsePortForward(spec) {
428
+ const trimmed = spec.trim();
429
+ if (!trimmed) {
430
+ throw new errors_1.MatchlockError('invalid port-forward spec: empty spec');
431
+ }
432
+ const parts = trimmed.split(":");
433
+ if (parts.length === 1) {
434
+ const remotePort = this.parsePort(parts[0], "remote");
435
+ return { localPort: remotePort, remotePort };
436
+ }
437
+ if (parts.length === 2) {
438
+ const localPort = this.parsePort(parts[0], "local");
439
+ const remotePort = this.parsePort(parts[1], "remote");
440
+ return { localPort, remotePort };
441
+ }
442
+ throw new errors_1.MatchlockError(`invalid port-forward spec: ${JSON.stringify(trimmed)} (expected [LOCAL_PORT:]REMOTE_PORT)`);
443
+ }
444
+ parsePort(raw, role) {
445
+ const value = raw.trim();
446
+ if (!value) {
447
+ throw new errors_1.MatchlockError(`invalid port-forward spec: empty ${role} port`);
448
+ }
449
+ const port = Number.parseInt(value, 10);
450
+ if (!Number.isFinite(port)) {
451
+ throw new errors_1.MatchlockError(`invalid port value ${JSON.stringify(value)}`);
452
+ }
453
+ if (port < 1 || port > 65535) {
454
+ throw new errors_1.MatchlockError(`invalid port value ${port}: must be in range 1-65535`);
455
+ }
456
+ return port;
457
+ }
458
+ async sendRequest(method, params, options = {}, onNotification) {
459
+ if (this.closed) {
460
+ throw new errors_1.MatchlockError("client is closed");
461
+ }
462
+ await this.start();
463
+ if (!this.isRunning()) {
464
+ throw new errors_1.MatchlockError("Matchlock process not running");
465
+ }
466
+ const id = ++this.requestID;
467
+ const request = {
468
+ jsonrpc: "2.0",
469
+ method,
470
+ id,
471
+ };
472
+ if (params && Object.keys(params).length > 0) {
473
+ request.params = params;
474
+ }
475
+ let resolvePending = () => { };
476
+ let rejectPending = () => { };
477
+ const resultPromise = new Promise((resolve, reject) => {
478
+ resolvePending = resolve;
479
+ rejectPending = reject;
480
+ });
481
+ this.pending.set(id, {
482
+ resolve: resolvePending,
483
+ reject: rejectPending,
484
+ onNotification,
485
+ });
486
+ let timeoutHandle;
487
+ const onAbort = () => {
488
+ this.sendCancelRequest(id);
489
+ const reason = options.signal?.reason;
490
+ if (reason instanceof Error) {
491
+ rejectPending(reason);
492
+ }
493
+ else {
494
+ rejectPending(new errors_1.MatchlockError(`request ${method} was aborted`));
495
+ }
496
+ };
497
+ try {
498
+ if (options.signal?.aborted) {
499
+ onAbort();
500
+ }
501
+ else if (options.signal) {
502
+ options.signal.addEventListener("abort", onAbort, { once: true });
503
+ }
504
+ if ((options.timeoutMs ?? 0) > 0) {
505
+ timeoutHandle = setTimeout(() => {
506
+ this.sendCancelRequest(id);
507
+ rejectPending(new errors_1.MatchlockError(`request ${method} (id=${id}) timed out after ${options.timeoutMs}ms`));
508
+ }, options.timeoutMs);
509
+ }
510
+ await this.enqueueWrite(`${JSON.stringify(request)}\n`);
511
+ return await resultPromise;
512
+ }
513
+ finally {
514
+ this.pending.delete(id);
515
+ if (timeoutHandle) {
516
+ clearTimeout(timeoutHandle);
517
+ }
518
+ if (options.signal) {
519
+ options.signal.removeEventListener("abort", onAbort);
520
+ }
521
+ }
522
+ }
523
+ sendCancelRequest(targetID) {
524
+ const request = {
525
+ jsonrpc: "2.0",
526
+ method: "cancel",
527
+ params: { id: targetID },
528
+ id: ++this.requestID,
529
+ };
530
+ void this.enqueueWrite(`${JSON.stringify(request)}\n`).catch(() => {
531
+ // Ignore cancellation write errors.
532
+ });
533
+ }
534
+ async enqueueWrite(line) {
535
+ this.writeLock = this.writeLock
536
+ .catch(() => {
537
+ // Keep queue alive.
538
+ })
539
+ .then(async () => {
540
+ if (!this.process || !this.process.stdin.writable) {
541
+ throw new errors_1.MatchlockError("Matchlock process not running");
542
+ }
543
+ await new Promise((resolve, reject) => {
544
+ this.process?.stdin.write(line, (error) => {
545
+ if (error) {
546
+ reject(error);
547
+ return;
548
+ }
549
+ resolve();
550
+ });
551
+ });
552
+ });
553
+ return this.writeLock;
554
+ }
555
+ processReadBuffer() {
556
+ for (;;) {
557
+ const newlineIndex = this.readBuffer.indexOf("\n");
558
+ if (newlineIndex === -1) {
559
+ break;
560
+ }
561
+ const line = this.readBuffer.slice(0, newlineIndex).trim();
562
+ this.readBuffer = this.readBuffer.slice(newlineIndex + 1);
563
+ if (!line) {
564
+ continue;
565
+ }
566
+ this.handleMessage(line);
567
+ }
568
+ }
569
+ handleMessage(line) {
570
+ let parsed;
571
+ try {
572
+ parsed = JSON.parse(line);
573
+ }
574
+ catch {
575
+ return;
576
+ }
577
+ if (typeof parsed.id !== "number") {
578
+ this.handleNotification(parsed);
579
+ return;
580
+ }
581
+ const pending = this.pending.get(parsed.id);
582
+ if (!pending) {
583
+ return;
584
+ }
585
+ if (parsed.error) {
586
+ pending.reject(new errors_1.RPCError(parsed.error.code, parsed.error.message));
587
+ return;
588
+ }
589
+ pending.resolve(parsed.result ?? null);
590
+ }
591
+ handleNotification(msg) {
592
+ const method = msg.method;
593
+ const params = msg.params ?? {};
594
+ if (method === "exec_stream.stdout" || method === "exec_stream.stderr") {
595
+ const reqID = asNumber(params.id, -1);
596
+ if (reqID < 0) {
597
+ return;
598
+ }
599
+ const pending = this.pending.get(reqID);
600
+ if (pending?.onNotification) {
601
+ pending.onNotification(method, params);
602
+ }
603
+ return;
604
+ }
605
+ if (method === "event") {
606
+ this.handleVFSFileEventNotification(params);
607
+ }
608
+ }
609
+ handleVFSFileEventNotification(params) {
610
+ const file = asObject(params.file);
611
+ if (Object.keys(file).length === 0) {
612
+ return;
613
+ }
614
+ const op = asString(file.op).toLowerCase();
615
+ if (!op) {
616
+ return;
617
+ }
618
+ const event = {
619
+ op,
620
+ path: asString(file.path),
621
+ size: asNumber(file.size),
622
+ mode: asNumber(file.mode),
623
+ uid: asNumber(file.uid),
624
+ gid: asNumber(file.gid),
625
+ };
626
+ this.handleVFSFileEvent(event);
627
+ }
628
+ handleVFSFileEvent(event) {
629
+ const hooks = [...this.vfsHooks];
630
+ if (hooks.length === 0) {
631
+ return;
632
+ }
633
+ const safeHooks = [];
634
+ for (const hook of hooks) {
635
+ if (!this.matchesVFSHook(hook.ops, hook.path, event.op, event.path)) {
636
+ continue;
637
+ }
638
+ if (hook.dangerous) {
639
+ void this.runSingleVFSHook(hook, event);
640
+ continue;
641
+ }
642
+ safeHooks.push(hook);
643
+ }
644
+ if (safeHooks.length === 0) {
645
+ return;
646
+ }
647
+ if (this.vfsHookActive) {
648
+ return;
649
+ }
650
+ void this.runVFSSafeHooksForEvent(safeHooks, event);
651
+ }
652
+ async runVFSSafeHooksForEvent(hooks, event) {
653
+ if (this.vfsHookActive) {
654
+ return;
655
+ }
656
+ this.vfsHookActive = true;
657
+ try {
658
+ for (const hook of hooks) {
659
+ await this.runSingleVFSHook(hook, event);
660
+ }
661
+ }
662
+ finally {
663
+ this.vfsHookActive = false;
664
+ }
665
+ }
666
+ async runSingleVFSHook(hook, event) {
667
+ try {
668
+ const run = hook.callback(this, event);
669
+ if (hook.timeoutMs > 0) {
670
+ await Promise.race([
671
+ run,
672
+ new Promise((resolve) => {
673
+ setTimeout(resolve, hook.timeoutMs);
674
+ }),
675
+ ]);
676
+ }
677
+ else {
678
+ await run;
679
+ }
680
+ }
681
+ catch {
682
+ // Hooks are intentionally best effort.
683
+ }
684
+ }
685
+ matchesVFSHook(ops, path, op, actualPath) {
686
+ if (ops.size > 0 && !ops.has(op.toLowerCase())) {
687
+ return false;
688
+ }
689
+ if (!path) {
690
+ return true;
691
+ }
692
+ try {
693
+ return (0, minimatch_1.minimatch)(actualPath, path, { dot: true });
694
+ }
695
+ catch {
696
+ return false;
697
+ }
698
+ }
699
+ async applyLocalWriteMutations(path, content, mode) {
700
+ const hooks = [...this.vfsMutateHooks];
701
+ if (hooks.length === 0) {
702
+ return content;
703
+ }
704
+ let current = content;
705
+ for (const hook of hooks) {
706
+ if (!this.matchesVFSHook(hook.ops, hook.path, types_1.VFS_HOOK_OP_WRITE, path)) {
707
+ continue;
708
+ }
709
+ const request = {
710
+ path,
711
+ size: current.length,
712
+ mode,
713
+ uid: getUID(),
714
+ gid: getGID(),
715
+ };
716
+ const mutated = await hook.callback(request);
717
+ if (mutated === null || mutated === undefined) {
718
+ continue;
719
+ }
720
+ if (typeof mutated === "string") {
721
+ current = Buffer.from(mutated, "utf8");
722
+ continue;
723
+ }
724
+ if (Buffer.isBuffer(mutated) ||
725
+ mutated instanceof Uint8Array ||
726
+ mutated instanceof ArrayBuffer) {
727
+ current = toBuffer(mutated);
728
+ continue;
729
+ }
730
+ throw new errors_1.MatchlockError(`invalid mutate_hook return type for ${JSON.stringify(hook.name)}: expected bytes|string|undefined`);
731
+ }
732
+ return current;
733
+ }
734
+ async applyLocalActionHooks(op, path, size, mode) {
735
+ const hooks = [...this.vfsActionHooks];
736
+ if (hooks.length === 0) {
737
+ return;
738
+ }
739
+ const request = {
740
+ op,
741
+ path,
742
+ size,
743
+ mode,
744
+ uid: getUID(),
745
+ gid: getGID(),
746
+ };
747
+ for (const hook of hooks) {
748
+ if (!this.matchesVFSHook(hook.ops, hook.path, op, path)) {
749
+ continue;
750
+ }
751
+ const decision = String(await hook.callback(request)).trim().toLowerCase();
752
+ if (decision === "" || decision === types_1.VFS_HOOK_ACTION_ALLOW) {
753
+ continue;
754
+ }
755
+ if (decision === types_1.VFS_HOOK_ACTION_BLOCK) {
756
+ throw new errors_1.MatchlockError(`vfs action hook blocked operation: op=${op} path=${path} hook=${JSON.stringify(hook.name)}`);
757
+ }
758
+ throw new errors_1.MatchlockError(`invalid action_hook return value for ${JSON.stringify(hook.name)}: expected ${JSON.stringify(types_1.VFS_HOOK_ACTION_ALLOW)}|${JSON.stringify(types_1.VFS_HOOK_ACTION_BLOCK)}, got ${JSON.stringify(decision)}`);
759
+ }
760
+ }
761
+ compileVFSHooks(cfg) {
762
+ if (!cfg) {
763
+ return [undefined, [], [], []];
764
+ }
765
+ const wire = {
766
+ emit_events: cfg.emitEvents,
767
+ rules: [],
768
+ };
769
+ const localHooks = [];
770
+ const localMutateHooks = [];
771
+ const localActionHooks = [];
772
+ for (const rule of cfg.rules ?? []) {
773
+ const callbackCount = Number(Boolean(rule.hook)) + Number(Boolean(rule.dangerousHook)) + Number(Boolean(rule.mutateHook)) + Number(Boolean(rule.actionHook));
774
+ if (callbackCount > 1) {
775
+ throw new errors_1.MatchlockError(`invalid vfs hook ${JSON.stringify(rule.name ?? "")}: cannot set more than one callback hook`);
776
+ }
777
+ if (!rule.hook &&
778
+ !rule.dangerousHook &&
779
+ !rule.mutateHook &&
780
+ !rule.actionHook) {
781
+ const action = String(rule.action ?? "allow").trim().toLowerCase();
782
+ if (action === "mutate_write") {
783
+ throw new errors_1.MatchlockError(`invalid vfs hook ${JSON.stringify(rule.name ?? "")}: mutate_write requires mutate_hook callback`);
784
+ }
785
+ wire.rules?.push(this.ruleToWire(rule, action));
786
+ continue;
787
+ }
788
+ if (rule.hook) {
789
+ this.validateLocalAfterRule(rule, "callback hooks");
790
+ localHooks.push({
791
+ name: rule.name ?? "",
792
+ ops: lowerSet(rule.ops),
793
+ path: rule.path ?? "",
794
+ timeoutMs: rule.timeoutMs ?? 0,
795
+ dangerous: false,
796
+ callback: async (_client, event) => {
797
+ await rule.hook?.(event);
798
+ },
799
+ });
800
+ continue;
801
+ }
802
+ if (rule.dangerousHook) {
803
+ this.validateLocalAfterRule(rule, "dangerous_hook");
804
+ localHooks.push({
805
+ name: rule.name ?? "",
806
+ ops: lowerSet(rule.ops),
807
+ path: rule.path ?? "",
808
+ timeoutMs: rule.timeoutMs ?? 0,
809
+ dangerous: true,
810
+ callback: async (client, event) => {
811
+ await rule.dangerousHook?.(client, event);
812
+ },
813
+ });
814
+ continue;
815
+ }
816
+ if (rule.actionHook) {
817
+ const action = String(rule.action ?? "").trim().toLowerCase();
818
+ if (action && action !== types_1.VFS_HOOK_ACTION_ALLOW) {
819
+ throw new errors_1.MatchlockError(`invalid vfs hook ${JSON.stringify(rule.name ?? "")}: action_hook cannot set action=${JSON.stringify(rule.action)}`);
820
+ }
821
+ if (rule.phase &&
822
+ String(rule.phase).toLowerCase() !== types_1.VFS_HOOK_PHASE_BEFORE) {
823
+ throw new errors_1.MatchlockError(`invalid vfs hook ${JSON.stringify(rule.name ?? "")}: action_hook must use phase=before`);
824
+ }
825
+ localActionHooks.push({
826
+ name: rule.name ?? "",
827
+ ops: lowerSet(rule.ops),
828
+ path: rule.path ?? "",
829
+ callback: async (request) => (await rule.actionHook?.(request)) ?? types_1.VFS_HOOK_ACTION_ALLOW,
830
+ });
831
+ continue;
832
+ }
833
+ const action = String(rule.action ?? "").trim().toLowerCase();
834
+ if (action && action !== types_1.VFS_HOOK_ACTION_ALLOW) {
835
+ throw new errors_1.MatchlockError(`invalid vfs hook ${JSON.stringify(rule.name ?? "")}: mutate_hook cannot set action=${JSON.stringify(rule.action)}`);
836
+ }
837
+ if (rule.phase && String(rule.phase).toLowerCase() !== types_1.VFS_HOOK_PHASE_BEFORE) {
838
+ throw new errors_1.MatchlockError(`invalid vfs hook ${JSON.stringify(rule.name ?? "")}: mutate_hook must use phase=before`);
839
+ }
840
+ localMutateHooks.push({
841
+ name: rule.name ?? "",
842
+ ops: lowerSet(rule.ops),
843
+ path: rule.path ?? "",
844
+ callback: async (request) => rule.mutateHook?.(request),
845
+ });
846
+ }
847
+ if (localHooks.length > 0) {
848
+ wire.emit_events = true;
849
+ }
850
+ if ((wire.rules?.length ?? 0) === 0) {
851
+ wire.rules = undefined;
852
+ }
853
+ if (!wire.emit_events && !wire.rules) {
854
+ return [undefined, localHooks, localMutateHooks, localActionHooks];
855
+ }
856
+ return [wire, localHooks, localMutateHooks, localActionHooks];
857
+ }
858
+ validateLocalAfterRule(rule, label) {
859
+ const action = String(rule.action ?? "").trim().toLowerCase();
860
+ if (action && action !== types_1.VFS_HOOK_ACTION_ALLOW) {
861
+ throw new errors_1.MatchlockError(`invalid vfs hook ${JSON.stringify(rule.name ?? "")}: ${label} cannot set action=${JSON.stringify(rule.action)}`);
862
+ }
863
+ if (String(rule.phase ?? "").toLowerCase() !== types_1.VFS_HOOK_PHASE_AFTER) {
864
+ throw new errors_1.MatchlockError(`invalid vfs hook ${JSON.stringify(rule.name ?? "")}: ${label} must use phase=after`);
865
+ }
866
+ }
867
+ ruleToWire(rule, normalizedAction) {
868
+ const wire = {
869
+ action: normalizedAction,
870
+ };
871
+ if (rule.name) {
872
+ wire.name = rule.name;
873
+ }
874
+ if (rule.phase) {
875
+ wire.phase = rule.phase;
876
+ }
877
+ if (rule.ops && rule.ops.length > 0) {
878
+ wire.ops = [...rule.ops];
879
+ }
880
+ if (rule.path) {
881
+ wire.path = rule.path;
882
+ }
883
+ if ((rule.timeoutMs ?? 0) > 0) {
884
+ wire.timeout_ms = rule.timeoutMs;
885
+ }
886
+ return wire;
887
+ }
888
+ setLocalVFSHooks(hooks, mutateHooks, actionHooks) {
889
+ this.vfsHooks = [...hooks];
890
+ this.vfsMutateHooks = [...mutateHooks];
891
+ this.vfsActionHooks = [...actionHooks];
892
+ this.vfsHookActive = false;
893
+ }
894
+ validateAddHost(mapping) {
895
+ if (!mapping.host || !mapping.host.trim()) {
896
+ throw new errors_1.MatchlockError("invalid add-host mapping: empty host");
897
+ }
898
+ if (/\s/.test(mapping.host)) {
899
+ throw new errors_1.MatchlockError(`invalid add-host mapping: host ${JSON.stringify(mapping.host)} contains whitespace`);
900
+ }
901
+ if (mapping.host.includes(":")) {
902
+ throw new errors_1.MatchlockError(`invalid add-host mapping: host ${JSON.stringify(mapping.host)} must not contain ':'`);
903
+ }
904
+ if (!mapping.ip || !mapping.ip.trim()) {
905
+ throw new errors_1.MatchlockError("invalid add-host mapping: empty ip");
906
+ }
907
+ if (!this.isValidIP(mapping.ip.trim())) {
908
+ throw new errors_1.MatchlockError(`invalid add-host mapping: invalid ip ${JSON.stringify(mapping.ip)}`);
909
+ }
910
+ }
911
+ isValidIP(ip) {
912
+ return (0, node_net_1.isIP)(ip) !== 0;
913
+ }
914
+ writeStreamChunk(writer, chunk) {
915
+ if (!writer) {
916
+ return;
917
+ }
918
+ if (typeof writer === "function") {
919
+ void writer(chunk);
920
+ return;
921
+ }
922
+ writer.write(chunk);
923
+ }
924
+ isRunning() {
925
+ return !!this.process && this.process.exitCode === null && !this.process.killed;
926
+ }
927
+ async waitForProcessExit(timeoutMs) {
928
+ const proc = this.process;
929
+ if (!proc) {
930
+ return;
931
+ }
932
+ if (proc.exitCode !== null) {
933
+ return;
934
+ }
935
+ let timer;
936
+ try {
937
+ await Promise.race([
938
+ (0, node_events_1.once)(proc, "exit").then(() => undefined),
939
+ new Promise((resolve) => {
940
+ timer = setTimeout(resolve, timeoutMs);
941
+ }),
942
+ ]);
943
+ }
944
+ finally {
945
+ if (timer) {
946
+ clearTimeout(timer);
947
+ }
948
+ }
949
+ if (proc.exitCode === null && !proc.killed) {
950
+ proc.kill("SIGKILL");
951
+ await (0, node_events_1.once)(proc, "exit").catch(() => undefined);
952
+ }
953
+ }
954
+ handleProcessClosed(error) {
955
+ const pending = [...this.pending.values()];
956
+ this.pending.clear();
957
+ const message = error
958
+ ? `Matchlock process closed unexpectedly: ${error.message}`
959
+ : "Matchlock process closed unexpectedly";
960
+ for (const request of pending) {
961
+ request.reject(new errors_1.MatchlockError(message));
962
+ }
963
+ this.process = undefined;
964
+ }
965
+ }
966
+ exports.Client = Client;