windows-development-sandbox 1.11.3 → 1.11.5

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 (36) hide show
  1. package/dist/app/features/accounts/accounts.def.js +0 -1
  2. package/dist/app/features/data/data.def.d.ts +1 -1
  3. package/dist/app/features/data/data.def.js +41 -48
  4. package/dist/app/features/defs.d.ts +7 -0
  5. package/dist/app/features/defs.js +7 -3
  6. package/dist/app/features/health/health.def.js +28 -9
  7. package/dist/app/features/sandbox/sandbox.def.js +4 -4
  8. package/dist/app/features/storage/storage.def.js +8 -13
  9. package/dist/app/features/updates/updates.def.js +2 -3
  10. package/dist/guest/toolchains/tools/cli.js +19 -0
  11. package/dist/host/life/host-memory.d.ts +18 -0
  12. package/dist/host/life/host-memory.js +66 -0
  13. package/dist/host/web/runner.d.ts +1 -1
  14. package/dist/host/web/server.d.ts +12 -3
  15. package/dist/host/web/server.js +20 -6
  16. package/dist/runtime/daemon.js +16 -1
  17. package/dist/runtime/main.js +16 -1
  18. package/dist/runtime/view.js +2 -0
  19. package/dist/selftest/app.test.js +21 -9
  20. package/dist/selftest/health.test.js +35 -0
  21. package/dist/selftest/live-terminal.test.js +30 -0
  22. package/dist/selftest/support/web-preview.js +3 -0
  23. package/dist/selftest/web.test.js +33 -0
  24. package/dist/shared/pty/client.d.ts +0 -1
  25. package/dist/shared/pty/client.js +5 -0
  26. package/dist/sync/watcher.d.ts +2 -0
  27. package/dist/sync/watcher.js +7 -2
  28. package/dist/web/index.html +2 -2
  29. package/dist/web/{main-Z645W45E.js → main-QL7KHCEE.js} +76 -76
  30. package/dist/web/{main-6JF3ASB5.css → main-RQFN4WBY.css} +1 -1
  31. package/native/csharp/HcsHost/AssemblyInfo.cs +3 -3
  32. package/native/csharp/HostTools/AssemblyInfo.cs +3 -3
  33. package/native/csharp/HostTools/MemoryStatus.cs +58 -2
  34. package/native/csharp/HostTools/Program.cs +7 -1
  35. package/package.json +1 -1
  36. package/sandbox/csharp/PtyHost.cs +228 -2
@@ -29266,6 +29266,17 @@ function ghExePath() {
29266
29266
  const dir = tool("cli", "gh");
29267
29267
  return path34.join(entry?.subdir ? path34.join(dir, entry.subdir) : dir, entry?.exe ?? "gh.exe");
29268
29268
  }
