dsh-apk-vm 0.4.1 → 0.6.0

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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dsh-apk-vm — Host half.
2
+ * dsh-apk-vm 鈥?Host half.
3
3
  *
4
4
  * Mounts the APK Virtual Machine bridge behind the Web server:
5
5
  * GET /apk-vm/state VM lifecycle snapshot (adb/emulator state, display size)
@@ -28,6 +28,7 @@ import { detectEnvironment, createAvd, installSystemImage, installSdkPackages, b
28
28
  import { acceptWebSocket } from './lib/ws.js';
29
29
  import { parseUiHierarchy, decorateUiNodes, buildInspectorPayload } from './lib/ui-inspector.js';
30
30
  import { FlowRecorder } from './lib/flows.js';
31
+ import { collectStorage, cleanTempFiles, cleanSnapshots, cleanOrphanAvds, formatBytes } from './lib/storage.js';
31
32
 
32
33
  export const name = 'dsh-apk-vm';
33
34
 
@@ -244,6 +245,9 @@ export function apply(ctx, config = {}) {
244
245
  quickBoot: cfg.quickBoot !== false,
245
246
  snapshotName: cfg.snapshotName || undefined,
246
247
  saveSnapshotAfterBoot: cfg.saveSnapshotAfterBoot !== false,
248
+ dnsServers: Array.isArray(cfg.dnsServers) ? cfg.dnsServers.map(String) : undefined,
249
+ networkMode: cfg.networkMode || undefined,
250
+ tcpdumpPath: cfg.tcpdumpPath || undefined,
247
251
  });
248
252
 
249
253
  const instanceManager =
@@ -263,6 +267,9 @@ export function apply(ctx, config = {}) {
263
267
  scrcpyBitrate: cfg.scrcpyBitrate || 6_000_000,
264
268
  scrcpyMaxFps: cfg.scrcpyMaxFps || 60,
265
269
  idleTimeoutMs: cfg.instanceIdleMs || 15 * 60 * 1000,
270
+ dnsServers: Array.isArray(cfg.dnsServers) ? cfg.dnsServers.map(String) : undefined,
271
+ networkMode: cfg.networkMode || undefined,
272
+ tcpdumpPath: cfg.tcpdumpPath || undefined,
266
273
  log: (line) => vm._pushLog(line),
267
274
  });
268
275
 
@@ -298,7 +305,7 @@ export function apply(ctx, config = {}) {
298
305
  /* ---- H.264 video stream (scrcpy) ---------------------------------- *
299
306
  * The PNG screencap path is capped near 1-2 fps by adb. For real frame
300
307
  * rates the plugin runs the Apache-2.0 scrcpy server (vendored jar), parses
301
- * its H.264 stream with lib/scrcpy.js, converts Annex-B → AVCC, and relays
308
+ * its H.264 stream with lib/scrcpy.js, converts Annex-B 鈫?AVCC, and relays
302
309
  * the packets over a no-dependency WebSocket (/apk-vm/ws) to the panel,
303
310
  * which decodes them with WebCodecs VideoDecoder. Input still goes through
304
311
  * the existing /apk-vm/input routes. */
@@ -868,6 +875,75 @@ export function apply(ctx, config = {}) {
868
875
  });
869
876
  });
870
877
 
