windows-development-sandbox 1.1.0 → 1.4.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 (187) hide show
  1. package/README.md +3 -2
  2. package/config.example.json +38 -5
  3. package/dist/cli/args.d.ts +3 -0
  4. package/dist/cli/args.js +3 -1
  5. package/dist/cli/commands/accounts.js +2 -2
  6. package/dist/cli/commands/agent-commands.js +1 -1
  7. package/dist/cli/commands/context.js +2 -1
  8. package/dist/cli/commands/data/clean.js +6 -1
  9. package/dist/cli/commands/data/collect.js +3 -3
  10. package/dist/cli/commands/index.d.ts +1 -1
  11. package/dist/cli/commands/index.js +1 -1
  12. package/dist/cli/commands/launch.js +36 -3
  13. package/dist/cli/commands/maintenance.d.ts +2 -0
  14. package/dist/cli/commands/maintenance.js +35 -2
  15. package/dist/cli/commands/sessions.js +3 -3
  16. package/dist/cli/commands/user-config.js +1 -1
  17. package/dist/cli/index.js +6 -4
  18. package/dist/cli/manifest.js +5 -0
  19. package/dist/cli/menu/apps.d.ts +21 -0
  20. package/dist/cli/menu/apps.js +122 -0
  21. package/dist/cli/menu/command-form.d.ts +28 -0
  22. package/dist/cli/menu/command-form.js +219 -0
  23. package/dist/cli/menu/index.d.ts +26 -0
  24. package/dist/cli/menu/index.js +180 -0
  25. package/dist/cli/menu/settings/categories.d.ts +9 -0
  26. package/dist/cli/menu/settings/categories.js +28 -0
  27. package/dist/cli/menu/settings/config-file.d.ts +40 -0
  28. package/dist/cli/menu/settings/config-file.js +71 -0
  29. package/dist/cli/menu/settings/editors.d.ts +30 -0
  30. package/dist/cli/menu/settings/editors.js +305 -0
  31. package/dist/cli/menu/settings/index.d.ts +32 -0
  32. package/dist/cli/menu/settings/index.js +136 -0
  33. package/dist/cli/menu/settings/schema-node.d.ts +62 -0
  34. package/dist/cli/menu/settings/schema-node.js +104 -0
  35. package/dist/cli/{menu-multi.d.ts → menu/several.d.ts} +10 -2
  36. package/dist/cli/menu/several.js +102 -0
  37. package/dist/cli/tui/app.d.ts +39 -0
  38. package/dist/cli/tui/app.js +52 -0
  39. package/dist/cli/tui/dialogs.d.ts +57 -0
  40. package/dist/cli/tui/dialogs.js +162 -0
  41. package/dist/cli/tui/frame.d.ts +18 -0
  42. package/dist/cli/tui/frame.js +27 -0
  43. package/dist/cli/tui/index.d.ts +7 -0
  44. package/dist/cli/tui/index.js +8 -0
  45. package/dist/cli/tui/list-screen.d.ts +101 -0
  46. package/dist/cli/tui/list-screen.js +299 -0
  47. package/dist/cli/tui/terminal.d.ts +37 -0
  48. package/dist/cli/tui/terminal.js +89 -0
  49. package/dist/cli/tui/text.d.ts +20 -0
  50. package/dist/cli/tui/text.js +105 -0
  51. package/dist/cli/tui/theme.d.ts +45 -0
  52. package/dist/cli/tui/theme.js +30 -0
  53. package/dist/core/config.js +9 -6
  54. package/dist/core/local-config.d.ts +15 -0
  55. package/dist/core/local-config.js +63 -4
  56. package/dist/core/schema/apps.d.ts +40 -0
  57. package/dist/core/schema/apps.js +27 -0
  58. package/dist/core/schema/config.d.ts +66 -1
  59. package/dist/core/schema/config.js +18 -6
  60. package/dist/core/schema/profile.d.ts +5 -0
  61. package/dist/core/schema/profile.js +4 -2
  62. package/dist/core/schema/sandbox.d.ts +28 -0
  63. package/dist/core/schema/sandbox.js +28 -0
  64. package/dist/core/schema/toolchains.d.ts +3 -0
  65. package/dist/core/schema/toolchains.js +6 -1
  66. package/dist/core/types.d.ts +6 -0
  67. package/dist/core/util.d.ts +2 -0
  68. package/dist/core/util.js +2 -0
  69. package/dist/core/workspace.js +22 -5
  70. package/dist/guest/apps/index.d.ts +10 -0
  71. package/dist/guest/apps/index.js +206 -0
  72. package/dist/guest/apps/resolvers.d.ts +8 -0
  73. package/dist/guest/apps/resolvers.js +90 -0
  74. package/dist/guest/audit-parse.d.ts +7 -0
  75. package/dist/guest/audit-parse.js +24 -0
  76. package/dist/guest/audit.d.ts +4 -0
  77. package/dist/guest/audit.js +47 -0
  78. package/dist/guest/daemon/relays.d.ts +1 -0
  79. package/dist/guest/daemon/relays.js +18 -0
  80. package/dist/guest/daemon.js +4 -0
  81. package/dist/guest/env.d.ts +1 -0
  82. package/dist/guest/env.js +3 -1
  83. package/dist/guest/helpers.d.ts +1 -1
  84. package/dist/guest/main.js +15 -2
  85. package/dist/guest/netproxy.d.ts +1 -0
  86. package/dist/guest/netproxy.js +100 -0
  87. package/dist/guest/ptyhost.js +14 -4
  88. package/dist/guest/setup/claude.d.ts +0 -4
  89. package/dist/guest/setup/claude.js +48 -2
  90. package/dist/guest/setup/network.d.ts +9 -0
  91. package/dist/guest/setup/network.js +97 -4
  92. package/dist/guest/setup/user.js +4 -0
  93. package/dist/guest/toolchains/native/mesa.js +13 -3
  94. package/dist/guest/toolchains/plan.d.ts +3 -1
  95. package/dist/guest/toolchains/plan.js +53 -9
  96. package/dist/guest/toolchains/python.js +26 -2
  97. package/dist/guest/toolchains/vscode-profile.d.ts +30 -0
  98. package/dist/guest/toolchains/vscode-profile.js +69 -0
  99. package/dist/guest/toolchains/vscode.d.ts +17 -0
  100. package/dist/guest/toolchains/vscode.js +87 -1
  101. package/dist/host/attach.js +2 -1
  102. package/dist/host/audit/collect.d.ts +34 -0
  103. package/dist/host/audit/collect.js +126 -0
  104. package/dist/host/audit/log.d.ts +46 -0
  105. package/dist/host/audit/log.js +143 -0
  106. package/dist/host/generate.d.ts +6 -0
  107. package/dist/host/generate.js +10 -0
  108. package/dist/host/launch/mappings.js +5 -1
  109. package/dist/host/launch/stage.js +1 -1
  110. package/dist/host/motw.d.ts +17 -0
  111. package/dist/host/motw.js +74 -0
  112. package/dist/host/platform.js +3 -0
  113. package/dist/host/port-forward.d.ts +8 -0
  114. package/dist/host/port-forward.js +37 -0
  115. package/dist/host/repo-guard.d.ts +10 -3
  116. package/dist/host/repo-guard.js +142 -14
  117. package/dist/host/stops.d.ts +67 -0
  118. package/dist/host/stops.js +126 -0
  119. package/dist/host/terminal.d.ts +8 -0
  120. package/dist/host/terminal.js +45 -1
  121. package/dist/host/workspace-view/index.d.ts +20 -0
  122. package/dist/host/workspace-view/index.js +53 -0
  123. package/dist/host/workspace-view/rules.d.ts +26 -0
  124. package/dist/host/workspace-view/rules.js +93 -0
  125. package/dist/host/workspace-view/view.d.ts +50 -0
  126. package/dist/host/workspace-view/view.js +281 -0
  127. package/dist/runtime/daemon.js +1319 -552
  128. package/dist/runtime/db.js +168 -13
  129. package/dist/runtime/main.js +2898 -608
  130. package/dist/runtime/netproxy.js +25936 -0
  131. package/dist/runtime/view.js +272 -23
  132. package/dist/selftest/audit.test.d.ts +2 -0
  133. package/dist/selftest/audit.test.js +57 -0
  134. package/dist/selftest/channel.test.js +7 -0
  135. package/dist/selftest/cli.test.js +47 -17
  136. package/dist/selftest/fixtures.d.ts +45 -0
  137. package/dist/selftest/fixtures.js +3 -1
  138. package/dist/selftest/generate.test.js +7 -2
  139. package/dist/selftest/index.js +5 -2
  140. package/dist/selftest/live-network.test.d.ts +2 -0
  141. package/dist/selftest/live-network.test.js +63 -0
  142. package/dist/selftest/local-config.test.js +16 -3
  143. package/dist/selftest/network.test.js +12 -0
  144. package/dist/selftest/projects.test.js +1 -1
  145. package/dist/selftest/repo-guard.test.js +59 -1
  146. package/dist/selftest/sync.test.js +50 -10
  147. package/dist/selftest/toolchains.test.js +30 -0
  148. package/dist/selftest/workspace-view.test.d.ts +2 -0
  149. package/dist/selftest/workspace-view.test.js +89 -0
  150. package/dist/shared/apps.d.ts +49 -0
  151. package/dist/shared/apps.js +114 -0
  152. package/dist/shared/netpolicy.d.ts +27 -0
  153. package/dist/shared/netpolicy.js +51 -0
  154. package/dist/shared/pty-client.d.ts +9 -4
  155. package/dist/shared/pty-client.js +139 -17
  156. package/dist/shared/rundir.d.ts +2 -0
  157. package/dist/shared/rundir.js +2 -0
  158. package/dist/shared/schema.d.ts +10 -0
  159. package/dist/shared/schema.js +2 -0
  160. package/dist/shared/system.d.ts +1 -0
  161. package/dist/shared/system.js +1 -0
  162. package/dist/sync/claude-mem.js +3 -2
  163. package/dist/sync/history.d.ts +6 -0
  164. package/dist/sync/history.js +26 -11
  165. package/dist/sync/watcher.js +78 -6
  166. package/package.json +2 -1
  167. package/sandbox/cargoshim.cs +56 -0
  168. package/sandbox/cpucap.cs +33 -0
  169. package/sandbox/cpucap.ps1 +4 -35
  170. package/sandbox/logon.cmd +6 -0
  171. package/sandbox/memlog.ps1 +34 -0
  172. package/sandbox/pagefile.cs +31 -0
  173. package/sandbox/pagefile.ps1 +21 -0
  174. package/sandbox/ptyhost.cs +105 -7
  175. package/schemas/config.schema.json +303 -2
  176. package/schemas/sandbox-config.schema.json +126 -12
  177. package/dist/cli/menu-multi.js +0 -82
  178. package/dist/cli/menu.d.ts +0 -14
  179. package/dist/cli/menu.js +0 -290
  180. package/dist/cli/settings/edit.d.ts +0 -13
  181. package/dist/cli/settings/edit.js +0 -19
  182. package/dist/cli/settings/index.d.ts +0 -8
  183. package/dist/cli/settings/index.js +0 -131
  184. package/dist/cli/settings/prompts.d.ts +0 -7
  185. package/dist/cli/settings/prompts.js +0 -48
  186. package/dist/cli/settings/schema.d.ts +0 -33
  187. package/dist/cli/settings/schema.js +0 -29
