windows-development-sandbox 1.13.0 → 1.14.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.
Files changed (125) hide show
  1. package/dist/app/features/accounts/accounts.def.d.ts +1 -0
  2. package/dist/app/features/accounts/accounts.def.js +12 -3
  3. package/dist/app/features/accounts/accounts.run.d.ts +1 -0
  4. package/dist/app/features/accounts/accounts.run.js +20 -11
  5. package/dist/app/features/defs.d.ts +1 -0
  6. package/dist/app/features/settings/settings-file.d.ts +7 -0
  7. package/dist/app/features/settings/settings-file.js +19 -2
  8. package/dist/app/features/settings/settings-rows.js +9 -1
  9. package/dist/app/features/storage/storage.run.js +1 -1
  10. package/dist/bin/agent-sandbox.js +7 -1
  11. package/dist/bin/chunk-BQ23GBLC.js +78790 -0
  12. package/dist/bin/chunk-F7IMME5G.js +10432 -0
  13. package/dist/bin/chunk-LQHEUN2A.js +7 -0
  14. package/dist/bin/claude-host.js +10 -4
  15. package/dist/bin/claude-sbx.js +8 -2
  16. package/dist/bin/codex-sbx.js +8 -2
  17. package/dist/bin/gemini-sbx.js +8 -2
  18. package/dist/bin/sandbox.js +7 -2
  19. package/dist/bin/sbx-hide.js +8 -2
  20. package/dist/bin/sbx-show.js +8 -2
  21. package/dist/cli/commands/launch/launch.js +1 -1
  22. package/dist/cli/commands/launch/prepare.js +24 -7
  23. package/dist/cli/commands/launch/running.js +4 -0
  24. package/dist/cli/commands/repair/checks/contained.js +1 -1
  25. package/dist/cli/commands/storage/offer.js +1 -1
  26. package/dist/cli/menu/live.d.ts +29 -0
  27. package/dist/cli/menu/live.js +92 -21
  28. package/dist/cli/menu/menu.d.ts +2 -1
  29. package/dist/cli/menu/menu.js +16 -2
  30. package/dist/cli/menu/pages/list-page.d.ts +5 -0
  31. package/dist/cli/menu/pages/list-page.js +19 -0
  32. package/dist/cli/menu/shell.d.ts +2 -0
  33. package/dist/cli/menu/shell.js +1 -1
  34. package/dist/cli/services/accounts-service.d.ts +7 -0
  35. package/dist/cli/services/accounts-service.js +20 -0
  36. package/dist/cli/tui/app.d.ts +4 -0
  37. package/dist/cli/tui/app.js +5 -0
  38. package/dist/cli/tui/terminal.d.ts +6 -0
  39. package/dist/cli/tui/terminal.js +8 -1
  40. package/dist/cli/validate.js +8 -4
  41. package/dist/core/package-info.js +1 -1
  42. package/dist/guest/daemon/sessions.d.ts +7 -0
  43. package/dist/guest/daemon/sessions.js +23 -2
  44. package/dist/guest/entries/daemon.js +1 -0
  45. package/dist/guest/sessions/pty-host.d.ts +10 -0
  46. package/dist/guest/sessions/pty-host.js +48 -3
  47. package/dist/host/channel/channel.js +1 -1
  48. package/dist/host/install/install-record.d.ts +1 -0
  49. package/dist/host/install/install-record.js +2 -0
  50. package/dist/host/sandbox/contained-deps.d.ts +2 -1
  51. package/dist/host/sandbox/contained-deps.js +6 -4
  52. package/dist/host/sandbox/platform.d.ts +2 -0
  53. package/dist/host/sandbox/platform.js +19 -4
  54. package/dist/host/sharing/lifecycle.js +9 -1
  55. package/dist/host/storage/contained/contained-storage.js +13 -0
  56. package/dist/host/storage/contained/disk-port.d.ts +5 -0
  57. package/dist/host/storage/contained/helper-port.d.ts +72 -0
  58. package/dist/host/storage/contained/helper-port.js +162 -0
  59. package/dist/host/storage/contained/index.d.ts +1 -0
  60. package/dist/host/storage/contained/index.js +1 -0
  61. package/dist/host/storage/contained/state.d.ts +3 -2
  62. package/dist/host/storage/contained/state.js +5 -2
  63. package/dist/host/web/bridge.d.ts +5 -0
  64. package/dist/host/web/bridge.js +11 -1
  65. package/dist/host/web/server.d.ts +2 -0
  66. package/dist/host/web/server.js +11 -1
  67. package/dist/runtime/agent.js +9 -0
  68. package/dist/runtime/daemon.js +57 -3
  69. package/dist/runtime/db.js +9 -0
  70. package/dist/runtime/main.js +195 -12
  71. package/dist/runtime/netproxy.js +9 -0
  72. package/dist/runtime/view.js +195 -12
  73. package/dist/selftest/channel.test.js +72 -1
  74. package/dist/selftest/contained.test.js +1 -37
  75. package/dist/selftest/hcs.test.js +5 -2
  76. package/dist/selftest/index.js +2 -0
  77. package/dist/selftest/install.test.js +11 -0
  78. package/dist/selftest/layout.test.js +10 -1
  79. package/dist/selftest/live-daemon.test.js +51 -1
  80. package/dist/selftest/live-terminal.test.js +112 -4
  81. package/dist/selftest/menu-perf.test.js +198 -12
  82. package/dist/selftest/menu.test.js +4 -0
  83. package/dist/selftest/storage-helper.test.d.ts +2 -0
  84. package/dist/selftest/storage-helper.test.js +493 -0
  85. package/dist/selftest/support/vhdx.d.ts +14 -0
  86. package/dist/selftest/support/vhdx.js +72 -0
  87. package/dist/selftest/web.test.js +14 -2
  88. package/dist/shared/channel/run-dir.d.ts +6 -0
  89. package/dist/shared/channel/run-dir.js +9 -0
  90. package/dist/shared/pty/index.d.ts +3 -1
  91. package/dist/shared/pty/index.js +3 -1
  92. package/dist/shared/pty/key-gate.d.ts +24 -0
  93. package/dist/shared/pty/key-gate.js +45 -0
  94. package/dist/shared/pty/mouse-modes.d.ts +27 -0
  95. package/dist/shared/pty/mouse-modes.js +155 -0
  96. package/dist/shared/pty/terminal.d.ts +0 -5
  97. package/dist/shared/pty/terminal.js +51 -20
  98. package/dist/sync/watcher.js +1 -1
  99. package/dist/web/index.html +1 -1
  100. package/dist/web/{main-Z2WYAE2Z.js → main-6XNBTJLT.js} +34 -34
  101. package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
  102. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  103. package/native/csharp/StorageHelper/AssemblyInfo.cs +15 -0
  104. package/native/csharp/StorageHelper/Client.cs +297 -0
  105. package/native/csharp/StorageHelper/Log.cs +177 -0
  106. package/native/csharp/StorageHelper/MountRecords.cs +160 -0
  107. package/native/csharp/StorageHelper/Operations.cs +596 -0
  108. package/native/csharp/StorageHelper/Pins.cs +270 -0
  109. package/native/csharp/StorageHelper/Privileges.cs +78 -0
  110. package/native/csharp/StorageHelper/Program.cs +349 -0
  111. package/native/csharp/StorageHelper/Protocol.cs +107 -0
  112. package/native/csharp/StorageHelper/RateLimiter.cs +97 -0
  113. package/native/csharp/StorageHelper/Rules.cs +156 -0
  114. package/native/csharp/StorageHelper/Server.cs +656 -0
  115. package/native/csharp/StorageHelper/Settings.cs +297 -0
  116. package/native/csharp/StorageHelper/StorageService.cs +53 -0
  117. package/native/csharp/StorageHelper/UserSettings.cs +206 -0
  118. package/native/csharp/StorageHelper/Vhdx.cs +230 -0
  119. package/native/csharp/StorageHelper/VirtualDisk.cs +241 -0
  120. package/native/csharp/StorageHelper/Volumes.cs +379 -0
  121. package/native/csharp/StorageHelper/Watchdog.cs +128 -0
  122. package/native/csharp/StorageHelper/Win32.cs +250 -0
  123. package/native/scripts/set-storage-helper.ps1 +203 -0
  124. package/package.json +1 -1
  125. package/sandbox/csharp/PtyHost.cs +236 -1