878
+ function storageSdkRoot() {
879
+ if (vm.sdkRoot) return vm.sdkRoot;
880
+ if (vm.adbPath) return path.dirname(path.dirname(vm.adbPath));
881
+ return process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || null;
882
+ }
883
+
884
+ register('/apk-vm/storage', async (req, res) => {
885
+ const known = () => {
886
+ const names = new Set();
887
+ if (vm.avdName) names.add(vm.avdName);
888
+ if (instanceManager) {
889
+ for (const item of instanceManager.list()) if (item.avdName) names.add(item.avdName);
890
+ }
891
+ return [...names];
892
+ };
893
+ const active = () => {
894
+ const names = new Set();
895
+ if (vm.avdName && ['ready', 'booting', 'starting'].includes(vm._state)) names.add(vm.avdName);
896
+ if (instanceManager) {
897
+ for (const item of instanceManager.list()) {
898
+ if (item.avdName && ['ready', 'booting', 'starting'].includes(item.state)) names.add(item.avdName);
899
+ }
900
+ }
901
+ return [...names];
902
+ };
903
+ if (req.method === 'GET') {
904
+ try {
905
+ sendJson(
906
+ res,
907
+ 200,
908
+ await collectStorage({
909
+ sdkRoot: storageSdkRoot(),
910
+ avdRoot: undefined,
911
+ knownAvds: known(),
912
+ activeAvds: active(),
913
+ }),
914
+ );
915
+ } catch (error) {
916
+ sendError(res, 500, error.message);
917
+ }
918
+ return;
919
+ }
920
+ if (req.method !== 'POST') {
921
+ sendError(res, 405, 'GET or POST only');
922
+ return;
923
+ }
924
+ const body = await readJsonBody(req);
925
+ const action = String(body.action || '').toLowerCase();
926
+ try {
927
+ let result;
928
+ if (action === 'temp') {
929
+ result = await cleanTempFiles();
930
+ } else if (action === 'snapshots') {
931
+ if (body.confirm !== true) throw new Error('snapshot cleanup requires confirm:true');
932
+ const name = String(body.avdName || vm.avdName || 'dsh_apk');
933
+ if (active().includes(name)) throw new Error(`AVD ${name} is active; stop it before deleting snapshots`);
934
+ result = await cleanSnapshots({ avdName: name });
935
+ } else if (action === 'orphan-avds') {
936
+ if (body.confirm !== true) throw new Error('orphan AVD cleanup requires confirm:true');
937
+ result = await cleanOrphanAvds({ knownAvds: known(), activeAvds: active() });
938
+ } else {
939
+ throw new Error(`unsupported storage action: ${action || '(missing)'}`);
940
+ }
941
+ sendJson(res, 200, result);
942
+ } catch (error) {
943
+ sendError(res, 400, error.message);
944
+ }
945
+ });
946
+
871
947
  register('/apk-vm/state', async (req, res) => {
872
948
  if (req.method !== 'GET' && req.method !== 'HEAD') {
873
949
  sendError(res, 405, 'GET only');
@@ -1148,6 +1224,55 @@ export function apply(ctx, config = {}) {
1148
1224
  });
1149
1225
  return;
1150
1226
  }
1227
+ if (rest === 'network') {
1228
+ if (!(await ready())) return;
1229
+ if (req.method === 'GET') {
1230
+ sendJson(res, 200, await instance.vm.networkInfo());
1231
+ return;
1232
+ }
1233
+ if (req.method !== 'POST') {
1234
+ sendError(res, 405, 'GET or POST only');
1235
+ return;
1236
+ }
1237
+ const body = await readJsonBody(req);
1238
+ const action = String(body.action || '').toLowerCase();
1239
+ try {
1240
+ let result;
1241
+ if (action === 'mode') result = instance.vm.setNetworkMode(body.mode);
1242
+ else if (action === 'dns') result = await instance.vm.setDnsServers(body.servers);
1243
+ else if (action === 'hosts') result = await instance.vm.setHosts(body.entries);
1244
+ else if (action === 'test') result = await instance.vm.testNetwork(body);
1245
+ else if (action === 'pcap') result = instance.vm.setTcpdumpPath(body.path);
1246
+ else if (action === 'forward-add') result = await instance.vm.addPortForward(body.localPort, body.remotePort, { shared: body.shared === true });
1247
+ else if (action === 'forward-remove') result = await instance.vm.removePortForward(body.localPort);
1248
+ else throw new Error(`unsupported network action: ${action || '(missing)'}`);
1249
+ sendJson(res, 200, result);
1250
+ } catch (error) {
1251
+ sendError(res, 400, error.message);
1252
+ }
1253
+ return;
1254
+ }
1255
+ if (rest === 'network/pcap') {
1256
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
1257
+ sendError(res, 405, 'GET or HEAD only');
1258
+ return;
1259
+ }
1260
+ const tcpdumpPath = instance.vm.tcpdumpPath;
1261
+ if (!tcpdumpPath || !existsSync(tcpdumpPath)) {
1262
+ sendError(res, 404, 'pcap capture is not configured or has no file yet');
1263
+ return;
1264
+ }
1265
+ const stat = await fsp.stat(tcpdumpPath);
1266
+ res.writeHead(200, {
1267
+ 'Content-Type': 'application/vnd.tcpdump.pcap',
1268
+ 'Content-Length': stat.size,
1269
+ 'Cache-Control': 'no-store',
1270
+ 'Content-Disposition': `attachment; filename="${path.basename(tcpdumpPath)}"`,
1271
+ });
1272
+ if (req.method === 'HEAD') res.end();
1273
+ else createReadStream(tcpdumpPath).pipe(res);
1274
+ return;
1275
+ }
1151
1276
  if (rest === 'touch' && req.method === 'POST') {
1152
1277
  const body = await readJsonBody(req);
1153
1278
  const action = String(body.action || '').toLowerCase();
@@ -1382,6 +1507,8 @@ export function apply(ctx, config = {}) {
1382
1507
  'debug/pull',
1383
1508
  'debug/reboot',
1384
1509
  'flows',
1510
+ 'network',
1511
+ 'network/pcap',
1385
1512
  ];
1386
1513
  const disposers = [];
1387
1514
  for (const suffix of suffixes) {
@@ -2016,6 +2143,59 @@ export function apply(ctx, config = {}) {
2016
2143
  }
2017
2144
  });