package/dist/guest/env.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Shared state for the guest scripts (main, daemon, view): fixed paths, the session description, logging, status.
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { spawnSync } from 'node:child_process';
5
6
  import { moveIntoPlace, setChecksumRequired, spawnAsync, stagingFolder } from '../shared/download.js';
@@ -44,8 +45,9 @@ export const setSandboxStatus = (phase, message) => {
44
45
  warn('Could not update sandbox status: ' + errMsg(e));
45
46
  }
46
47
  };
48
+ const ownAddresses = () => Object.values(os.networkInterfaces()).flat().filter((i) => Boolean(i) && i.family === 'IPv4' && !i.internal).map((i) => i.address).slice(0, 8);
47
49
  const writeJsonAtomicChecked = (phase, message) => {
48
- writeJsonAtomic(run.sandbox, { launchId: session.launchId, phase, message, updatedAt: now(), workspaceHost: session.workspaceHost, guestWorkspace: workspace, headless: session.headless });
50
+ writeJsonAtomic(run.sandbox, { launchId: session.launchId, phase, message, updatedAt: now(), workspaceHost: session.workspaceHost, guestWorkspace: workspace, headless: session.headless, addresses: ownAddresses() });
49
51
  };
50
52
  /** Runs a program, streaming output, and throws with a readable message when it fails. */