29269
+ var GH_NOTE = " (log in once with: gh auth login --web --insecure-storage; it is kept between sandboxes)";
29270
+ function warnKeyringLogin() {
29271
+ try {
29272
+ const hosts = fs28.readFileSync(path34.join(ghConfigDir(), "hosts.yml"), "utf8");
29273
+ if (/^\s*user:/m.test(hosts) && !/oauth_token:/.test(hosts))
29274
+ warn(
29275
+ "gh: the GitHub login from an earlier sandbox was kept in Windows Credential Manager, which is wiped with each sandbox. Log in again with: gh auth login --web --insecure-storage (then it lasts)."
29276
+ );
29277
+ } catch {
29278
+ }
29279
+ }
29269
29280
  var toolVars = {
29270
29281
  gh: () => ({
29271
29282
  GH_CONFIG_DIR: exists(deliveredGhConfigDir()) ? deliveredGhConfigDir() : mkdirp(ghConfigDir()),
@@ -29306,9 +29317,11 @@ async function setupCli(env, settings, gitAccess, refresh = false) {
29306
29317
  await setupSingleExe(env, name, entry.repo, entry.asset(arch), entry.exe, tool("cli", name), {
29307
29318
  refresh,
29308
29319
  subdir: entry.subdir,
29309
- size: entry.size
29320
+ size: entry.size,
29321
+ ...name === "gh" ? { note: GH_NOTE } : {}
29310
29322
  });
29311
29323
  Object.assign(env.vars, toolVars[name]?.() ?? {});
29324
+ if (name === "gh") warnKeyringLogin();
29312
29325
  } catch (e) {
29313
29326
  warn(name + " could not be set up and is skipped; the other tools are not affected. Reason: " + errMsg(e));
29314
29327
  env.summary.push(entry.name + ": FAILED");
@@ -34673,6 +34686,7 @@ var connectOnce = (host, port, timeoutMs) => {
34673
34686
  socket.once("connect", () => {
34674
34687
  clearTimeout(timer);
34675
34688
  socket.setNoDelay(true);
34689
+ socket.setKeepAlive(true, KEEPALIVE_IDLE_MS);
34676
34690
  resolve2(socket);
34677
34691
  });
34678
34692
  socket.once("error", (e) => {
@@ -34681,6 +34695,7 @@ var connectOnce = (host, port, timeoutMs) => {
34681
34695
  });
34682
34696
  });
34683
34697
  };
34698
+ var KEEPALIVE_IDLE_MS = 1e4;
34684
34699
  async function connect(endpoint, timeoutMs = 3e3) {
34685
34700
  const errors = [];
34686
34701
  for (const address of endpoint.addresses) {
@@ -5594,6 +5594,7 @@ var connectOnce = (host, port, timeoutMs) => {
5594
5594
  socket.once("connect", () => {
5595
5595
  clearTimeout(timer);
5596
5596
  socket.setNoDelay(true);
5597
+ socket.setKeepAlive(true, KEEPALIVE_IDLE_MS);
5597
5598
  resolve(socket);
5598
5599
  });
5599
5600
  socket.once("error", (e) => {
@@ -5602,6 +5603,7 @@ var connectOnce = (host, port, timeoutMs) => {
5602
5603
  });
5603
5604
  });
5604
5605
  };
5606
+ var KEEPALIVE_IDLE_MS = 1e4;
5605
5607
  async function connect(endpoint, timeoutMs = 3e3) {
5606
5608
  const errors = [];
5607
5609
  for (const address of endpoint.addresses) {
@@ -629,12 +629,18 @@ export const appSuite = {
629
629
  const dataSample = dataPage(stored);
630
630
  const dataSectionIds = dataSample.sections.map((s) => s.id);
631
631
  const dataOps = pageActions(dataSample);
632
- const accountsItem = dataSample.sections.find((s) => s.id === 'area:host')?.items?.[0];
633
- t.check(dataSectionIds.includes('area:tools') &&
634
- dataSectionIds.includes('area:state') &&
635
- dataOps.some((a) => a.kind === 'operation' && a.op === 'data.clean' && JSON.stringify(a.input) === '{"targets":["tools/rust"]}') &&
636
- (accountsItem?.actions ?? []).length === 0 &&
637
- dataOps.some((a) => a.kind === 'operation' && a.op === 'data.prune'), 'data: disks, one section per area, x deletes only what may be deleted', dataSectionIds.join());
632
+ const keptSection = dataSample.sections.find((s) => s.id === 'kept');
633
+ const keptItems = keptSection?.items ?? [];
634
+ const accountsItem = keptItems.find((i) => i.label === 'accounts');
635
+ t.check(dataSectionIds.join() === 'disks,kept' &&
636
+ keptItems.map((i) => i.label).join() === 'rust,state/claude,accounts' &&
637
+ keptSection?.summary === '6.5 GB in all' &&
638
+ keptItems[0]?.primary?.kind === 'operation' &&
639
+ keptItems[0].primary.op === 'data.clean' &&
640
+ JSON.stringify(keptItems[0].primary.input) === '{"targets":["tools/rust"]}' &&
641
+ accountsItem?.primary === undefined &&
642
+ (keptSection?.actions ?? []).some((a) => a.kind === 'operation' && a.op === 'data.prune') &&
643
+ dataOps.some((a) => a.kind === 'operation' && a.op === 'data.clean'), 'data: disks, then one table of every kept folder (total in the summary); delete only where it may be deleted', dataSectionIds.join());
638
644
  const shaped = showData.cli.shapeJson(stored);
639
645
  const shapedEntries = shaped.entries;
640
646
  const shapedDisks = shaped.disks;
@@ -644,6 +650,10 @@ export const appSuite = {
644
650
  const dataLines = pageLines(dataSample);
645
651
  t.check(dataLines.some((l) => l.startsWith('Disks')) &&
646
652
  dataLines.some((l) => /^DRIVE\s+USED\s+FREE\s+SIZE/.test(l)) &&
653
+ dataLines.some((l) => l.startsWith('What the sandbox keeps 6.5 GB in all')) &&
654
+ dataLines.some((l) => /^KIND\s+NAME\s+SIZE\s+FOLDER/.test(l)) &&
655
+ dataLines.some((l) => /^Toolchains\s+rust\s+4\.6 GB\s+C:\\AgentData\\tools\\rust/.test(l)) &&
656
+ dataLines.some((l) => l.startsWith('Kept on this PC on purpose, not deleted here: accounts.')) &&
647
657
  dataLines.some((l) => l.startsWith('Persistent data for the sandbox: ')) &&
648
658
  dataLines.some((l) => l.startsWith('Free space with:')), 'data on the command line: the disks table, the data folder line and the free space line', dataLines.join('\n'));
649
659
  t.check(definitionFor('data', ['clean'])?.id === 'data.clean' &&
@@ -837,10 +847,12 @@ export const appSuite = {
837
847
  mountedNow: null,
838
848
  };
839
849
  const storagePageNow = storagePage(shownStorage, shownContained);
840
- const folderPrimary = storagePageNow.sections[0]?.items?.[0]?.primary;
850
+ const folderSection = storagePageNow.sections[0];
851
+ const folderOps = (folderSection?.actions ?? []).map((a) => (a.kind === 'operation' ? a.op : a.kind));
841
852
  const containedActions = storagePageNow.sections[1]?.actions ?? [];
842
- t.check(folderPrimary?.kind === 'operation' &&
843
- folderPrimary.op === 'storage.change' &&
853
+ t.check(folderOps.join() === 'storage.move,storage.use' &&
854
+ (folderSection?.facts ?? []).map((f) => f.label).join() ===
855
+ 'Folder,Size,Default folder,Other folder with data' &&
844
856
  containedActions.length === 1 &&
845
857
  containedActions[0].kind === 'operation' &&
846
858
  containedActions[0].op === 'storage.containedOn', 'the storage page: the data folder row changes it, and contained storage offers to turn it on while it is off');
@@ -524,6 +524,41 @@ async function sharedCore(t) {
524
524
  t.equal(keys, ['f', 'l'], 'the health keys are f and l (e only when Windows Sandbox can be turned on)');
525
525
  const canEnable = healthPage({ last: { ...saved, setup: { ...saved.setup, canEnable: true } } });
526
526
  t.equal(menuView(canEnable, { category: 'health', refresh: false }).actions.map((a) => a.key), ['f', 'l', 'e'], 'e turns Windows Sandbox on when the setup says it can be');
527
+ const problems = healthPage({
528
+ last: {
529
+ ...saved,
530
+ mode: 'repair',
531
+ checks: [
532
+ {
533
+ id: 'stale',
534
+ group: 'Data folder',
535
+ name: 'Stale run folders',
536
+ status: 'needs-you',
537
+ detail: 'two folders from a sandbox that did not close',
538
+ means: 'they take space',
539
+ fix: 'stop the sandbox',
540
+ lines: ['run-20261012-0931'],
541
+ fixable: false,
542
+ },
543
+ {
544
+ id: 'net',
545
+ group: 'Network',
546
+ name: 'GitHub',
547
+ status: 'warn',
548
+ detail: 'no answer',
549
+ fix: 'check your VPN',
550
+ fixable: false,
551
+ },
552
+ ],
553
+ },
554
+ });
555
+ const repairItems = problems.sections.find((s) => s.id === 'repair')?.items ?? [];
556
+ const stale = repairItems[0];
557
+ t.equal(stale?.details, ['Found: two folders from a sandbox that did not close', 'Fix: stop the sandbox', 'run-20261012-0931'], 'a repair row lists what was found as a Found: line, then the fix and the paths (ids stay plain)');
558
+ t.equal(stale?.description, 'they take space', 'a repair row with a meaning shows it as the description');
559
+ const net = repairItems[1];
560
+ t.equal(net?.details, ['Found: no answer', 'Fix: check your VPN'], 'a repair row without a meaning still says Found:');
561
+ t.equal(net?.description, undefined, 'without a meaning, the found text is not repeated as the description');
527
562
  }
528
563
  export const healthSuite = {
529
564
  name: 'health',
@@ -90,6 +90,36 @@ export const liveTerminalSuite = {
90
90
  t.check(!errors, 'ptyhost and the stand-in agent compile', errors);
91
91
  if (errors)
92
92
  return;
93
+ t.section('what a program leaves running ends with it (no leftover processes)');
94
+ {
95
+ // A copy of PING with a name of its own, started detached and hidden (its own console, so closing the pseudo
96
+ // console does not end it), by a program that then exits.
97
+ const probe = t.at('leftover-probe.exe');
98
+ fs.copyFileSync(path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'PING.EXE'), probe);
99
+ const starter = '"' +
100
+ SYSTEM.powershell() +
101
+ '" -NoProfile -Command "Start-Process -WindowStyle Hidden -FilePath \'' +
102
+ probe +
103
+ "' -ArgumentList '-n','120','127.0.0.1'; Start-Sleep -Seconds 5\"";
104
+ const host = spawn(ptyhost, [String(await freePort()), 'tok', '80', '24', t.at('leftover.log'), starter], {
105
+ env: { ...process.env, PTYHOST_BIND: 'loopback' },
106
+ stdio: 'ignore',
107
+ windowsHide: true,
108
+ });
109
+ const probeRunning = () => spawnSync(SYSTEM.tasklist(), ['/FI', 'IMAGENAME eq leftover-probe.exe', '/NH'], { encoding: 'utf8' })
110
+ .stdout.toLowerCase()
111
+ .includes('leftover-probe.exe');
112
+ await t.waitFor(probeRunning, WAIT.screen);
113
+ t.check(probeRunning(), 'the program started a background process of its own');
114
+ await t.waitFor(() => host.exitCode !== null, 30000);
115
+ await t.waitFor(() => !probeRunning(), 5000);
116
+ const log = fs.existsSync(t.at('leftover.log')) ? fs.readFileSync(t.at('leftover.log'), 'utf8') : '';
117
+ t.check(!probeRunning() && /ended [1-9]\d* process\(es\) the program left running/.test(log), 'when the program exits, the background process it left running is ended too', log.slice(-400));
118
+ if (probeRunning())
119
+ spawnSync(path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe'), ['/F', '/IM', 'leftover-probe.exe'], {
120
+ stdio: 'ignore',
121
+ });
122
+ }
93
123
  t.section("the agents' job: a last-resort backstop, set once");
94
124
  {
95
125
  // A job of the test's own (PTYHOST_JOB_NAME), never the agents' real one.
@@ -575,6 +575,8 @@ function previewRpc(ctx, state) {
575
575
  return { message: 'Done. Restart Windows to finish. (preview: nothing was changed)' };
576
576
  case 'engine.status':
577
577
  return sampleEngines();
578
+ case 'engine.leftovers':
579
+ return { available: true, message: 'No HCS systems of yours are running.', systems: [] };
578
580
  case 'logs.list':
579
581
  return sampleLogs();
580
582
  case 'logs.tail':
@@ -789,6 +791,7 @@ function previewRpc(ctx, state) {
789
791
  'logs.list': 'query',
790
792
  'logs.tail': 'query',
791
793
  'engine.status': 'query',
794
+ 'engine.leftovers': 'query',
792
795
  };
793
796
  return {
794
797
  kind: (id) => kinds[id],
@@ -245,6 +245,39 @@ export const webSuite = {
245
245
  await new Promise((r) => setTimeout(r, 300));
246
246
  t.check(hub.size === 0 && reads === stopped, 'with nobody watching, nothing is read');
247
247
  }
248
+ t.section('an unused server ends by itself (no process left behind)');
249
+ {
250
+ const idleServer = async (sandbox, limits) => {
251
+ let ended = false;
252
+ const idle = new WebServer({
253
+ port: 0,
254
+ token: TOKEN,
255
+ backend: {
256
+ info: () => Promise.resolve({
257
+ sandbox,
258
+ owner: null,
259
+ canUse: true,
260
+ harnesses: [],
261
+ defaultHarness: 'claude',
262
+ projects: [],
263
+ }),
264
+ sessions: () => Promise.resolve([]),
265
+ endpoint: () => null,
266
+ },
267
+ staticRoot: t.dir('idle-static-' + sandbox + limits.exit),
268
+ idleExitMs: limits.exit,
269
+ idleNoSandboxMs: limits.quiet,
270
+ onStop: () => (ended = true),
271
+ });
272
+ await idle.start();
273
+ await new Promise((r) => setTimeout(r, 1500));
274
+ await idle.stop();
275
+ return ended;
276
+ };
277
+ t.check(await idleServer('stopped', { exit: 60_000, quiet: 200 }), 'no browser and no sandbox: it ends after the short wait');
278
+ t.check(!(await idleServer('running', { exit: 60_000, quiet: 200 })), 'a sandbox runs: the short wait does not end it');
279
+ t.check(await idleServer('running', { exit: 300, quiet: 200 }), 'no browser for the long wait: it ends even while a sandbox runs');
280
+ }
248
281
  t.section('the server');
249
282
  const fake = new FakePtyHost();
250
283
  await fake.start();
@@ -4,7 +4,6 @@ export interface Endpoint {
4
4
  port: number;
5
5
  token: string;
6
6
  }
7
- /** Tries every address the sandbox published and returns the first one that answers. */
8
7
  export declare function connect(endpoint: Endpoint, timeoutMs?: number): Promise<net.Socket>;
9
8
  /** Sends one control frame (e.g. RESTART) without taking over the screen. */
10
9
  export declare function sendControl(endpoint: Endpoint, type: number): Promise<void>;
@@ -10,6 +10,9 @@ const connectOnce = (host, port, timeoutMs) => {
10
10
  socket.once('connect', () => {
11
11
  clearTimeout(timer);
12
12
  socket.setNoDelay(true);
13
+ // A sandbox that vanished (closed hard, ended in Task Manager) never closes the connection: keepalive notices
14
+ // within seconds, so an attached terminal or the web's bridge ends instead of hanging on.
15
+ socket.setKeepAlive(true, KEEPALIVE_IDLE_MS);
13
16
  resolve(socket);
14
17
  });
15
18
  socket.once('error', (e) => {
@@ -19,6 +22,8 @@ const connectOnce = (host, port, timeoutMs) => {
19
22
  });
20
23
  };
21
24
  /** Tries every address the sandbox published and returns the first one that answers. */
25
+ /** Idle time before TCP keepalive probes start (the same as ptyhost's own side). */
26
+ const KEEPALIVE_IDLE_MS = 10_000;
22
27
  export async function connect(endpoint, timeoutMs = 3000) {
23
28
  const errors = [];
24
29
  for (const address of endpoint.addresses) {
@@ -23,6 +23,8 @@ export declare class Watcher {
23
23
  private views;
24
24
  private stops;
25
25
  private watchdogTimer;
26
+ /** This PC's memory, read by one long-running HostTools instead of a program per sample. */
27
+ private memoryStream;
26
28
  private watchdog;
27
29
  private watchdogBusy;
28
30
  private lastCpu;
@@ -13,7 +13,7 @@ import { readForwards, sandboxAddress, startPortForwards } from '../host/network
13
13
  import { AuditCollector, readAuditSettings } from '../host/audit/collect.js';
14
14
  import { readMarkedProjects, saveDataSize, watchAndMark } from '../host/guard/index.js';
15
15
  import { sizeWithin } from '../core/disk.js';
16
- import { autoRestartAllowed, causeSince, appendHostMemory, cpuPercentBetween, KeepAwake, LifeRecord, readHostMemoryAsync, readLoadSettings, REQUEST_SECONDS, WATCHDOG_START, watchdogStep, saveCrashReport, SessionLedger, } from '../host/life/index.js';
16
+ import { autoRestartAllowed, causeSince, appendHostMemory, cpuPercentBetween, KeepAwake, LifeRecord, HostMemoryStream, readHostMemoryAsync, readLoadSettings, REQUEST_SECONDS, WATCHDOG_START, watchdogStep, saveCrashReport, SessionLedger, } from '../host/life/index.js';
17
17
  import { SandboxChannel } from '../host/channel/index.js';
18
18
  import { ProjectRegistry } from '../host/projects/registry.js';
19
19
  import { ContainedStorage, setNameOf, storageOf } from '../host/storage/index.js';
@@ -85,6 +85,8 @@ export class Watcher {
85
85
  views = [];
86
86
  stops = [];
87
87
  watchdogTimer = null;
88
+ /** This PC's memory, read by one long-running HostTools instead of a program per sample. */
89
+ memoryStream = null;
88
90
  watchdog = WATCHDOG_START;
89
91
  watchdogBusy = false;
90
92
  lastCpu = os.cpus();
@@ -208,6 +210,8 @@ export class Watcher {
208
210
  });
209
211
  this.stops = [...viewStops, stopForwards, stopMarking];
210
212
  // Memory and CPU of this PC: when low, the sandbox is asked to slow its builds down (host/life/watchdog.ts).
213
+ this.memoryStream = new HostMemoryStream(storageRoot, WATCHDOG_MS / 1000);
214
+ this.memoryStream.start();
211
215
  this.watchdogTimer = setInterval(() => void this.watchHost(), WATCHDOG_MS);
212
216
  this.watchdogTimer.unref();
213
217
  // sandbox.keepAwake, and the life record that tells the next launch how this run ended (host/life).
@@ -220,7 +224,7 @@ export class Watcher {
220
224
  this.watchdogBusy = true;
221
225
  try {
222
226
  const settings = readLoadSettings(this.storageRoot, this.dataPath);
223
- const memory = await readHostMemoryAsync(this.storageRoot);
227
+ const memory = this.memoryStream?.latest() ?? (await readHostMemoryAsync(this.storageRoot));
224
228
  const cpus = os.cpus();
225
229
  const cpuPercent = cpuPercentBetween(this.lastCpu, cpus);
226
230
  this.lastCpu = cpus;
@@ -324,6 +328,7 @@ export class Watcher {
324
328
  this.awake?.stop();
325
329
  if (this.watchdogTimer)
326
330
  clearInterval(this.watchdogTimer);
331
+ this.memoryStream?.stop();
327
332
  this.stops.forEach((stop) => stop());
328
333
  if (this.auditTimer)
329
334
  clearInterval(this.auditTimer);
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Sandbox</title>
7
7
  <link rel="icon" href="data:," />
8
- <link rel="stylesheet" href="/main-6JF3ASB5.css" />
9
- <script type="module" src="/main-Z645W45E.js"></script>
8
+ <link rel="stylesheet" href="/main-RQFN4WBY.css" />
9
+ <script type="module" src="/main-QL7KHCEE.js"></script>
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>