2018
2145
 
2146
+ register('/apk-vm/network', async (req, res) => {
2147
+ if (req.method === 'GET') {
2148
+ if (!(await ensureDebugReady(res))) return;
2149
+ try {
2150
+ sendJson(res, 200, await vm.networkInfo());
2151
+ } catch (error) {
2152
+ sendError(res, 400, error.message);
2153
+ }
2154
+ return;
2155
+ }
2156
+ if (req.method !== 'POST') {
2157
+ sendError(res, 405, 'GET or POST only');
2158
+ return;
2159
+ }
2160
+ if (!(await ensureDebugReady(res))) return;
2161
+ const body = await readJsonBody(req);
2162
+ const action = String(body.action || '').toLowerCase();
2163
+ try {
2164
+ let result;
2165
+ if (action === 'mode') result = vm.setNetworkMode(body.mode);
2166
+ else if (action === 'dns') result = await vm.setDnsServers(body.servers);
2167
+ else if (action === 'hosts') result = await vm.setHosts(body.entries);
2168
+ else if (action === 'test') result = await vm.testNetwork(body);
2169
+ else if (action === 'pcap') result = vm.setTcpdumpPath(body.path);
2170
+ else if (action === 'forward-add') result = await vm.addPortForward(body.localPort, body.remotePort, { shared: body.shared === true });
2171
+ else if (action === 'forward-remove') result = await vm.removePortForward(body.localPort);
2172
+ else throw new Error(`unsupported network action: ${action || '(missing)'}`);
2173
+ sendJson(res, 200, result);
2174
+ } catch (error) {
2175
+ sendError(res, 400, error.message);
2176
+ }
2177
+ });
2178
+
2179
+ register('/apk-vm/network/pcap', async (req, res) => {
2180
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
2181
+ sendError(res, 405, 'GET or HEAD only');
2182
+ return;
2183
+ }
2184
+ if (!vm.tcpdumpPath || !existsSync(vm.tcpdumpPath)) {
2185
+ sendError(res, 404, 'pcap capture is not configured or has no file yet');
2186
+ return;
2187
+ }
2188
+ const stat = await fsp.stat(vm.tcpdumpPath);
2189
+ res.writeHead(200, {
2190
+ 'Content-Type': 'application/vnd.tcpdump.pcap',
2191
+ 'Content-Length': stat.size,
2192
+ 'Cache-Control': 'no-store',
2193
+ 'Content-Disposition': `attachment; filename="${path.basename(vm.tcpdumpPath)}"`,
2194
+ });
2195
+ if (req.method === 'HEAD') res.end();
2196
+ else createReadStream(vm.tcpdumpPath).pipe(res);
2197
+ });
2198
+
2019
2199
  register('/apk-vm/debug/record', async (req, res) => {
2020
2200
  if (req.method !== 'POST') {
2021
2201
  sendError(res, 405, 'POST only');
@@ -2223,6 +2403,14 @@ export function apply(ctx, config = {}) {
2223
2403
  getProxy: () => vm.getProxy(),
2224
2404
  setProxy: (options) => vm.setProxy(options),
2225
2405
  installCaCertificate: (certPath) => vm.installCaCertificate(certPath),
2406
+ networkInfo: () => vm.networkInfo(),
2407
+ setNetworkMode: (mode) => vm.setNetworkMode(mode),
2408
+ setTcpdumpPath: (filePath) => vm.setTcpdumpPath(filePath),
2409
+ setDnsServers: (servers) => vm.setDnsServers(servers),
2410
+ setHosts: (entries) => vm.setHosts(entries),
2411
+ testNetwork: (options) => vm.testNetwork(options),
2412
+ addPortForward: (local, remote, options) => vm.addPortForward(local, remote, options),
2413
+ removePortForward: (local) => vm.removePortForward(local),
2226
2414
  screenRecord: (options) => vm.screenRecord(options),
2227
2415
  perfSnapshot: () => vm.perfSnapshot(),
2228
2416
  screenshot: async () => {
@@ -2267,6 +2455,14 @@ export function apply(ctx, config = {}) {
2267
2455
  getProxy: () => instance.vm.getProxy(),
2268
2456
  setProxy: (options) => instance.vm.setProxy(options),
2269
2457
  installCaCertificate: (certPath) => instance.vm.installCaCertificate(certPath),
2458
+ networkInfo: () => instance.vm.networkInfo(),
2459
+ setNetworkMode: (mode) => instance.vm.setNetworkMode(mode),
2460
+ setTcpdumpPath: (filePath) => instance.vm.setTcpdumpPath(filePath),
2461
+ setDnsServers: (servers) => instance.vm.setDnsServers(servers),
2462
+ setHosts: (entries) => instance.vm.setHosts(entries),
2463
+ testNetwork: (options) => instance.vm.testNetwork(options),
2464
+ addPortForward: (local, remote, options) => instance.vm.addPortForward(local, remote, options),
2465
+ removePortForward: (local) => instance.vm.removePortForward(local),
2270
2466
  screenRecord: (options) => instance.screenRecord(options),
2271
2467
  perfSnapshot: () => instance.perfSnapshot(),
2272
2468
  screenshot: async () => {
@@ -2368,8 +2564,8 @@ export function apply(ctx, config = {}) {
2368
2564
  'Operate the local rooted Android APK virtual machine (Android emulator) that the APK VM panel manages. ' +
2369
2565
  'The VM is usually already running (warm start); adbd runs as root once ready. ' +
2370
2566
  'Actions: info, shell, logcat, getprop, setprop, packages, install, push, pull, reboot, root, ' +
2371
- 'force_stop, clear_data, uninstall, rotate, ui_dump, inspect_ui, proxy, install_ca, record, perf, screenshot, tap, swipe, long_press, key, text, ' +
2372
- 'apk_info, find_ui, assert_text, tap_text, clipboard_get, clipboard_set, devices, attach_device, detach_device, start, stop, list_instances, create_instance, destroy_instance, flow_start, flow_stop, flow_list, flow_replay. ' +
2567
+ 'force_stop, clear_data, uninstall, rotate, ui_dump, inspect_ui, proxy, install_ca, network, record, perf, screenshot, tap, swipe, long_press, key, text, ' +
2568
+ 'apk_info, find_ui, assert_text, tap_text, clipboard_get, clipboard_set, devices, attach_device, detach_device, start, stop, list_instances, create_instance, destroy_instance, flow_start, flow_stop, flow_list, flow_replay, storage, clean_storage. ' +
2373
2569
  'Multi-instance: pass `instance` to target an isolated VM; omit it to use the calling Agent\'s ' +
2374
2570
  'assigned instance (first Agent gets the default VM, later Agents get isolated instances). ' +
2375
2571
  'Use shell for low-level Android commands, logcat for logs, ' +
@@ -2382,7 +2578,7 @@ export function apply(ctx, config = {}) {
2382
2578
  properties: {
2383
2579
  action: {
2384
2580
  type: 'string',
2385
- description: 'info|shell|logcat|getprop|setprop|packages|install|push|pull|reboot|root|force_stop|clear_data|uninstall|rotate|ui_dump|inspect_ui|proxy|install_ca|record|perf|screenshot|tap|swipe|long_press|key|text|apk_info|find_ui|assert_text|tap_text|clipboard_get|clipboard_set|devices|attach_device|detach_device|start|stop|list_instances|create_instance|destroy_instance|flow_start|flow_stop|flow_list|flow_replay',
2581
+ description: 'info|shell|logcat|getprop|setprop|packages|install|push|pull|reboot|root|force_stop|clear_data|uninstall|rotate|ui_dump|inspect_ui|proxy|install_ca|network|record|perf|screenshot|tap|swipe|long_press|key|text|apk_info|find_ui|assert_text|tap_text|clipboard_get|clipboard_set|devices|attach_device|detach_device|start|stop|list_instances|create_instance|destroy_instance|flow_start|flow_stop|flow_list|flow_replay|storage|clean_storage',
2386
2582
  },
2387
2583
  instance: { type: 'string', description: 'Target VM instance id (default or an extra instance from list_instances). Omit for the calling Agent\'s assigned instance.' },
2388
2584
  instanceName: { type: 'string', description: 'create_instance: optional name for the new isolated instance.' },
@@ -2418,6 +2614,19 @@ export function apply(ctx, config = {}) {
2418
2614
  port: { type: 'integer', description: 'proxy: HTTP proxy port (1-65535).' },
2419
2615
  bypass: { type: 'string', description: 'proxy: optional no-proxy/exclusion list.' },
2420
2616
  flowId: { type: 'string', description: 'flow_replay: saved flow id from flow_list.' },
2617
+ storageAction: { type: 'string', description: 'clean_storage: temp|snapshots|orphan-avds.' },
2618
+ avdName: { type: 'string', description: 'clean_storage snapshots: target AVD (defaults to the active AVD).' },
2619
+ confirm: { type: 'boolean', description: 'clean_storage: required true for snapshot or orphan AVD deletion.' },
2620
+ networkAction: { type: 'string', description: 'network: info|mode|dns|hosts|test|pcap|forward-add|forward-remove.' },
2621
+ networkMode: { type: 'string', description: 'network mode: isolated|shared.' },
2622
+ localPort: { type: 'integer', description: 'port forward: host loopback port.' },
2623
+ remotePort: { type: 'integer', description: 'port forward: guest service port.' },
2624
+ shared: { type: 'boolean', description: 'port forward: expose to other VMs through the emulator host alias (requires shared mode).' },
2625
+ servers: { type: 'array', items: { type: 'string' }, description: 'network DNS: up to 4 IPv4/IPv6 DNS servers.' },
2626
+ entries: { type: 'array', items: { type: 'string' }, description: 'network hosts: lines such as "1.2.3.4 api.example.com".' },
2627
+ testHost: { type: 'string', description: 'network test: host to ping/TCP-test (default: emulator host alias).' },
2628
+ testPort: { type: 'integer', description: 'network test: TCP port.' },
2629
+ pcapPath: { type: 'string', description: 'network pcap: host path ending in .pcap; capture starts on the next emulator start.' },
2421
2630
  },
2422
2631
  required: ['action'],
2423
2632
  },
@@ -2544,6 +2753,109 @@ export function apply(ctx, config = {}) {
2544
2753
  });
2545
2754
  return { ok: true, summary: `replayed ${replayed.steps} step(s) from ${replayed.name}`, result: replayed };
2546
2755
  }
2756
+ if (action === 'storage') {
2757
+ const report = await collectStorage({
2758
+ sdkRoot: storageSdkRoot(),
2759
+ knownAvds: [
2760
+ vm.avdName,
2761
+ ...(instanceManager ? instanceManager.list().map((item) => item.avdName) : []),
2762
+ ].filter(Boolean),
2763
+ activeAvds: [
2764
+ ...(['ready', 'booting', 'starting'].includes(vm._state) ? [vm.avdName] : []),
2765
+ ...(instanceManager
2766
+ ? instanceManager
2767
+ .list()
2768
+ .filter((item) => ['ready', 'booting', 'starting'].includes(item.state))
2769
+ .map((item) => item.avdName)
2770
+ : []),
2771
+ ].filter(Boolean),
2772
+ });
2773
+ return {
2774
+ ok: true,
2775
+ summary:
2776
+ `AVD ${formatBytes(report.totals.avdBytes)}, snapshots ` +
2777
+ `${formatBytes(report.totals.snapshotBytes)}, SDK ${formatBytes(report.totals.sdkBytes)}, ` +
2778
+ `temp ${formatBytes(report.totals.tempBytes)}, free ${formatBytes(report.totals.freeBytes)}`,
2779
+ result: report,
2780
+ };
2781
+ }
2782
+ if (action === 'clean_storage') {
2783
+ if (!args.storageAction) throw new Error('action=clean_storage requires storageAction');
2784
+ const storageAction = String(args.storageAction).toLowerCase();
2785
+ const knownAvds = [
2786
+ vm.avdName,
2787
+ ...(instanceManager ? instanceManager.list().map((item) => item.avdName) : []),
2788
+ ].filter(Boolean);
2789
+ const activeAvds = [
2790
+ ...(['ready', 'booting', 'starting'].includes(vm._state) ? [vm.avdName] : []),
2791
+ ...(instanceManager
2792
+ ? instanceManager
2793
+ .list()
2794
+ .filter((item) => ['ready', 'booting', 'starting'].includes(item.state))
2795
+ .map((item) => item.avdName)
2796
+ : []),
2797
+ ].filter(Boolean);
2798
+ let result;
2799
+ if (storageAction === 'temp') result = await cleanTempFiles();
2800
+ else if (storageAction === 'snapshots') {
2801
+ if (args.confirm !== true) throw new Error('clean_storage snapshots requires confirm=true');
2802
+ const target = String(args.avdName || vm.avdName || 'dsh_apk');
2803
+ if (activeAvds.includes(target)) throw new Error(`AVD ${target} is active; stop it first`);
2804
+ result = await cleanSnapshots({ avdName: target });
2805
+ } else if (storageAction === 'orphan-avds') {
2806
+ if (args.confirm !== true) throw new Error('clean_storage orphan-avds requires confirm=true');
2807
+ result = await cleanOrphanAvds({ knownAvds, activeAvds });
2808
+ } else throw new Error(`unsupported storageAction: ${storageAction}`);
2809
+ return {
2810
+ ok: true,
2811
+ summary: `${storageAction}: freed ${formatBytes(result.freedBytes)}`,
2812
+ result,
2813
+ };
2814
+ }
2815
+ if (action === 'network') {
2816
+ const networkAction = String(args.networkAction || 'info').toLowerCase();
2817
+ if (networkAction === 'mode') {
2818
+ const result = api.setNetworkMode(args.networkMode || args.mode);
2819
+ return { ok: true, summary: `network mode: ${result.mode}`, result };
2820
+ }
2821
+ if (networkAction === 'dns') {
2822
+ const servers = Array.isArray(args.servers) ? args.servers : String(args.servers || '').split(',');
2823
+ const result = await api.setDnsServers(servers);
2824
+ return { ok: true, summary: `DNS: ${(result.servers || []).join(', ') || '(default)'}`, result };
2825
+ }
2826
+ if (networkAction === 'pcap') {
2827
+ const result = api.setTcpdumpPath(args.pcapPath);
2828
+ return { ok: true, summary: result.enabled ? `pcap -> ${result.path}` : 'pcap disabled', result };
2829
+ }
2830
+ await ensureToolApiReady(api, action);
2831
+ if (networkAction === 'info') {
2832
+ const result = await api.networkInfo();
2833
+ return {
2834
+ ok: true,
2835
+ summary: `mode=${result.mode} proxy=${result.proxy || 'off'} forwards=${result.forwards.length} dns=${(result.dnsServers || []).join(',') || 'default'}`,
2836
+ result,
2837
+ };
2838
+ }
2839
+ if (networkAction === 'hosts') {
2840
+ const result = await api.setHosts(Array.isArray(args.entries) ? args.entries : []);
2841
+ return { ok: true, summary: `hosts applied: ${result.entries}`, result };
2842
+ }
2843
+ if (networkAction === 'test') {
2844
+ const result = await api.testNetwork({ host: args.testHost, port: args.testPort });
2845
+ return { ok: result.ok, summary: result.checks.map((item) => `${item.name}:${item.ok ? 'ok' : 'fail'}`).join(', '), result };
2846
+ }
2847
+ if (networkAction === 'forward-add') {
2848
+ if (!args.localPort || !args.remotePort) throw new Error('forward-add requires localPort and remotePort');
2849
+ const result = await api.addPortForward(args.localPort, args.remotePort, { shared: args.shared === true });
2850
+ return { ok: true, summary: `forward tcp:${result.localPort} -> ${result.remotePort}`, result };
2851
+ }
2852
+ if (networkAction === 'forward-remove') {
2853
+ if (!args.localPort) throw new Error('forward-remove requires localPort');
2854
+ const result = await api.removePortForward(args.localPort);
2855
+ return { ok: true, summary: `removed forward tcp:${result.localPort}`, result };
2856
+ }
2857
+ throw new Error(`unsupported networkAction: ${networkAction}`);
2858
+ }
2547
2859
  if (action === 'start') {
2548
2860
  const snapshot = await api.start();
2549
2861
  return { ok: true, summary: `started ${api.id}`, result: snapshot || {} };
@@ -2766,3 +3078,4 @@ export function apply(ctx, config = {}) {
2766
3078
  }, 'dsh-apk-vm: apk_vm tool');
2767
3079
  });
2768
3080
  }
3081
+