@@ -5892,12 +5892,175 @@ var CtrlCGuard = class {
5892
5892
  }
5893
5893
  };
5894
5894
 
5895
- // src/shared/pty/terminal.ts
5896
- var MOUSE_OFF = "\x1B[?1000l\x1B[?1002l\x1B[?1003l\x1B[?1005l\x1B[?1006l\x1B[?1015l";
5895
+ // src/shared/pty/key-gate.ts
5897
5896
  function fixWindowsKeys(chunk) {
5898
5897
  if (!chunk.includes(8) && !chunk.includes(127)) return chunk;
5899
5898
  return Buffer.from(chunk.map((b) => b === 8 ? 127 : b === 127 ? 8 : b));
5900
5899
  }
5900
+ var KeyGate = class {
5901
+ pending = [];
5902
+ decided;
5903
+ swap = false;
5904
+ /** windowsConsole: input comes from a Windows console in raw mode (its mode is decided later); else it goes as is. */
5905
+ constructor(windowsConsole) {
5906
+ this.decided = !windowsConsole;
5907
+ }
5908
+ /** The bytes to send now (empty while the mode is not known yet). */
5909
+ push(data) {
5910
+ if (!data.length) return data;
5911
+ if (!this.decided) {
5912
+ this.pending.push(data);
5913
+ return Buffer.alloc(0);
5914
+ }
5915
+ return this.swap ? fixWindowsKeys(data) : data;
5916
+ }
5917
+ /** The console's mode is known: what was held, ready to send. Later calls only change how new input is read. */
5918
+ decide(vtInput) {
5919
+ this.swap = !vtInput;
5920
+ this.decided = true;
5921
+ const held = Buffer.concat(this.pending);
5922
+ this.pending = [];
5923
+ return this.swap ? fixWindowsKeys(held) : held;
5924
+ }
5925
+ };
5926
+
5927
+ // src/shared/pty/mouse-modes.ts
5928
+ var TRACKING = [9, 1e3, 1002, 1003];
5929
+ var MAX_PARAMS = 64;
5930
+ var MOTION = 32;
5931
+ var NO_BUTTON = 3;
5932
+ var MouseModes = class {
5933
+ on = /* @__PURE__ */ new Set();
5934
+ /** 0 text, 1 after ESC, 2 after ESC[, 3 in ESC[? parameters, 4 after ESC[! */
5935
+ state = 0;
5936
+ params = "";
5937
+ /** Reads the program's output (after OutputFilter): the mode changes in it. */
5938
+ watch(output2) {
5939
+ for (const b of output2) {
5940
+ if (b === 27) {
5941
+ this.state = 1;
5942
+ continue;
5943
+ }
5944
+ if (this.state === 1) {
5945
+ if (b === 99) this.on.clear();
5946
+ this.state = b === 91 ? 2 : 0;
5947
+ } else if (this.state === 2) {
5948
+ this.state = b === 63 ? 3 : b === 33 ? 4 : 0;
5949
+ this.params = "";
5950
+ } else if (this.state === 4) {
5951
+ if (b === 112) this.on.clear();
5952
+ this.state = 0;
5953
+ } else if (this.state === 3) {
5954
+ if (b >= 48 && b <= 57 || b === 59) {
5955
+ this.params += String.fromCharCode(b);
5956
+ if (this.params.length > MAX_PARAMS) this.state = 0;
5957
+ } else {
5958
+ if (b === 104 || b === 108) this.apply(this.params, b === 104);
5959
+ this.state = 0;
5960
+ }
5961
+ }
5962
+ }
5963
+ }
5964
+ /** The program is gone (exited or restarting): its modes went with it. */
5965
+ reset() {
5966
+ this.on.clear();
5967
+ this.state = 0;
5968
+ this.params = "";
5969
+ }
5970
+ /** True while the program tracks the mouse in any way. */
5971
+ tracking() {
5972
+ return TRACKING.some((m) => this.on.has(m));
5973
+ }
5974
+ /** True while the program has a mode on (2004: bracketed paste). */
5975
+ has(mode) {
5976
+ return this.on.has(mode);
5977
+ }
5978
+ /** The mode numbers on now (for the report and tests). */
5979
+ modes() {
5980
+ return [...this.on].sort((a, b) => a - b);
5981
+ }
5982
+ /** `input` without the mouse reports the program has not asked for. Sequences must be whole (InputJoiner). */
5983
+ filter(input2) {
5984
+ if (!input2.includes(27)) return input2;
5985
+ const parts = [];
5986
+ let from = 0;
5987
+ let i = input2.indexOf(27);
5988
+ while (i !== -1) {
5989
+ const report = mouseReport(input2, i, this.on.has(1005), this.on.has(1015));
5990
+ if (report && !this.allows(report.button)) {
5991
+ parts.push(input2.subarray(from, i));
5992
+ from = i + report.length;
5993
+ i = input2.indexOf(27, from);
5994
+ } else i = input2.indexOf(27, i + 1);
5995
+ }
5996
+ if (from === 0) return input2;
5997
+ parts.push(input2.subarray(from));
5998
+ return Buffer.concat(parts);
5999
+ }
6000
+ allows(button) {
6001
+ if (!this.tracking()) return false;
6002
+ if ((button & MOTION) === 0) return true;
6003
+ if (this.on.has(1003)) return true;
6004
+ return this.on.has(1002) && (button & NO_BUTTON) !== NO_BUTTON;
6005
+ }
6006
+ apply(params, set2) {
6007
+ for (const p of params.split(";")) {
6008
+ const mode = Number(p);
6009
+ if (!p || !Number.isSafeInteger(mode)) continue;
6010
+ if (set2) this.on.add(mode);
6011
+ else this.on.delete(mode);
6012
+ }
6013
+ }
6014
+ };
6015
+ function mouseReport(data, at, utf8, urxvt) {
6016
+ if (data[at + 1] !== 91) return null;
6017
+ const sgr = data[at + 2] === 60;
6018
+ if (sgr || urxvt && data[at + 2] >= 48 && data[at + 2] <= 57) {
6019
+ let i = at + (sgr ? 3 : 2);
6020
+ let digits = "";
6021
+ let fields = 1;
6022
+ for (; i < data.length && i - at < 32; i++) {
6023
+ const b = data[i];
6024
+ if (b >= 48 && b <= 57) {
6025
+ if (fields === 1) digits += String.fromCharCode(b);
6026
+ } else if (b === 59) fields++;
6027
+ else break;
6028
+ }
6029
+ if (i >= data.length || fields !== 3 || !digits) return null;
6030
+ if (sgr ? data[i] !== 77 && data[i] !== 109 : data[i] !== 77) return null;
6031
+ const button = Number(digits) - (sgr ? 0 : 32);
6032
+ return button >= 0 ? { button, length: i - at + 1 } : null;
6033
+ }
6034
+ if (data[at + 2] === 77 && !utf8 && data.length - at >= 6) {
6035
+ const button = data[at + 3] - 32;
6036
+ return button >= 0 ? { button, length: 6 } : null;
6037
+ }
6038
+ return null;
6039
+ }
6040
+
6041
+ // src/shared/pty/terminal.ts
6042
+ var MOUSE_OFF = "\x1B[?1000l\x1B[?1002l\x1B[?1003l\x1B[?1005l\x1B[?1006l\x1B[?1015l";
6043
+ var SETTLE_QUIET_MS = 50;
6044
+ var SETTLE_MAX_MS = 300;
6045
+ function drainInput(stream) {
6046
+ return new Promise((done) => {
6047
+ let quiet;
6048
+ const end = () => {
6049
+ clearTimeout(quiet);
6050
+ clearTimeout(cap);
6051
+ stream.off("data", onData);
6052
+ done();
6053
+ };
6054
+ const onData = () => {
6055
+ clearTimeout(quiet);
6056
+ quiet = setTimeout(end, SETTLE_QUIET_MS);
6057
+ };
6058
+ const cap = setTimeout(end, SETTLE_MAX_MS);
6059
+ stream.on("data", onData);
6060
+ onData();
6061
+ });
6062
+ }
6063
+ var VT_WAIT_MS = 1e4;
5901
6064
  function runTerminalClient(socket, token, options = {}) {
5902
6065
  const stdin = process.stdin;
5903
6066
  const stdout = process.stdout;
@@ -5905,14 +6068,16 @@ function runTerminalClient(socket, token, options = {}) {
5905
6068
  const [c, r] = stdout.isTTY ? stdout.getWindowSize() : [0, 0];
5906
6069
  return [c || 120, r || 30];
5907
6070
  };
5908
- let swapKeys = process.platform === "win32" && stdin.isTTY;
6071
+ const keys = new KeyGate(process.platform === "win32" && Boolean(stdin.isTTY));
5909
6072
  return new Promise((resolve) => {
5910
6073
  let done = false;
5911
6074
  const reader = new FrameReader();
5912
6075
  const filter = new OutputFilter({ clipboardWrites: options.clipboardWrites });
5913
6076
  let sent = size();
5914
6077
  let resizeTimer;
5915
- let vtInput;
6078
+ let vtInput, vtTimer;
6079
+ const mouse = new MouseModes();
6080
+ let passthrough = false;
5916
6081
  const finish = (result) => {
5917
6082
  if (done) return;
5918
6083
  done = true;
@@ -5923,9 +6088,13 @@ function runTerminalClient(socket, token, options = {}) {
5923
6088
  clearInterval(sizeWatch);
5924
6089
  clearTimeout(joinTimer);
5925
6090
  clearTimeout(hintTimer);
6091
+ clearTimeout(vtTimer);
5926
6092
  socket.destroy();
5927
- stdout.write("\x1B[0m\x1B[?25h" + MOUSE_OFF + "\r\n");
5928
- void (vtInput?.stop() ?? Promise.resolve()).then(() => {
6093
+ const tracked = mouse.tracking();
6094
+ const reset = "\x1B[0m\x1B[?25h" + MOUSE_OFF + (mouse.has(2004) ? "\x1B[?2004l" : "") + "\r\n";
6095
+ const flushed = new Promise((written) => stdout.write(reset, () => written()));
6096
+ const settled = tracked ? flushed.then(() => drainInput(stdin)) : Promise.resolve();
6097
+ void Promise.all([settled, vtInput?.stop()]).then(() => {
5929
6098
  if (stdin.isTTY) stdin.setRawMode(false);
5930
6099
  stdin.pause();
5931
6100
  resolve(result);
@@ -5933,9 +6102,10 @@ function runTerminalClient(socket, token, options = {}) {
5933
6102
  };
5934
6103
  const joiner = new InputJoiner();
5935
6104
  let joinTimer;
5936
- const sendInput = (data) => {
5937
- if (data.length && !done) socket.write(encodeFrame(FrameType.Input, swapKeys ? fixWindowsKeys(data) : data));
6105
+ const write = (data) => {
6106
+ if (data.length && !done) socket.write(encodeFrame(FrameType.Input, data));
5938
6107
  };
6108
+ const sendInput = (data) => write(keys.push(passthrough ? mouse.filter(data) : data));
5939
6109
  const guard = options.confirmCtrlC ? new CtrlCGuard() : null;
5940
6110
  let hintTimer;
5941
6111
  const onInput = (chunk) => {
@@ -5957,7 +6127,6 @@ function runTerminalClient(socket, token, options = {}) {
5957
6127
  sendInput(joiner.push(chunk));
5958
6128
  if (joiner.holding()) joinTimer = setTimeout(() => sendInput(joiner.flush()), JOIN_MS);
5959
6129
  };
5960
- let passthrough = false;
5961
6130
  const sendSize = () => {
5962
6131
  const [c2, r2] = size();
5963
6132
  if (c2 === sent[0] && r2 === sent[1]) return;
@@ -5987,11 +6156,14 @@ function runTerminalClient(socket, token, options = {}) {
5987
6156
  for (const frame of frames) {
5988
6157
  if (frame.type === FrameType.Output) {
5989
6158
  const out = filter.push(frame.payload);
6159
+ mouse.watch(out);
5990
6160
  guard?.watchOutput(out.toString("latin1"));
5991
6161
  stdout.write(out);
5992
6162
  } else if (frame.type === FrameType.Console) passthrough = frame.payload.toString("latin1") === "conpty.dll";
5993
- else if (frame.type === FrameType.Restarting) stdout.write(MOUSE_OFF + "\x1B[2J\x1B[3J\x1B[H");
5994
- else if (frame.type === FrameType.Exit)
6163
+ else if (frame.type === FrameType.Restarting) {
6164
+ mouse.reset();
6165
+ stdout.write(MOUSE_OFF + "\x1B[2J\x1B[3J\x1B[H");
6166
+ } else if (frame.type === FrameType.Exit)
5995
6167
  finish({ reason: "exit", exitCode: frame.payload.length >= 4 ? frame.payload.readInt32LE(0) : 1 });
5996
6168
  else if (frame.type === FrameType.Error)
5997
6169
  finish({
@@ -6007,8 +6179,10 @@ function runTerminalClient(socket, token, options = {}) {
6007
6179
  socket.write(helloFrame(token, c, r));
6008
6180
  if (stdin.isTTY) stdin.setRawMode(true);
6009
6181
  vtInput = enableVtInput();
6182
+ vtTimer = setTimeout(() => write(keys.decide(false)), VT_WAIT_MS);
6010
6183
  void vtInput.ready.then((on) => {
6011
- if (on) swapKeys = false;
6184
+ clearTimeout(vtTimer);
6185
+ write(keys.decide(on));
6012
6186
  });
6013
6187
  stdin.on("data", onInput);
6014
6188
  stdin.resume();
@@ -27603,6 +27777,11 @@ var RunDir = class {
27603
27777
  projectsDir;
27604
27778
  /** audit.processes: programs the sandbox started, in small .jsonl files the host takes in and deletes. */
27605
27779
  auditDir;
27780
+ /**
27781
+ * Each running session's process tree, recorded by its ptyhost (guest only: the daemon ends what a ptyhost that was
27782
+ * itself ended left running).
27783
+ */
27784
+ treesDir;
27606
27785
  /** One-shot secrets the host hands over (see secrets.ts). Next to run, not in it: shared-use users can change run. */
27607
27786
  secretsDir;
27608
27787
  constructor(dataDir2) {
@@ -27616,6 +27795,7 @@ var RunDir = class {
27616
27795
  this.sessionsDir = path5.join(this.root, "sessions");
27617
27796
  this.projectsDir = path5.join(this.root, "projects");
27618
27797
  this.auditDir = path5.join(this.root, "audit");
27798
+ this.treesDir = path5.join(this.root, "trees");
27619
27799
  this.secretsDir = path5.join(dataDir2, "secrets");
27620
27800
  }
27621
27801
  requestFile(id) {
@@ -27627,6 +27807,9 @@ var RunDir = class {
27627
27807
  sessionFile(id) {
27628
27808
  return path5.join(this.sessionsDir, safeId(id) + ".json");
27629
27809
  }
27810
+ treeFile(id) {
27811
+ return path5.join(this.treesDir, safeId(id) + ".json");
27812
+ }
27630
27813
  projectFile(name) {
27631
27814
  return path5.join(this.projectsDir, safeId(name) + ".json");
27632
27815
  }
@@ -3,7 +3,7 @@
3
3
  import { safeId } from '../shared/channel/index.js';
4
4
  import { GuestRequestSchema, SandboxStatusSchema, SessionStatusSchema } from '../shared/channel/index.js';
5
5
  import { bootLine, firstStartNotice, pendingDownloads, readyIn, stillWorking } from '../cli/commands/launch/index.js';
6
- import { CTRL_C_CONFIRM_MS, CTRL_C_HINT, CtrlCGuard, encodeFrame, FrameReader, FrameType, fixWindowsKeys, helloFrame, InputJoiner, MAX_FRAME_BYTES, MOUSE_OFF, OutputFilter, } from '../shared/pty/index.js';
6
+ import { CTRL_C_CONFIRM_MS, CTRL_C_HINT, CtrlCGuard, encodeFrame, FrameReader, FrameType, fixWindowsKeys, helloFrame, InputJoiner, KeyGate, MAX_FRAME_BYTES, MOUSE_OFF, MouseModes, OutputFilter, } from '../shared/pty/index.js';
7
7
  import { bootSilent, FIRST_STATUS_MS } from '../host/channel/index.js';
8
8
  export const channelSuite = {
9
9
  name: 'channel',
@@ -148,6 +148,77 @@ export const channelSuite = {
148
148
  hello.payload[4] === 2 &&
149
149
  hello.payload.subarray(5).toString() === 'tok', 'a frame of exactly the maximum size is accepted; a hello frame carries size, flags and token');
150
150
  t.equal(fixWindowsKeys(Buffer.from([0x61, 0x08, 0x7f, 0x1b])), Buffer.from([0x61, 0x7f, 0x08, 0x1b]), 'Windows console Backspace (^H) reaches the agent as DEL, so it deletes one character, not a word');
151
+ {
152
+ // Keys typed while the client is still turning VT input on are the terminal's own bytes once it is on: they wait
153
+ // and are never swapped (a Backspace sent as DEL used to reach the agent as ^H, deleting a word).
154
+ const keys = new KeyGate(true);
155
+ const early = keys.push(Buffer.from([0x7f, 0x08]));
156
+ const held = keys.decide(true);
157
+ const later = keys.push(Buffer.from([0x7f]));
158
+ const fallback = new KeyGate(true);
159
+ fallback.push(Buffer.from([0x08]));
160
+ const off = fallback.decide(false);
161
+ const plain = new KeyGate(false).push(Buffer.from([0x7f, 0x08]));
162
+ t.check(early.length === 0 &&
163
+ held.equals(Buffer.from([0x7f, 0x08])) &&
164
+ later.equals(Buffer.from([0x7f])) &&
165
+ off.equals(Buffer.from([0x7f])) &&
166
+ fallback.push(Buffer.from([0x7f])).equals(Buffer.from([0x08])) &&
167
+ plain.equals(Buffer.from([0x7f, 0x08])), 'keys typed before the console mode is known wait, then go unswapped with VT input (swapped only without it)');
168
+ }
169
+ t.section('raw mouse input: motion and drag only while the agent asked for them');
170
+ {
171
+ const m = new MouseModes();
172
+ const b = (text) => Buffer.from(text, 'latin1');
173
+ const press = '\x1b[<0;10;5M';
174
+ const release = '\x1b[<0;10;5m';
175
+ const wheel = '\x1b[<64;10;5M';
176
+ const drag = '\x1b[<32;11;5M';
177
+ const ctrlDrag = '\x1b[<48;11;5M';
178
+ const move = '\x1b[<35;12;6M';
179
+ const all = 'a' + press + drag + move + release + wheel + 'b';
180
+ const pass = () => m.filter(b(all)).toString('latin1');
181
+ const none = pass();
182
+ m.watch(b('\x1b[?10'));
183
+ m.watch(b('00h\x1b[?1006h'));
184
+ const normal = pass();
185
+ m.watch(b('\x1b[?1002h'));
186
+ const button = pass();
187
+ const ctrl = m.filter(b(ctrlDrag)).toString('latin1');
188
+ m.watch(b('\x1b[?1003;1015h'));
189
+ const any = pass();
190
+ const urxvt = m.filter(b('\x1b[67;3;4M\x1b[1;5C')).toString('latin1');
191
+ t.check(none === 'ab' &&
192
+ normal === 'a' + press + release + wheel + 'b' &&
193
+ button === 'a' + press + drag + release + wheel + 'b' &&
194
+ ctrl === ctrlDrag &&
195
+ any === all &&
196
+ urxvt === '\x1b[67;3;4M\x1b[1;5C' &&
197
+ m.modes().join(',') === '1000,1002,1003,1006,1015', 'no tracking: no reports; 1000: clicks and wheel; 1002: + drags (modifiers kept); 1003: + every move; mode ' +
198
+ 'changes split across chunks and lists (1003;1015) are followed; keys are never touched', JSON.stringify({ none, normal, button, ctrl, any, urxvt, modes: m.modes() }));
199
+ m.watch(b('\x1b[?1003l'));
200
+ const afterOff = pass();
201
+ m.watch(b('\x1b[?1015l\x1b[?1000l\x1b[?1002l'));
202
+ const x10 = m.filter(b('k\x1b[M #%')).toString('latin1');
203
+ m.watch(b('\x1b[?1000h'));
204
+ const x10On = m.filter(b('k\x1b[M #%\x1b[MC#%')).toString('latin1');
205
+ m.watch(b('\x1bc'));
206
+ const ris = m.tracking();
207
+ m.watch(b('\x1b[?1003h\x1b[!p'));
208
+ const decstr = m.tracking();
209
+ m.watch(b('\x1b[?1002h\x1b[?2004h'));
210
+ const paste = m.has(2004);
211
+ m.reset();
212
+ t.check(afterOff === 'a' + press + drag + release + wheel + 'b' &&
213
+ x10 === 'k' &&
214
+ x10On === 'k\x1b[M #%' &&
215
+ !ris &&
216
+ !decstr &&
217
+ paste &&
218
+ !m.tracking() &&
219
+ !m.has(2004), 'turning 1003 off drops moves again; X10 reports follow the same rules; a full or soft reset (ESC c, ' +
220
+ 'ESC[!p) and a restart turn tracking off', JSON.stringify({ afterOff, x10, x10On, ris, decstr, paste }));
221
+ }
151
222
  t.section('Ctrl+C meant as "copy" does not reach the agent');
152
223
  const ctrlC = Buffer.from([3]);
153
224
  const g = new CtrlCGuard();
@@ -15,44 +15,8 @@ import { currentUserSid } from '../host/sharing/index.js';
15
15
  import { addHolder, CONTAINED_FOLDERS, containedAvailability, ContainedStorage, containedMappings, containedPaths, diagnose, hostDataFolder, markMounted, mountedSets, planTransfer, providerFor, pruneHolders, removeHolder, runTransfer, ScriptDiskPort, setNameOf, setStateOf, staleMountNote, StateFile, } from '../host/storage/index.js';
16
16
  import { EMPTY_STATE } from '../host/storage/contained/state.js';
17
17
  import { SYSTEM } from '../shared/system.js';
18
+ import { fakeVhdx } from './support/vhdx.js';
18
19
  const SCRIPTS = ['contained-common', 'mount-contained', 'dismount-contained', 'check-contained', 'run-elevated'];
19
- /** Mixed-endian GUID bytes, as VHDX (and .NET's Guid) store them. */
20
- function guidBytes(text) {
21
- const hex = text.replace(/-/g, '');
22
- const raw = Buffer.from(hex, 'hex');
23
- return Buffer.concat([
24
- Buffer.from(raw.subarray(0, 4)).reverse(),
25
- Buffer.from(raw.subarray(4, 6)).reverse(),
26
- Buffer.from(raw.subarray(6, 8)).reverse(),
27
- raw.subarray(8, 16),
28
- ]);
29
- }
30
- /** A file with the parts of a VHDX header the scripts read (signature, region table, metadata table). */
31
- function fakeVhdx(file, o) {
32
- const buf = Buffer.alloc(2 * 1024 * 1024);
33
- buf.write('vhdxfile', 0, 'ascii');
34
- const table = 196608;
35
- buf.write('regi', table, 'ascii');
36
- buf.writeUInt32LE(1, table + 8);
37
- guidBytes('8b7ca206-4790-4b9a-b8fe-575f050f886e').copy(buf, table + 16);
38
- buf.writeBigUInt64LE(BigInt(1048576), table + 32);
39
- buf.writeUInt32LE(65536, table + 40);
40
- const md = 1048576;
41
- buf.write('metadata', md, 'ascii');
42
- const items = [
43
- { guid: '2fa54224-cd1b-4876-b211-5dbed83bf4b8', offset: 65536 },
44
- ...(o.parent ? [{ guid: 'a8d35f2d-b30b-454d-abf7-d3d84834ab0c', offset: 65600 }] : []),
45
- ];
46
- buf.writeUInt16LE(items.length, md + 10);
47
- items.forEach((item, i) => {
48
- const at = md + 32 + 32 * i;
49
- guidBytes(item.guid).copy(buf, at);
50
- buf.writeUInt32LE(item.offset, at + 16);
51
- buf.writeUInt32LE(8, at + 20);
52
- });
53
- buf.writeBigUInt64LE(BigInt(o.gb) * 1024n ** 3n, md + 65536);
54
- fs.writeFileSync(file, buf);
55
- }
56
20
  class FakePort {
57
21
  paths;
58
22
  calls = [];
@@ -537,10 +537,13 @@ export const hcsSuite = {
537
537
  }
538
538
  t.section('the helpers say the version they ship with (file details)');
539
539
  const version = JSON.parse(fs.readFileSync(path.join(t.packageRoot, 'package.json'), 'utf8')).version;
540
- for (const helper of ['HostTools', 'HcsHost']) {
540
+ for (const helper of ['HostTools', 'HcsHost', 'StorageHelper']) {
541
541
  const info = fs.readFileSync(path.join(t.packageRoot, 'native', 'csharp', helper, 'AssemblyInfo.cs'), 'utf8');
542
542
  t.check(info.includes('AssemblyInformationalVersion("' + version + '")') &&
543
- info.includes('AssemblyFileVersion("' + version + '.0")'), helper + '.exe file version is ' + version + ' (update AssemblyInfo.cs with package.json)');
543
+ info.includes('AssemblyFileVersion("' + version + '.0")'), (helper === 'StorageHelper' ? 'AgentSandboxStorage' : helper) +
544
+ '.exe file version is ' +
545
+ version +
546
+ ' (update AssemblyInfo.cs with package.json)');
544
547
  }
545
548
  t.section('HcsHost.exe compiles (C# 5, the csc that ships with Windows)');
546
549
  if (!isWin || !fs.existsSync(SYSTEM.csc())) {
@@ -34,6 +34,7 @@ import { detectSuite } from './detect.test.js';
34
34
  import { generateSuite } from './generate.test.js';
35
35
  import { engineSuite } from './engine.test.js';
36
36
  import { containedSuite } from './contained.test.js';
37
+ import { storageHelperSuite } from './storage-helper.test.js';
37
38
  import { hcsSuite } from './hcs.test.js';
38
39
  import { gitAccessSuite } from './git-access.test.js';
39
40
  import { guestBootSuite } from './guest-boot.test.js';
@@ -86,6 +87,7 @@ export const SUITES = [
86
87
  generateSuite,
87
88
  engineSuite,
88
89
  containedSuite,
90
+ storageHelperSuite,
89
91
  hcsSuite,
90
92
  guestBootSuite,
91
93
  mirrorSuite,
@@ -24,6 +24,17 @@ function installerText(t, script) {
24
24
  timeout: 60000,
25
25
  });
26
26
  t.equal(parse.status, 0, 'it parses under the PowerShell 5.1 parser' + (parse.stderr ? ': ' + parse.stderr : ''));
27
+ t.section('install.ps1: the storage helper (installs for everyone only)');
28
+ t.check(/\[switch\]\$ContainedStorageHelper,/.test(source) &&
29
+ /\[switch\]\$NoContainedStorageHelper,/.test(source) &&
30
+ source.includes("$env:WDS_STORAGEHELPER -eq '1'"), 'it has -ContainedStorageHelper / -NoContainedStorageHelper (and WDS_STORAGEHELPER)');
31
+ const helperStep = source.slice(source.indexOf('# 8b.'), source.indexOf('# 9. Automatic updates'));
32
+ t.check(helperStep.includes('if ($machine) {') &&
33
+ helperStep.includes('set-storage-helper.ps1') &&
34
+ helperStep.includes('-Install -GroupSid $groupSid -InstallRoot $toolFolder'), 'the helper is installed by its own script, only for an install for everyone, for the group');
35
+ t.check(helperStep.includes("Ask 'Install a small Windows service") && helperStep.includes('$record.storageHelper'), 'asked once (default no), then the answer in install.json is kept');
36
+ t.check(source.includes('storageHelper = [bool]$helperOn') && /'staging', 'helper', 'active-version'/.test(source), 'install.json records it, and uninstall removes the helper folder');
37
+ t.check(/if \(\$machine\) \{ Remove-StorageHelper/.test(source), 'uninstall stops and deletes the service (and dismounts what it mounted) first');
27
38
  t.section('install.ps1: the shims equal shimText()');
28
39
  const cases = [
29
40
  ['sandbox', undefined],
@@ -1,6 +1,7 @@
1
1
  // The architecture rules, checked on the compiled output (dist/**/*.js): which areas may import which, the guest's
2
2
  // portable core, the pure guest files the host and tests may load without the guest runtime's side effects, and no
3
- // import cycles. The bundled dist/runtime is left out (it has no imports).
3
+ // import cycles. The bundled dist/runtime is left out (it has no imports); the bundled commands (dist/bin) must import
4
+ // only their own chunks.
4
5
  import fs from 'node:fs';
5
6
  import path from 'node:path';
6
7
  const STATIC = /^\s*(?:import|export)\b[^;]*?\bfrom\s*(['"])([^'"\n]+)\1;?\s*$|^\s*import\s*(['"])([^'"\n]+)\3;?\s*$/gm;
@@ -149,5 +150,13 @@ export const layoutSuite = {
149
150
  .filter((d) => area(d) === 'app' && !(area(f) === 'host' && d.startsWith('app/kernel/')))
150
151
  .map((d) => f + ' -> ' + d));
151
152
  t.check(!intoApp.length, 'below the CLI only host code uses the shared core, and only its kernel', list(intoApp));
153
+ t.section('the commands');
154
+ // Loading the CLI as hundreds of modules took most of a second before anything was shown (scripts/bundle.mjs).
155
+ const bins = files.filter((f) => area(f) === 'bin');
156
+ const unbundled = bins.flatMap((f) => modules
157
+ .get(f)
158
+ .statics.filter((d) => area(d) !== 'bin')
159
+ .map((d) => f + ' -> ' + d));
160
+ t.check(bins.includes('bin/sandbox.js') && bins.some((f) => f.startsWith('bin/chunk-')) && !unbundled.length, 'the commands (dist/bin) are bundled: they load no compiled module one by one', list(unbundled));
152
161
  },
153
162
  };
@@ -2,11 +2,13 @@
2
2
  // with cmd.exe standing in for the agent. One daemon serves all the checks below, in order (compiling ptyhost is slow).
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
5
6
  import { queryViewers } from '../shared/pty/index.js';
7
+ import { SYSTEM } from '../shared/system.js';
6
8
  import { copyCmdAsFakeAgent, openViewer, sees, startDaemon, tail, viewersWhen, WAIT } from './support/live.js';
7
9
  export const liveDaemonSuite = {
8
10
  name: 'live-daemon',
9
- title: 'live: session daemon + ptyhost (start, attach, viewers, exec, restart, exit)',
11
+ title: 'live: session daemon + ptyhost (start, attach, viewers, exec, a killed ptyhost, restart, exit)',
10
12
  live: true,
11
13
  timeoutMs: 360000,
12
14
  run: async (t, f) => {
@@ -107,6 +109,54 @@ export const liveDaemonSuite = {
107
109
  const again = await daemon.response('close2');
108
110
  t.check(again?.ok === false, 'closing a session that has ended says it is not running', JSON.stringify(again));
109
111
  }
112
+ t.section('live: a session host ended from outside (Task Manager)');
113
+ {
114
+ // A copy of PING with a name of its own, started hidden in a console of its own (closing the pseudo console
115
+ // does not end it) by a PowerShell the shell runs; then the session's ptyhost is killed.
116
+ const probe = t.at('tree-probe.exe');
117
+ fs.copyFileSync(path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'PING.EXE'), probe);
118
+ const probeRunning = () => spawnSync(SYSTEM.tasklist(), ['/FI', 'IMAGENAME eq tree-probe.exe', '/NH'], { encoding: 'utf8' })
119
+ .stdout.toLowerCase()
120
+ .includes('tree-probe.exe');
121
+ daemon.request({ kind: 'session', id: 'shell2', harness: 'shell', args: [], cwd: workspace, shell: 'cmd' });
122
+ const tree = await daemon.started('shell2');
123
+ const record = path.join(daemon.run, 'trees', 'shell2.json');
124
+ const readRecord = () => {
125
+ try {
126
+ return JSON.parse(fs.readFileSync(record, 'utf8'));
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ };
132
+ if (tree?.attach) {
133
+ const treeView = await openViewer(tree.attach);
134
+ viewers.push(treeView);
135
+ await sees(t, treeView, '>');
136
+ treeView.type('"' +
137
+ SYSTEM.powershell() +
138
+ '" -NoProfile -Command "Start-Process -WindowStyle Hidden -FilePath \'' +
139
+ probe +
140
+ "' -ArgumentList '-n','120','127.0.0.1'; Start-Sleep -Seconds 8\"");
141
+ // cmd, the PowerShell and the probe, once ptyhost has followed the tree (every 3 s).
142
+ const recorded = await t.waitFor(() => {
143
+ const r = readRecord();
144
+ return probeRunning() && r && r.processes.length >= 3 ? r : null;
145
+ }, WAIT.screen);
146
+ t.check(recorded !== null, "the session's ptyhost records what its program started in the run folder", JSON.stringify(readRecord()) + ' probe running: ' + probeRunning() + '\n' + daemon.diagnostics());
147
+ if (recorded) {
148
+ spawnSync(path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe'), ['/F', '/PID', String(recorded.ptyhost.pid)], { stdio: 'ignore' });
149
+ const gone = await t.waitFor(() => !probeRunning() && !fs.existsSync(record), 30000);
150
+ t.check(Boolean(gone) &&
151
+ daemon.status('shell2')?.phase === 'exited' &&
152
+ /session host was ended from outside; ended [1-9]\d* process\(es\)/.test(daemon.diagnostics()), 'when its ptyhost is killed, the daemon ends what the session left running (and forgets the record)', 'probe running: ' + probeRunning() + ', record: ' + fs.existsSync(record) + '\n' + daemon.diagnostics());
153
+ }
154
+ }
155
+ else
156
+ t.check(false, 'a second shell session starts', JSON.stringify(tree) + '\n' + daemon.diagnostics());
157
+ if (probeRunning())
158
+ spawnSync(path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe'), ['/F', '/IM', 'tree-probe.exe'], { stdio: 'ignore' });
159
+ }
110
160
  t.section('live: restart and exit');
111
161
  daemon.request({ kind: 'restart', id: 'restart1', harness: 'claude' });
112
162
  const sawResumed = await sees(t, viewer, 'LIVE-AGENT-RESUMED');