51
53
  export const exec = (file, args, options = {}) => {
@@ -1,3 +1,3 @@
1
- export type Helper = 'ptyhost' | 'gitshim' | 'vswhere' | 'openssl';
1
+ export type Helper = 'ptyhost' | 'gitshim' | 'vswhere' | 'openssl' | 'cargoshim';
2
2
  /** %TEMP%\agent-sandbox\<name>-<hash>.exe, compiled from this launch's sandbox\<name>.cs the first time it is needed. */
3
3
  export declare const ensureCompiled: (name: Helper) => string;
@@ -9,11 +9,14 @@ import { provisionHarness, resolveLaunch } from './setup/agents.js';
9
9
  import { restoreClaudeMem, syncClaudeProfile } from './setup/claude.js';
10
10
  import { agentEnv, persistEnvironment } from './setup/environment.js';
11
11
  import { runHooks } from './setup/hooks.js';
12
- import { applyNetworkPolicy } from './setup/network.js';
12
+ import { applyAllowOnly, applyNetworkPolicy, proxyEnv } from './setup/network.js';
13
13
  import { writeManagedSettings } from './setup/policy.js';
14
14
  import { applyPersonalization, setupGitConfig } from './setup/user.js';
15
15
  import { ensurePtyHost } from './ptyhost.js';
16
16
  import { pruneToolLeftovers } from './toolchains/leftovers.js';
17
+ import { wanted } from './toolchains/common.js';
18
+ import { configureVscode } from './toolchains/vscode.js';
19
+ import { enableProcessAudit } from './audit.js';
17
20
  import { now, readSession, writeJsonAtomic } from '../shared/rundir.js';
18
21
  import { viewSession } from './view.js';
19
22
  import { saveBootEnv } from './boot-env.js';
@@ -62,6 +65,7 @@ const main = async () => {
62
65
  await soft('Building the session host', () => { ptyHost = ensurePtyHost(); });
63
66
  let lan = session.networking ? 'open' : 'offline';
64
67
  await soft('Private-network blocking', async () => { lan = await applyNetworkPolicy(ptyHost); });
68
+ await soft('Audit', enableProcessAudit);
65
69
  step(2, 'Pre-provision hooks and desktop');
66
70
  runHooks('pre-provision', process.env);
67
71
  await soft('Personalisation', applyPersonalization);
@@ -69,10 +73,19 @@ const main = async () => {
69
73
  step(3, 'Developer tools (the first start downloads them once; later starts are quick)');
70
74
  const basePath = process.env.PATH ?? '';
71
75
  const tools = await setupToolchains(session.toolchains, session.versions, {
72
- gitEnabled: session.provisioning.git.enabled, gitAccess, hostGit: session.hostTools.git, refresh: session.provisioning.refresh, databases: session.databases ?? [],
76
+ gitEnabled: session.provisioning.git.enabled, gitAccess, hostGit: session.hostTools.git, refresh: session.provisioning.refresh, databases: session.databases ?? [], apps: session.apps,
73
77
  });
74
78
  if (!dryRun)
75
79
  tools.prepend.push(writeDbCommand());
80
+ // network.allowOnly / network.proxy: everything started from now on uses the proxy.
81
+ Object.assign(tools.vars, proxyEnv());
82
+ await soft('Network: allow only some hosts', async () => { if (await applyAllowOnly())
83
+ tools.summary.push('network: only network.allowOnly hosts (refused: logs\\netproxy.log)'); });
84
+ const vscode = session.toolchains.vscode;
85
+ if (vscode.enabled && vscode.setup !== false) {
86
+ const languages = ['python', 'java', 'go', 'rust', 'cpp', 'msvc', 'dotnet', 'ruby', 'php', 'deno', 'pwsh'].filter((t) => wanted(session.toolchains[t]?.enabled ?? false, t));
87
+ await soft('VS Code setup', () => configureVscode(tools, { toolchains: ['node', ...languages], databases: (session.databases ?? []).map((d) => d.database), config: vscode, workspace }));
88
+ }
76
89
  const env = agentEnv(tools);
77
90
  persistEnvironment(env, Object.keys(tools.vars));
78
91
  saveBootEnv({ basePath, toolVars: Object.keys(tools.vars), steps: tools.steps, summary: tools.summary, lan });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,100 @@
1
+ // The sandbox's own proxy for network.allowOnly (runs detached for the sandbox's life, as sbx-netproxy.exe so the
2
+ // firewall can let only it out). HTTPS goes through CONNECT, plain HTTP as proxy requests; a host that is not allowed
3
+ // gets 403 and a line in logs\netproxy.log. With network.proxy set it hands everything on to that proxy.
4
+ // Usage: sbx-netproxy.exe netproxy.js
5
+ import fs from 'node:fs';
6
+ import http from 'node:http';
7
+ import net from 'node:net';
8
+ import path from 'node:path';
9
+ import { expandAllowList, hostAllowed, PROXY_PORT } from '../shared/netpolicy.js';
10
+ import { logsDir, session } from './env.js';
11
+ const allowed = expandAllowList(session.network.allowOnly ?? []);
12
+ const upstream = session.network.proxy ? new URL(session.network.proxy) : null;
13
+ const logFile = path.join(logsDir, 'netproxy.log');
14
+ let logged = 0;
15
+ /** Refusals, capped: a program retrying in a loop must not fill the disk. */
16
+ const refuse = (host) => {
17
+ if (logged++ > 5000)
18
+ return;
19
+ try {
20
+ fs.appendFileSync(logFile, new Date().toISOString() + ' blocked ' + host + '\n');
21
+ }
22
+ catch { /* ignore */ }
23
+ };
24
+ /** Opens a TCP tunnel to host:port, directly or through the upstream proxy (CONNECT). */
25
+ const tunnel = (host, port, onReady, onError) => {
26
+ if (!upstream) {
27
+ const socket = net.connect(port, host, () => onReady(socket));
28
+ socket.once('error', onError);
29
+ return;
30
+ }
31
+ const socket = net.connect(Number(upstream.port || 80), upstream.hostname, () => socket.write('CONNECT ' + host + ':' + port + ' HTTP/1.1\r\nHost: ' + host + ':' + port + '\r\n\r\n'));
32
+ socket.once('error', onError);
33
+ let head = '';
34
+ const onData = (chunk) => {
35
+ head += chunk.toString('latin1');
36
+ const end = head.indexOf('\r\n\r\n');
37
+ if (end < 0) {
38
+ if (head.length > 16384) {
39
+ socket.destroy();
40
+ onError();
41
+ }
42
+ return;
43
+ }
44
+ socket.off('data', onData);
45
+ if (!/^HTTP\/1\.\d 200/.test(head)) {
46
+ socket.destroy();
47
+ onError();
48
+ return;
49
+ }
50
+ const rest = Buffer.from(head.slice(end + 4), 'latin1');
51
+ if (rest.length)
52
+ socket.unshift(rest);
53
+ onReady(socket);
54
+ };
55
+ socket.on('data', onData);
56
+ };
57
+ const server = http.createServer((req, res) => {
58
+ // Plain HTTP: the request line has the full URL.
59
+ let target;
60
+ try {
61
+ target = new URL(req.url ?? '');
62
+ }
63
+ catch {
64
+ res.writeHead(400).end('bad proxy request');
65
+ return;
66
+ }
67
+ if (!hostAllowed(target.hostname, allowed)) {
68
+ refuse(target.hostname);
69
+ res.writeHead(403, { 'Content-Type': 'text/plain' }).end('Blocked by the sandbox (network.allowOnly): ' + target.hostname + '\n');
70
+ return;
71
+ }
72
+ const options = upstream
73
+ ? { host: upstream.hostname, port: Number(upstream.port || 80), path: target.href, method: req.method, headers: req.headers }
74
+ : { host: target.hostname, port: Number(target.port || 80), path: target.pathname + target.search, method: req.method, headers: req.headers };
75
+ const out = http.request(options, (up) => { res.writeHead(up.statusCode ?? 502, up.headers); up.pipe(res); });
76
+ out.on('error', () => { if (!res.headersSent)
77
+ res.writeHead(502).end('upstream error');
78
+ else
79
+ res.destroy(); });
80
+ req.pipe(out);
81
+ });
82
+ server.on('connect', (req, client, head) => {
83
+ const [host, portText] = (req.url ?? '').split(/:(?=\d+$)/);
84
+ const port = Number(portText || 443);
85
+ client.on('error', () => client.destroy());
86
+ if (!host || !(port > 0 && port < 65536) || !hostAllowed(host, allowed)) {
87
+ refuse(host ?? '?');
88
+ client.end('HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\n\r\nBlocked by the sandbox (network.allowOnly): ' + host + '\n');
89
+ return;
90
+ }
91
+ tunnel(host, port, (remote) => {
92
+ client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
93
+ if (head.length)
94
+ remote.write(head);
95
+ remote.pipe(client);
96
+ client.pipe(remote);
97
+ remote.on('error', () => client.destroy());
98
+ }, () => client.end('HTTP/1.1 502 Bad Gateway\r\n\r\n'));
99
+ });
100
+ server.listen(PROXY_PORT, '127.0.0.1');
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { ensureCompiled } from './helpers.js';
6
6
  import { portOpen } from './ports.js';
7
- import { logsDir } from './env.js';
7
+ import { logsDir, session, toolsDir } from './env.js';
8
8
  export const ensurePtyHost = () => ensureCompiled('ptyhost');
9
9
  /** Resolves when ptyhost listens; rejects early if it exits first (e.g. the agent could not be started). */
10
10
  export const waitForPtyHost = async (child, port, timeoutMs) => {
@@ -30,7 +30,17 @@ export const ptyHostLogTail = (id, lines = 12) => {
30
30
  return '';
31
31
  }
32
32
  };
33
+ /** conpty.dll (+ OpenConsole.exe) the host downloaded, when provisioning.conpty is on; ptyhost falls back to Windows' own. */
34
+ const conptyDll = () => {
35
+ const dll = path.join(toolsDir, 'conpty', 'conpty.dll');
36
+ return session.provisioning.conpty?.enabled !== false && fs.existsSync(dll) && fs.existsSync(path.join(toolsDir, 'conpty', 'OpenConsole.exe')) ? dll : null;
37
+ };
33
38
  /** Starts ptyhost detached, so closing a terminal tab never kills the agent; only the agent exiting ends it. */
34
- export const startPtyHost = (o) => spawn(o.exe, [String(o.port), o.token, String(o.cols), String(o.rows), ptyHostLog(o.id), o.command, o.restartCommand], {
35
- cwd: o.cwd, env: { ...o.env, PTYHOST_BIND: o.remote ? 'any' : 'loopback' }, detached: true, stdio: 'ignore', windowsHide: true,
36
- });
39
+ export const startPtyHost = (o) => {
40
+ const env = { ...o.env, PTYHOST_BIND: o.remote ? 'any' : 'loopback' };
41
+ delete env.PTYHOST_CONPTY;
42
+ const dll = conptyDll();
43
+ if (dll)
44
+ env.PTYHOST_CONPTY = dll;
45
+ return spawn(o.exe, [String(o.port), o.token, String(o.cols), String(o.rows), ptyHostLog(o.id), o.command, o.restartCommand], { cwd: o.cwd, env, detached: true, stdio: 'ignore', windowsHide: true });
46
+ };
@@ -14,8 +14,4 @@ export declare const claudeMemPaths: {
14
14
  local: string;
15
15
  };
16
16
  export declare const restoreClaudeMem: () => void;
17
- /**
18
- * Copies the live claude-mem folder back to persistent storage (staging folder + swap, never a half copy). Called from
19
- * the daemon tick: when no database changed since the last save it does nothing (no copy of the whole folder).
20
- */
21
17
  export declare const saveClaudeMem: () => void;
@@ -89,6 +89,17 @@ export const restoreClaudeMem = () => {
89
89
  const previous = claudeMemPaths.store + '.previous';
90
90
  if (!exists(claudeMemPaths.store) && exists(previous))
91
91
  fs.renameSync(previous, claudeMemPaths.store);
92
+ // A store that is gone or damaged: the newest backup that is a real database.
93
+ const backups = claudeMemPaths.store + '.backups';
94
+ if (!looksLikeSqlite(path.join(claudeMemPaths.store, 'claude-mem.db')) || !exists(claudeMemPaths.store)) {
95
+ const good = (exists(backups) ? fs.readdirSync(backups).sort().reverse() : []).find((b) => exists(path.join(backups, b, 'claude-mem.db')) && looksLikeSqlite(path.join(backups, b, 'claude-mem.db')));
96
+ if (good) {
97
+ if (exists(claudeMemPaths.store))
98
+ fs.renameSync(claudeMemPaths.store, claudeMemPaths.store + '.damaged-' + Date.now());
99
+ fs.cpSync(path.join(backups, good), claudeMemPaths.store, { recursive: true });
100
+ warn('claude-mem: the stored database was missing or damaged; restored the backup from ' + new Date(Number(good)).toISOString() + '.');
101
+ }
102
+ }
92
103
  if (!exists(claudeMemPaths.store))
93
104
  return;
94
105
  robocopy(claudeMemPaths.store, claudeMemPaths.local);
@@ -117,6 +128,36 @@ let lastSaved = null;
117
128
  * Copies the live claude-mem folder back to persistent storage (staging folder + swap, never a half copy). Called from
118
129
  * the daemon tick: when no database changed since the last save it does nothing (no copy of the whole folder).
119
130
  */
131
+ const SQLITE_HEADER = 'SQLite format 3\0';
132
+ /** A database file that starts like SQLite and is not empty (a missing one counts as fine: nothing to protect yet). */
133
+ const looksLikeSqlite = (file) => {
134
+ if (!exists(file))
135
+ return true;
136
+ const fd = fs.openSync(file, 'r');
137
+ try {
138
+ const head = Buffer.alloc(16);
139
+ return fs.readSync(fd, head, 0, 16, 0) === 16 && head.toString('latin1') === SQLITE_HEADER;
140
+ }
141
+ finally {
142
+ fs.closeSync(fd);
143
+ }
144
+ };
145
+ const MAX_BACKUPS = 3;
146
+ const BACKUP_EVERY_MS = 6 * 3600 * 1000;
147
+ /** Moves the replaced copy into `dir` as a dated backup (at most one per 6 hours, the newest 3 kept); else deletes it. */
148
+ const keepBackup = (previous, dir) => {
149
+ if (!exists(previous))
150
+ return;
151
+ const backups = exists(dir) ? fs.readdirSync(dir).filter((n) => /^\d{13}$/.test(n)).sort() : [];
152
+ const newest = Number(backups[backups.length - 1] ?? 0);
153
+ if (Date.now() - newest < BACKUP_EVERY_MS) {
154
+ fs.rmSync(previous, { recursive: true, force: true });
155
+ return;
156
+ }
157
+ fs.renameSync(previous, path.join(mkdirp(dir), String(Date.now())));
158
+ for (const old of [...backups, String(Date.now())].slice(0, -MAX_BACKUPS))
159
+ fs.rmSync(path.join(dir, old), { recursive: true, force: true });
160
+ };
120
161
  export const saveClaudeMem = () => {
121
162
  if (!claudeMemEnabled() || !exists(claudeMemPaths.local))
122
163
  return;
@@ -124,12 +165,17 @@ export const saveClaudeMem = () => {
124
165
  const fingerprint = databaseFingerprint(local);
125
166
  if (fingerprint === lastSaved && exists(store))
126
167
  return;
168
+ // A damaged copy (zero-filled after a crash, cut short) must never replace the store: that once destroyed the only copy.
169
+ if (!looksLikeSqlite(path.join(local, 'claude-mem.db'))) {
170
+ warn('claude-mem: the local database does not look valid, so it was not saved over the stored copy (' + local + ').');
171
+ return;
172
+ }
127
173
  const incoming = store + '.incoming';
128
174
  const previous = store + '.previous';
129
175
  fs.rmSync(incoming, { recursive: true, force: true });
130
176
  // The database, its -wal and -shm files are copied together, so the copy is consistent with itself.
131
177
  fs.cpSync(local, incoming, { recursive: true, filter: (src) => !/\.(pid|lock)$/i.test(src) });
132
- // Swap without a window where no copy exists: old aside, new in, then drop the old one.
178
+ // Swap without a window where no copy exists: old aside, new in, then keep the old one as a dated backup.
133
179
  fs.rmSync(previous, { recursive: true, force: true });
134
180
  if (exists(store))
135
181
  fs.renameSync(store, previous);
@@ -141,6 +187,6 @@ export const saveClaudeMem = () => {
141
187
  fs.renameSync(previous, store);
142
188
  throw e;
143
189
  }
144
- fs.rmSync(previous, { recursive: true, force: true });
190
+ keepBackup(previous, store + '.backups');
145
191
  lastSaved = fingerprint;
146
192
  };
@@ -6,5 +6,14 @@ export type LanState = 'blocked' | 'open' | 'offline';
6
6
  * all, the block stays in place: being offline is never a reason to open the LAN.
7
7
  */
8
8
  export declare const applyNetworkPolicy: (ptyHostExe: string | null) => Promise<LanState>;
9
+ /** HTTP_PROXY & co. for everything in the sandbox: its own proxy (allowOnly) or yours (network.proxy). */
10
+ export declare const proxyEnv: () => Record<string, string>;
11
+ /**
12
+ * network.allowOnly, after the tools are installed: the sandbox's proxy (a copy of Node named sbx-netproxy.exe, so the
13
+ * firewall can tell it apart) is the only program allowed out, besides DNS and DHCP; Windows and browsers use it as
14
+ * the system proxy, everything else through HTTP_PROXY. A guardrail for agents that behave: an admin in the sandbox
15
+ * could undo it, which is why sandbox.networking false is the only hard isolation.
16
+ */
17
+ export declare const applyAllowOnly: () => Promise<boolean>;
9
18
  /** Guest IPv4 addresses the host can use to reach session ports. */
10
19
  export declare const guestAddresses: () => string[];
@@ -1,10 +1,13 @@
1
1
  // Guest firewall: block the host's LAN (keeping DNS/DHCP to the gateway) and let the host reach session ports.
2
2
  import dns from 'node:dns';
3
+ import fs from 'node:fs';
3
4
  import os from 'node:os';
4
- import { spawnSync } from 'node:child_process';
5
- import { buildFirewallCommands, ipv4DnsServers, parseDefaultGateways, parseDnsServers } from '../../shared/netrules.js';
5
+ import path from 'node:path';
6
+ import { spawn, spawnSync } from 'node:child_process';
7
+ import { expandAllowList, PROXY_PORT, relayPort, withBlockedHosts } from '../../shared/netpolicy.js';
8
+ import { buildFirewallCommands, ipv4DnsServers, isIPv4, parseDefaultGateways, parseDnsServers } from '../../shared/netrules.js';
6
9
  import { SYSTEM } from '../../shared/system.js';
7
- import { dryRun, isWin, log, session, warn } from '../env.js';
10
+ import { dryRun, errMsg, isWin, launchDir, log, session, toolsDir, warn } from '../env.js';
8
11
  const ATTACH_RULE = 'AgentSandbox-Attach';
9
12
  const netsh = (args) => {
10
13
  const r = spawnSync(SYSTEM.netsh(), args, { encoding: 'utf8', windowsHide: true });
@@ -68,7 +71,21 @@ export const applyNetworkPolicy = async (ptyHostExe) => {
68
71
  const block = session.network.blockPrivateRanges;
69
72
  const exempt = block ? discoverExemptIps() : [];
70
73
  const lanRules = block ? buildFirewallCommands(exempt) : [];
71
- const commands = [...lanRules, ...(ptyHostExe && session.attach.enabled ? attachRule(ptyHostExe) : [])];
74
+ const commands = [...lanRules, ...(ptyHostExe && session.attach.enabled ? attachRule(ptyHostExe) : []), ...forwardRule()];
75
+ if (isWin && !dryRun) {
76
+ try {
77
+ applyBlockedHosts();
78
+ }
79
+ catch (e) {
80
+ warn('network.blockHosts could not be applied: ' + errMsg(e));
81
+ }
82
+ try {
83
+ applyDns();
84
+ }
85
+ catch (e) {
86
+ warn('network.dns could not be applied: ' + errMsg(e));
87
+ }
88
+ }
72
89
  if (!block)
73
90
  log('Private-range blocking disabled by config.');
74
91
  if (block)
@@ -95,5 +112,81 @@ export const applyNetworkPolicy = async (ptyHostExe) => {
95
112
  log('LAN blocked in the guest firewall (10/8, 172.16/12, 192.168/16, 100.64/10, link-local, multicast, IPv6 local, SMB); DNS/DHCP to the gateway allowed.');
96
113
  return 'blocked';
97
114
  };
115
+ // ---- the rest of the network settings -----------------------------------------------------------------------------
116
+ const FORWARD_RULE = 'AgentSandbox-Forward';
117
+ const ALLOW_RULES = ['AgentSandbox-Proxy-Out', 'AgentSandbox-DNS-Out-UDP', 'AgentSandbox-DNS-Out-TCP', 'AgentSandbox-DHCP-Out'];
118
+ const HOSTS_FILE = path.join(process.env.WINDIR ?? 'C:\\Windows', 'System32', 'drivers', 'etc', 'hosts');
119
+ /** network.blockHosts: those names go nowhere (the sandbox's hosts file; the registry and files are new every boot). */
120
+ const applyBlockedHosts = () => {
121
+ const hosts = session.network.blockHosts ?? [];
122
+ if (!hosts.length)
123
+ return;
124
+ const text = fs.existsSync(HOSTS_FILE) ? fs.readFileSync(HOSTS_FILE, 'utf8') : '';
125
+ fs.writeFileSync(HOSTS_FILE, withBlockedHosts(text, hosts));
126
+ log('Blocked hosts (network.blockHosts): ' + hosts.join(', '));
127
+ };
128
+ /** network.dns: these servers on every connected adapter. */
129
+ const applyDns = () => {
130
+ const servers = (session.network.dns ?? []).filter(isIPv4);
131
+ if (!servers.length)
132
+ return;
133
+ const r = spawnSync(SYSTEM.powershell(), ['-NoLogo', '-NoProfile', '-Command', "Get-NetAdapter | Where-Object Status -eq 'Up' | Set-DnsClientServerAddress -ServerAddresses " + servers.map((s) => "'" + s + "'").join(',')], { encoding: 'utf8', windowsHide: true });
134
+ if (r.status !== 0)
135
+ throw new Error('could not set the DNS servers: ' + (r.stderr ?? '').trim().slice(0, 300));
136
+ log('DNS servers (network.dns): ' + servers.join(', '));
137
+ };
138
+ /** network.forwardPorts: the relays (in the daemon) take connections from the PC only, i.e. the sandbox's own subnet. */
139
+ const forwardRule = () => {
140
+ const forwards = session.network.forwardPorts ?? [];
141
+ return forwards.length ? [
142
+ ['advfirewall', 'firewall', 'delete', 'rule', 'name=' + FORWARD_RULE],
143
+ ['advfirewall', 'firewall', 'add', 'rule', 'name=' + FORWARD_RULE, 'dir=in', 'action=allow', 'enable=yes', 'profile=any', 'protocol=tcp', 'remoteip=LocalSubnet', 'localport=' + forwards.map((_f, i) => relayPort(i)).join(',')],
144
+ ] : [];
145
+ };
146
+ /** HTTP_PROXY & co. for everything in the sandbox: its own proxy (allowOnly) or yours (network.proxy). */
147
+ export const proxyEnv = () => {
148
+ const own = (session.network.allowOnly ?? []).length > 0;
149
+ const url = own ? 'http://127.0.0.1:' + PROXY_PORT : session.network.proxy ?? '';
150
+ if (!url)
151
+ return {};
152
+ const noProxy = 'localhost,127.0.0.1,::1';
153
+ return { HTTP_PROXY: url, HTTPS_PROXY: url, http_proxy: url, https_proxy: url, NO_PROXY: noProxy, no_proxy: noProxy };
154
+ };
155
+ /**
156
+ * network.allowOnly, after the tools are installed: the sandbox's proxy (a copy of Node named sbx-netproxy.exe, so the
157
+ * firewall can tell it apart) is the only program allowed out, besides DNS and DHCP; Windows and browsers use it as
158
+ * the system proxy, everything else through HTTP_PROXY. A guardrail for agents that behave: an admin in the sandbox
159
+ * could undo it, which is why sandbox.networking false is the only hard isolation.
160
+ */
161
+ export const applyAllowOnly = async () => {
162
+ const hosts = session.network.allowOnly ?? [];
163
+ if (!session.networking || !hosts.length)
164
+ return false;
165
+ if (!isWin || dryRun) {
166
+ log('(dry run) would allow only: ' + hosts.join(', '));
167
+ return false;
168
+ }
169
+ const exe = path.join(toolsDir, 'netproxy', 'sbx-netproxy.exe');
170
+ fs.mkdirSync(path.dirname(exe), { recursive: true });
171
+ if (!fs.existsSync(exe) || fs.statSync(exe).size !== fs.statSync(process.execPath).size)
172
+ fs.copyFileSync(process.execPath, exe);
173
+ spawn(exe, [path.join(launchDir, 'runtime', 'netproxy.js')], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
174
+ const add = (name, extra) => ['advfirewall', 'firewall', 'add', 'rule', 'name=' + name, 'dir=out', 'action=allow', 'enable=yes', 'profile=any', ...extra];
175
+ run([
176
+ ...ALLOW_RULES.map((n) => ['advfirewall', 'firewall', 'delete', 'rule', 'name=' + n]),
177
+ add('AgentSandbox-Proxy-Out', ['program=' + exe]),
178
+ add('AgentSandbox-DNS-Out-UDP', ['protocol=udp', 'remoteport=53']),
179
+ add('AgentSandbox-DNS-Out-TCP', ['protocol=tcp', 'remoteport=53']),
180
+ add('AgentSandbox-DHCP-Out', ['protocol=udp', 'remoteport=67', 'localport=68']),
181
+ ['advfirewall', 'set', 'allprofiles', 'firewallpolicy', 'blockinbound,blockoutbound'],
182
+ ['winhttp', 'set', 'proxy', '127.0.0.1:' + PROXY_PORT, '<local>'],
183
+ ]);
184
+ const internet = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings';
185
+ for (const [name, type, value] of [['ProxyEnable', 'REG_DWORD', '1'], ['ProxyServer', 'REG_SZ', '127.0.0.1:' + PROXY_PORT], ['ProxyOverride', 'REG_SZ', '<local>']]) {
186
+ spawnSync(SYSTEM.reg(), ['add', internet, '/v', name, '/t', type, '/d', value, '/f'], { windowsHide: true, stdio: 'ignore' });
187
+ }
188
+ log('Only these hosts can be reached now (network.allowOnly): ' + expandAllowList(hosts).join(', ') + '. Refused ones are listed in logs\\netproxy.log.');
189
+ return true;
190
+ };
98
191
  /** Guest IPv4 addresses the host can use to reach session ports. */
99
192
  export const guestAddresses = () => Object.values(os.networkInterfaces()).flat().filter((i) => Boolean(i) && i.family === 'IPv4' && !i.internal).map((i) => i.address);
@@ -44,4 +44,8 @@ export const setupGitConfig = (env) => {
44
44
  spawnSync(git, ['config', '--global', '--add', 'safe.directory', '*'], { env: rest, windowsHide: true, stdio: 'ignore' });
45
45
  // Windows long paths are on in the sandbox, but git skips files over 260 characters unless told.
46
46
  spawnSync(git, ['config', '--global', 'core.longpaths', 'true'], { env: rest, windowsHide: true, stdio: 'ignore' });
47
+ // Git for Windows' system config says autocrlf=true: checkouts made in the sandbox (worktrees, clones) got CRLF and
48
+ // tools that want LF (rustfmt, shellcheck, prettier) failed on every file. "input" keeps the line endings the repo
49
+ // has, and a checkout from your PC (CRLF in the folder) still shows as unchanged.
50
+ spawnSync(git, ['config', '--global', 'core.autocrlf', 'input'], { env: rest, windowsHide: true, stdio: 'ignore' });
47
51
  };
@@ -2,7 +2,7 @@
2
2
  import path from 'node:path';
3
3
  import { installArchive } from '../../../shared/download.js';
4
4
  import { SYSTEM } from '../../../shared/system.js';
5
- import { dryRun, execAsync, exists, log } from '../../env.js';
5
+ import { dryRun, execAsync, execOutput, exists, log } from '../../env.js';
6
6
  import { arch, installFromGithub, tool } from '../common.js';
7
7
  /**
8
8
  * Mesa 3D for Windows (llvmpipe): OpenGL 4.5 on the CPU when the sandbox has no GPU. Windows only offers OpenGL 1.1 in
@@ -40,7 +40,17 @@ const setupLavapipe = async (env, mesaDir) => {
40
40
  // The zip has x86 and x64 folders: only the 64-bit loader (and vulkaninfo) is extracted.
41
41
  await installArchive(VULKAN_RUNTIME, dir, { locate: 'vulkan-1.dll', fileName: 'vulkan-runtime.zip', members: ['*/x64/*'] });
42
42
  }
43
- await execAsync(SYSTEM.reg(), ['add', 'HKLM\\SOFTWARE\\Khronos\\Vulkan\\Drivers', '/v', icd, '/t', 'REG_DWORD', '/d', '0', '/f'], { stdio: 'ignore' });
43
+ const register = (file) => execAsync(SYSTEM.reg(), ['add', 'HKLM\\SOFTWARE\\Khronos\\Vulkan\\Drivers', '/v', file, '/t', 'REG_DWORD', '/d', '0', '/f'], { stdio: 'ignore' });
44
+ await register(icd);
45
+ // sandbox.vGpu: the sandbox sees this PC's GPU through Direct3D 12, and Dozen runs Vulkan on it. Programs prefer it
46
+ // over lavapipe (a GPU before a CPU device); lavapipe stays for the ones that need it.
47
+ const dozen = path.join(mesaDir, arch === 'arm64' ? 'dzn_icd.aarch64.json' : 'dzn_icd.x86_64.json');
48
+ const gpu = hasGpu();
49
+ if (gpu && exists(dozen))
50
+ await register(dozen);
44
51
  env.prepend.push(dir);
45
- env.summary.push('vulkan: lavapipe (software Vulkan 1.4 on the CPU), vulkaninfo on PATH; WGPU_BACKEND=vulkan for wgpu');
52
+ env.summary.push('vulkan: ' + (gpu && exists(dozen) ? 'Dozen (Vulkan on the GPU via Direct3D 12) and ' : '') + 'lavapipe (software Vulkan 1.4 on the CPU), vulkaninfo on PATH; WGPU_BACKEND=vulkan for wgpu');
46
53
  };
54
+ /** A real display adapter (sandbox.vGpu on), not only the remote display or basic render driver. */
55
+ const hasGpu = () => execOutput(SYSTEM.powershell(), ['-NoProfile', '-NonInteractive', '-Command', '(Get-CimInstance Win32_VideoController).Name'], { timeout: 30000 })
56
+ .split(/\r?\n/).map((n) => n.trim()).some((n) => n && !/^Microsoft (Remote Display|Basic (Render|Display))/i.test(n));
@@ -1,11 +1,13 @@
1
- import type { DatabasePlan, GitAccess, Toolchains, ToolVersions } from '../../core/types.js';
1
+ import type { Config, DatabasePlan, GitAccess, Toolchains, ToolVersions } from '../../core/types.js';
2
2
  import { type ToolEnv } from './common.js';
3
+ /** apps: the boot only (apps are machine-wide; a project that joins later shares them). */
3
4
  export interface ToolchainOptions {
4
5
  gitEnabled: boolean;
5
6
  gitAccess: GitAccess;
6
7
  hostGit: boolean;
7
8
  refresh: boolean;
8
9
  databases: DatabasePlan[];
10
+ apps?: Config['apps'];
9
11
  }
10
12
  /**
11
13
  * shared: the step's effect is machine-wide and the same for every project (an installer, a registry entry, a
@@ -1,8 +1,11 @@
1
1
  // Which toolchain and database steps this sandbox runs, in the order their PATH entries are listed, and what each
2
2
  // step depends on. The scheduler (index.ts) runs them.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
3
5
  import path from 'node:path';
4
- import { dataDir, execAsync, exists, localDir } from '../env.js';
5
- import { projectWorkspace } from '../project-context.js';
6
+ import { appsNeedMesa, setupApps } from '../apps/index.js';
7
+ import { dataDir, dryRun, execAsync, exists, localDir, mkdirp } from '../env.js';
8
+ import { ensureCompiled } from '../helpers.js';
6
9
  import { arch, installOnce, tool, wanted } from './common.js';
7
10
  import { DATABASE_NAMES, setupDatabase } from './databases/index.js';
8
11
  import { setupGit } from './git.js';
@@ -30,16 +33,46 @@ const setupPythonTools = async (env, tools) => {
30
33
  throw new Error('toolchains.python.tools needs uv (toolchains.uv.enabled)');
31
34
  await installOnce(env, 'uv tool', tools, (pkg) => execAsync(uv, ['tool', 'install', pkg], { env: { ...process.env, ...uvVars() } }));
32
35
  };
33
- /** Where cargo builds: shared with the PC's target\, a separate folder next to it, one on the sandbox's disk, or a path. */
36
+ /**
37
+ * Where cargo builds: shared with the PC's target\, one on the sandbox's disk, or a path. "separate" sets no variable:
38
+ * the cargo shim (sandbox/cargoshim.cs) gives every checkout its own target\sandbox, so git worktrees of several agents
39
+ * never overwrite each other's builds.
40
+ */
34
41
  const cargoTarget = (targetDir) => {
35
- if (targetDir === 'shared')
42
+ if (targetDir === 'shared' || targetDir === 'separate')
36
43
  return null;
37
- if (targetDir === 'separate')
38
- return path.join(projectWorkspace(), 'target', 'sandbox');
39
44
  if (targetDir === 'local')
40
45
  return path.join(localDir, 'cargo-target');
41
46
  return targetDir;
42
47
  };
48
+ /** "separate": tools\shims\cargo\cargo.exe in front of the real cargo, which it runs with the checkout's target dir. */
49
+ const cargoShim = (env, rust) => {
50
+ const real = path.join(tool('rust', 'cargo', 'bin'), 'cargo.exe');
51
+ if (rust.targetDir !== 'separate' || !exists(real))
52
+ return;
53
+ const dir = mkdirp(tool('shims', 'cargo'));
54
+ fs.copyFileSync(ensureCompiled('cargoshim'), path.join(dir, 'cargo.exe'));
55
+ env.prepend.unshift(dir);
56
+ env.vars.AGENT_REAL_CARGO = real;
57
+ };
58
+ /**
59
+ * Cargo defaults that fit Windows, written only when there is no $CARGO_HOME\config.toml yet: fewer parallel jobs
60
+ * (parallel rustc ran out of paging file) and, for the GNU host, only line tables as debug info (full debug info made
61
+ * test executables bigger than the 2 GB Windows allows).
62
+ */
63
+ const cargoDefaults = (host) => {
64
+ const file = tool('rust', 'cargo', 'config.toml');
65
+ if (exists(file) || !exists(path.dirname(file)))
66
+ return;
67
+ // Several agents often build at once: up to 4 jobs each (8 each crashed Windows inside the sandbox on 13 Oct 2026).
68
+ const jobs = Math.max(2, Math.min(4, Math.floor(os.cpus().length / 4)));
69
+ const gnu = host.endsWith('-gnu');
70
+ fs.writeFileSync(file, [
71
+ '# Written by windows-development-sandbox once; edit freely, it is never overwritten.',
72
+ '[build]', 'jobs = ' + jobs, '',
73
+ ...(gnu ? ['[profile.dev]', 'debug = "line-tables-only"', '', '[profile.test]', 'debug = "line-tables-only"', ''] : []),
74
+ ].join('\n'));
75
+ };
43
76
  /**
44
77
  * Where cargo builds and where sccache keeps its cache. Cargo fingerprints hold absolute paths and the host toolchain,
45
78
  * so a target\\ shared with builds on the PC rebuilds every time the side changes: "separate" (the default) keeps a
@@ -57,6 +90,7 @@ const rustHost = (config) => {
57
90
  return cpu + (wanted(config.msvc.enabled, 'msvc') ? '-pc-windows-msvc' : '-pc-windows-gnu');
58
91
  };
59
92
  export const plan = (config, versions, o) => {
93
+ const apps = Boolean(o.apps && (o.apps.install.length || Object.keys(o.apps.custom).length));
60
94
  const java = wanted(config.java.enabled, 'java');
61
95
  const python = wanted(config.python.enabled, 'python');
62
96
  return [
@@ -71,14 +105,22 @@ export const plan = (config, versions, o) => {
71
105
  // Rust links with MSVC (msvc host) or MinGW's gcc (GNU host): whichever it uses goes first.
72
106
  {
73
107
  name: 'Rust', on: wanted(config.rust.enabled, 'rust'), after: ['C/C++', 'MSVC'],
74
- run: async (env) => { await setupRust(env, { ...config.rust, host: rustHost(config) }, o.refresh); Object.assign(env.vars, rustCaches(config.rust)); },
108
+ run: async (env) => {
109
+ await setupRust(env, { ...config.rust, host: rustHost(config) }, o.refresh);
110
+ Object.assign(env.vars, rustCaches(config.rust));
111
+ if (!dryRun) {
112
+ cargoDefaults(rustHost(config));
113
+ cargoShim(env, config.rust);
114
+ }
115
+ },
75
116
  },
76
117
  { name: '.NET', on: wanted(config.dotnet.enabled, 'dotnet'), run: (env) => setupDotnet(env, config.dotnet, o.refresh) },
77
118
  { name: 'Go', on: wanted(config.go.enabled, 'go'), run: (env) => setupGo(env, config.go) },
78
119
  { name: 'Java', on: java, run: (env) => setupJava(env, config.java) },
79
120
  { name: 'Ruby', on: wanted(config.ruby.enabled, 'ruby'), run: (env) => setupRuby(env, config.ruby) },
80
121
  // PHP installs the Visual C++ runtime itself: one installer at a time.
81
- { name: 'Visual C++ runtime', on: config.vcRuntime.enabled, shared: true, run: async (env) => { await setupVcRuntime(env); } },
122
+ // Many apps (bat, delta, Neovim, most GUIs, your own programs) are built against it too.
123
+ { name: 'Visual C++ runtime', on: config.vcRuntime.enabled || apps, shared: true, run: async (env) => { await setupVcRuntime(env); } },
82
124
  { name: 'PHP', on: wanted(config.php.enabled, 'php'), after: ['Visual C++ runtime'], run: (env) => setupPhp(env, config.php) },
83
125
  { name: 'Deno', on: wanted(config.deno.enabled, 'deno'), run: (env) => setupDeno(env) },
84
126
  { name: 'Bun', on: config.bun.enabled, run: (env) => setupBun(env) },
@@ -86,8 +128,10 @@ export const plan = (config, versions, o) => {
86
128
  { name: 'PowerShell 7', on: wanted(config.pwsh.enabled, 'pwsh'), run: (env) => setupPwsh(env, o.refresh) },
87
129
  // The openssl tool comes from Git's install: after Git.
88
130
  { name: 'CLI tools', on: Object.values(config.cli).some((v) => v !== false), after: ['Git'], run: (env) => setupCli(env, config.cli, o.gitAccess, o.refresh) },
89
- { name: 'Mesa 3D', on: config.mesa.enabled, shared: true, run: (env) => setupMesa(env, config.mesa) },
131
+ { name: 'Mesa 3D', on: config.mesa.enabled || appsNeedMesa(o.apps), shared: true, run: (env) => setupMesa(env, config.mesa) },
90
132
  { name: 'VS Code', on: config.vscode.enabled, shared: true, run: (env) => setupVscode(env, o.refresh) },
133
+ // Desktop apps and extra tools (apps.install / apps.custom); games and 3D apps copy Mesa's OpenGL next to them.
134
+ { name: 'Apps', on: apps, shared: true, after: ['Mesa 3D', 'Visual C++ runtime'], run: (env) => setupApps(env, o.apps, { gitAccess: o.gitAccess, refresh: o.refresh }) },
91
135
  { name: 'Python tools', on: python && config.python.tools.length > 0, after: ['Python', 'uv'], run: (env) => setupPythonTools(env, config.python.tools) },
92
136
  // DynamoDB Local runs on Java: the Java step installs the JDK first instead of racing it.
93
137
  // One server per database in the sandbox: a project that joins later shares the one the boot started.