windows-development-sandbox 1.0.5 → 1.1.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 (368) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -6
  3. package/config.example.json +17 -0
  4. package/dist/api.js +2 -1
  5. package/dist/cli/args.d.ts +20 -0
  6. package/dist/cli/args.js +35 -3
  7. package/dist/cli/colors.d.ts +24 -0
  8. package/dist/cli/colors.js +42 -0
  9. package/dist/cli/commands/agent-commands.d.ts +6 -0
  10. package/dist/cli/commands/agent-commands.js +61 -0
  11. package/dist/cli/commands/context.d.ts +2 -0
  12. package/dist/cli/commands/context.js +3 -0
  13. package/dist/cli/commands/data/clean.d.ts +15 -0
  14. package/dist/cli/commands/data/clean.js +101 -0
  15. package/dist/cli/commands/data/collect.d.ts +11 -0
  16. package/dist/cli/commands/data/collect.js +54 -0
  17. package/dist/cli/commands/data/index.d.ts +4 -0
  18. package/dist/cli/commands/data/index.js +45 -0
  19. package/dist/cli/commands/data/remove.d.ts +2 -0
  20. package/dist/cli/commands/data/remove.js +30 -0
  21. package/dist/cli/commands/index.d.ts +5 -3
  22. package/dist/cli/commands/index.js +5 -3
  23. package/dist/cli/commands/launch.d.ts +26 -2
  24. package/dist/cli/commands/launch.js +220 -39
  25. package/dist/cli/commands/maintenance.js +33 -4
  26. package/dist/cli/commands/project.d.ts +29 -0
  27. package/dist/cli/commands/project.js +63 -0
  28. package/dist/cli/commands/projects.d.ts +14 -0
  29. package/dist/cli/commands/projects.js +146 -0
  30. package/dist/cli/commands/repair/checks.d.ts +2 -0
  31. package/dist/cli/commands/{repair.js → repair/checks.js} +19 -181
  32. package/dist/cli/commands/{repair.d.ts → repair/finders.d.ts} +0 -29
  33. package/dist/cli/commands/repair/finders.js +108 -0
  34. package/dist/cli/commands/repair/index.d.ts +12 -0
  35. package/dist/cli/commands/repair/index.js +61 -0
  36. package/dist/cli/commands/repair/types.d.ts +26 -0
  37. package/dist/cli/commands/repair/types.js +3 -0
  38. package/dist/cli/commands/restart.d.ts +6 -0
  39. package/dist/cli/commands/restart.js +85 -0
  40. package/dist/cli/commands/sessions.d.ts +2 -1
  41. package/dist/cli/commands/sessions.js +189 -45
  42. package/dist/cli/commands/setup/checks.d.ts +13 -0
  43. package/dist/cli/commands/setup/checks.js +71 -0
  44. package/dist/cli/commands/{setup.d.ts → setup/facts.d.ts} +1 -15
  45. package/dist/cli/commands/setup/facts.js +60 -0
  46. package/dist/cli/commands/setup/index.d.ts +2 -0
  47. package/dist/cli/commands/setup/index.js +43 -0
  48. package/dist/cli/commands/user-config.d.ts +8 -0
  49. package/dist/cli/commands/user-config.js +41 -0
  50. package/dist/cli/history.d.ts +11 -0
  51. package/dist/cli/history.js +21 -0
  52. package/dist/cli/index.d.ts +7 -1
  53. package/dist/cli/index.js +38 -13
  54. package/dist/cli/manifest.d.ts +8 -0
  55. package/dist/cli/manifest.js +35 -15
  56. package/dist/cli/menu-multi.d.ts +12 -0
  57. package/dist/cli/menu-multi.js +82 -0
  58. package/dist/cli/menu.d.ts +5 -13
  59. package/dist/cli/menu.js +149 -46
  60. package/dist/cli/progress.d.ts +9 -0
  61. package/dist/cli/progress.js +49 -0
  62. package/dist/cli/settings/edit.d.ts +13 -0
  63. package/dist/cli/settings/edit.js +19 -0
  64. package/dist/cli/settings/index.d.ts +8 -0
  65. package/dist/cli/settings/index.js +131 -0
  66. package/dist/cli/settings/prompts.d.ts +7 -0
  67. package/dist/cli/settings/prompts.js +48 -0
  68. package/dist/cli/settings/schema.d.ts +33 -0
  69. package/dist/cli/settings/schema.js +29 -0
  70. package/dist/cli/validate.d.ts +17 -0
  71. package/dist/cli/validate.js +89 -0
  72. package/dist/core/config.js +4 -1
  73. package/dist/core/disk.d.ts +16 -0
  74. package/dist/core/disk.js +55 -0
  75. package/dist/core/local-config.d.ts +8 -0
  76. package/dist/core/local-config.js +18 -5
  77. package/dist/core/project-policy.d.ts +15 -0
  78. package/dist/core/project-policy.js +35 -0
  79. package/dist/core/prompt.d.ts +4 -0
  80. package/dist/core/prompt.js +15 -0
  81. package/dist/core/safe-paths.d.ts +4 -0
  82. package/dist/core/safe-paths.js +37 -0
  83. package/dist/core/schema/common.d.ts +17 -0
  84. package/dist/core/schema/common.js +15 -0
  85. package/dist/core/schema/config.d.ts +594 -0
  86. package/dist/core/schema/config.js +63 -0
  87. package/dist/core/schema/databases.d.ts +33 -0
  88. package/dist/core/schema/databases.js +35 -0
  89. package/dist/core/schema/harness.d.ts +12 -0
  90. package/dist/core/schema/harness.js +17 -0
  91. package/dist/core/schema/json-schema.d.ts +5 -0
  92. package/dist/core/schema/json-schema.js +25 -0
  93. package/dist/core/schema/profile.d.ts +27 -0
  94. package/dist/core/schema/profile.js +29 -0
  95. package/dist/core/schema/projects.d.ts +9 -0
  96. package/dist/core/schema/projects.js +11 -0
  97. package/dist/core/schema/sandbox.d.ts +50 -0
  98. package/dist/core/schema/sandbox.js +36 -0
  99. package/dist/core/schema/toolchains.d.ts +93 -0
  100. package/dist/core/schema/toolchains.js +83 -0
  101. package/dist/core/schema.d.ts +6 -604
  102. package/dist/core/schema.js +8 -264
  103. package/dist/core/types.d.ts +29 -1
  104. package/dist/core/util.d.ts +7 -0
  105. package/dist/core/util.js +12 -3
  106. package/dist/core/workspace.d.ts +0 -5
  107. package/dist/core/workspace.js +12 -46
  108. package/dist/guest/boot-env.d.ts +4 -0
  109. package/dist/guest/boot-env.js +25 -0
  110. package/dist/guest/cmdline.d.ts +12 -0
  111. package/dist/guest/cmdline.js +23 -0
  112. package/dist/guest/daemon/exec.d.ts +5 -0
  113. package/dist/guest/daemon/exec.js +32 -0
  114. package/dist/guest/daemon/project.d.ts +14 -0
  115. package/dist/guest/daemon/project.js +132 -0
  116. package/dist/guest/daemon/session.d.ts +13 -0
  117. package/dist/guest/daemon/session.js +139 -0
  118. package/dist/guest/daemon/shutdown.d.ts +1 -0
  119. package/dist/guest/daemon/shutdown.js +21 -0
  120. package/dist/guest/daemon/state.d.ts +17 -0
  121. package/dist/guest/daemon/state.js +31 -0
  122. package/dist/guest/daemon.js +8 -141
  123. package/dist/guest/db.js +18 -3
  124. package/dist/guest/env.d.ts +2 -0
  125. package/dist/guest/env.js +4 -2
  126. package/dist/guest/guide.d.ts +6 -2
  127. package/dist/guest/guide.js +5 -4
  128. package/dist/guest/helpers.d.ts +3 -0
  129. package/dist/guest/helpers.js +26 -0
  130. package/dist/guest/main.js +17 -7
  131. package/dist/guest/ports.d.ts +6 -0
  132. package/dist/guest/ports.js +22 -0
  133. package/dist/guest/project-context.d.ts +7 -0
  134. package/dist/guest/project-context.js +10 -0
  135. package/dist/guest/project-env.d.ts +20 -0
  136. package/dist/guest/project-env.js +20 -0
  137. package/dist/guest/project-status.d.ts +5 -0
  138. package/dist/guest/project-status.js +17 -0
  139. package/dist/guest/ptyhost.d.ts +0 -18
  140. package/dist/guest/ptyhost.js +5 -63
  141. package/dist/guest/setup/agents.d.ts +5 -2
  142. package/dist/guest/setup/agents.js +22 -8
  143. package/dist/guest/setup/claude.js +11 -3
  144. package/dist/guest/setup/environment.d.ts +13 -3
  145. package/dist/guest/setup/environment.js +14 -10
  146. package/dist/guest/setup/hooks.d.ts +9 -1
  147. package/dist/guest/setup/hooks.js +28 -14
  148. package/dist/guest/toolchains/catalogue.d.ts +4 -0
  149. package/dist/guest/toolchains/catalogue.js +2 -2
  150. package/dist/guest/toolchains/cli.js +25 -17
  151. package/dist/guest/toolchains/common.d.ts +18 -1
  152. package/dist/guest/toolchains/common.js +34 -11
  153. package/dist/guest/toolchains/databases/dynamodb.d.ts +8 -0
  154. package/dist/guest/toolchains/databases/dynamodb.js +52 -0
  155. package/dist/guest/toolchains/{databases.d.ts → databases/index.d.ts} +2 -3
  156. package/dist/guest/toolchains/databases/index.js +62 -0
  157. package/dist/guest/toolchains/databases/mongodb.d.ts +3 -0
  158. package/dist/guest/toolchains/databases/mongodb.js +31 -0
  159. package/dist/guest/toolchains/databases/mysql.d.ts +3 -0
  160. package/dist/guest/toolchains/databases/mysql.js +38 -0
  161. package/dist/guest/toolchains/databases/postgres.d.ts +5 -0
  162. package/dist/guest/toolchains/databases/postgres.js +38 -0
  163. package/dist/guest/toolchains/databases/redis.d.ts +3 -0
  164. package/dist/guest/toolchains/databases/redis.js +16 -0
  165. package/dist/guest/toolchains/databases/server.d.ts +14 -0
  166. package/dist/guest/toolchains/databases/server.js +52 -0
  167. package/dist/guest/toolchains/git.js +25 -16
  168. package/dist/guest/toolchains/go.js +1 -1
  169. package/dist/guest/toolchains/index.d.ts +13 -9
  170. package/dist/guest/toolchains/index.js +40 -82
  171. package/dist/guest/toolchains/java.js +5 -5
  172. package/dist/guest/toolchains/leftovers.d.ts +2 -0
  173. package/dist/guest/toolchains/leftovers.js +26 -0
  174. package/dist/guest/toolchains/managers.js +12 -10
  175. package/dist/guest/toolchains/{binstall.js → native/binstall.js} +6 -9
  176. package/dist/guest/toolchains/native/cpp.d.ts +4 -0
  177. package/dist/guest/toolchains/native/cpp.js +38 -0
  178. package/dist/guest/toolchains/native/dotnet.d.ts +4 -0
  179. package/dist/guest/toolchains/native/dotnet.js +103 -0
  180. package/dist/guest/toolchains/native/mesa.d.ts +7 -0
  181. package/dist/guest/toolchains/native/mesa.js +46 -0
  182. package/dist/guest/toolchains/native/msvc/environment.d.ts +16 -0
  183. package/dist/guest/toolchains/native/msvc/environment.js +57 -0
  184. package/dist/guest/toolchains/native/msvc/index.d.ts +6 -0
  185. package/dist/guest/toolchains/native/msvc/index.js +48 -0
  186. package/dist/guest/toolchains/native/msvc/packages.d.ts +6 -0
  187. package/dist/guest/toolchains/native/msvc/packages.js +156 -0
  188. package/dist/guest/toolchains/native/msvc/tree.d.ts +15 -0
  189. package/dist/guest/toolchains/native/msvc/tree.js +21 -0
  190. package/dist/guest/toolchains/native/rust.d.ts +3 -0
  191. package/dist/guest/toolchains/native/rust.js +45 -0
  192. package/dist/guest/toolchains/node.js +26 -6
  193. package/dist/guest/toolchains/plan.d.ts +22 -0
  194. package/dist/guest/toolchains/plan.js +96 -0
  195. package/dist/guest/toolchains/pwsh.js +1 -1
  196. package/dist/guest/toolchains/python.js +17 -10
  197. package/dist/guest/toolchains/runtimes.d.ts +2 -0
  198. package/dist/guest/toolchains/runtimes.js +6 -8
  199. package/dist/guest/toolchains/scripting/php.d.ts +3 -0
  200. package/dist/guest/toolchains/scripting/php.js +71 -0
  201. package/dist/guest/toolchains/scripting/ruby.d.ts +3 -0
  202. package/dist/guest/toolchains/scripting/ruby.js +47 -0
  203. package/dist/guest/toolchains/scripting/vcruntime.d.ts +7 -0
  204. package/dist/guest/toolchains/scripting/vcruntime.js +45 -0
  205. package/dist/guest/view.js +21 -1
  206. package/dist/host/accounts.js +14 -5
  207. package/dist/host/agent-commands.d.ts +25 -0
  208. package/dist/host/agent-commands.js +145 -0
  209. package/dist/host/attach.d.ts +17 -0
  210. package/dist/host/attach.js +69 -0
  211. package/dist/host/channel.d.ts +23 -22
  212. package/dist/host/channel.js +66 -84
  213. package/dist/host/claude-profile.js +7 -3
  214. package/dist/host/cleanup.js +2 -11
  215. package/dist/host/database-plan.d.ts +12 -0
  216. package/dist/host/database-plan.js +52 -0
  217. package/dist/host/generate.d.ts +4 -12
  218. package/dist/host/generate.js +48 -135
  219. package/dist/host/join.d.ts +28 -0
  220. package/dist/host/join.js +76 -0
  221. package/dist/host/launch/mappings.d.ts +3 -0
  222. package/dist/host/launch/mappings.js +50 -0
  223. package/dist/host/launch/projects.d.ts +15 -0
  224. package/dist/host/launch/projects.js +56 -0
  225. package/dist/host/launch/stage.d.ts +11 -0
  226. package/dist/host/launch/stage.js +66 -0
  227. package/dist/host/launch/wsb.d.ts +10 -0
  228. package/dist/host/launch/wsb.js +34 -0
  229. package/dist/host/pc-scan.d.ts +6 -0
  230. package/dist/host/pc-scan.js +37 -0
  231. package/dist/host/platform.d.ts +2 -0
  232. package/dist/host/platform.js +13 -1
  233. package/dist/host/project-scan.d.ts +1 -17
  234. package/dist/host/project-scan.js +2 -86
  235. package/dist/host/projects.d.ts +100 -0
  236. package/dist/host/projects.js +147 -0
  237. package/dist/host/repo-guard.d.ts +15 -2
  238. package/dist/host/repo-guard.js +103 -12
  239. package/dist/host/terminal.js +3 -0
  240. package/dist/host/test-guard.d.ts +6 -0
  241. package/dist/host/test-guard.js +15 -0
  242. package/dist/host/window.js +7 -3
  243. package/dist/host/wsb.d.ts +37 -0
  244. package/dist/host/wsb.js +101 -0
  245. package/dist/runtime/daemon.js +10997 -2794
  246. package/dist/runtime/db.js +6428 -464
  247. package/dist/runtime/main.js +7359 -1135
  248. package/dist/runtime/view.js +5922 -47
  249. package/dist/selftest/accounts.test.d.ts +2 -0
  250. package/dist/selftest/accounts.test.js +44 -0
  251. package/dist/selftest/agent-commands.test.d.ts +2 -0
  252. package/dist/selftest/agent-commands.test.js +47 -0
  253. package/dist/selftest/channel.test.d.ts +2 -0
  254. package/dist/selftest/channel.test.js +37 -0
  255. package/dist/selftest/cli.test.d.ts +2 -2
  256. package/dist/selftest/cli.test.js +115 -27
  257. package/dist/selftest/cmdline.test.d.ts +2 -0
  258. package/dist/selftest/cmdline.test.js +45 -0
  259. package/dist/selftest/config.test.d.ts +2 -3
  260. package/dist/selftest/config.test.js +36 -72
  261. package/dist/selftest/data.test.d.ts +2 -2
  262. package/dist/selftest/data.test.js +77 -70
  263. package/dist/selftest/detect.test.d.ts +2 -2
  264. package/dist/selftest/detect.test.js +54 -38
  265. package/dist/selftest/fixtures.d.ts +44 -4
  266. package/dist/selftest/fixtures.js +40 -23
  267. package/dist/selftest/generate.test.d.ts +2 -0
  268. package/dist/selftest/generate.test.js +63 -0
  269. package/dist/selftest/git-access.test.d.ts +2 -0
  270. package/dist/selftest/git-access.test.js +26 -0
  271. package/dist/selftest/guest-boot.test.d.ts +2 -0
  272. package/dist/selftest/guest-boot.test.js +76 -0
  273. package/dist/selftest/harness.d.ts +44 -7
  274. package/dist/selftest/harness.js +62 -21
  275. package/dist/selftest/host-sim.test.d.ts +2 -0
  276. package/dist/selftest/host-sim.test.js +93 -0
  277. package/dist/selftest/hostile-input.test.d.ts +2 -0
  278. package/dist/selftest/hostile-input.test.js +54 -0
  279. package/dist/selftest/index.d.ts +8 -1
  280. package/dist/selftest/index.js +134 -35
  281. package/dist/selftest/links.test.d.ts +2 -0
  282. package/dist/selftest/links.test.js +50 -0
  283. package/dist/selftest/live-daemon.test.d.ts +2 -0
  284. package/dist/selftest/live-daemon.test.js +91 -0
  285. package/dist/selftest/live-git.test.d.ts +2 -0
  286. package/dist/selftest/live-git.test.js +60 -0
  287. package/dist/selftest/live-projects.test.d.ts +2 -0
  288. package/dist/selftest/live-projects.test.js +127 -0
  289. package/dist/selftest/live.d.ts +65 -0
  290. package/dist/selftest/live.js +109 -0
  291. package/dist/selftest/local-config.test.d.ts +2 -0
  292. package/dist/selftest/local-config.test.js +107 -0
  293. package/dist/selftest/mirror.test.d.ts +2 -3
  294. package/dist/selftest/mirror.test.js +92 -65
  295. package/dist/selftest/network.test.d.ts +2 -0
  296. package/dist/selftest/network.test.js +28 -0
  297. package/dist/selftest/personalization.test.d.ts +2 -0
  298. package/dist/selftest/personalization.test.js +53 -0
  299. package/dist/selftest/projects.test.d.ts +2 -0
  300. package/dist/selftest/projects.test.js +254 -0
  301. package/dist/selftest/repair.test.d.ts +2 -0
  302. package/dist/selftest/repair.test.js +42 -0
  303. package/dist/selftest/repo-guard.test.d.ts +2 -0
  304. package/dist/selftest/repo-guard.test.js +63 -0
  305. package/dist/selftest/secrets.test.d.ts +2 -0
  306. package/dist/selftest/secrets.test.js +71 -0
  307. package/dist/selftest/setup.test.d.ts +2 -2
  308. package/dist/selftest/setup.test.js +45 -22
  309. package/dist/selftest/suite.d.ts +13 -0
  310. package/dist/selftest/suite.js +1 -0
  311. package/dist/selftest/sync.test.d.ts +2 -3
  312. package/dist/selftest/sync.test.js +90 -78
  313. package/dist/selftest/test-guard.test.d.ts +2 -0
  314. package/dist/selftest/test-guard.test.js +49 -0
  315. package/dist/selftest/toolchains.test.d.ts +2 -0
  316. package/dist/selftest/toolchains.test.js +103 -0
  317. package/dist/selftest/workspace.test.d.ts +2 -0
  318. package/dist/selftest/workspace.test.js +53 -0
  319. package/dist/shared/download.d.ts +3 -1
  320. package/dist/shared/download.js +19 -4
  321. package/dist/shared/paths.d.ts +5 -0
  322. package/dist/shared/paths.js +6 -0
  323. package/dist/shared/projects.d.ts +53 -0
  324. package/dist/shared/projects.js +67 -0
  325. package/dist/shared/rundir.d.ts +10 -1
  326. package/dist/shared/rundir.js +12 -4
  327. package/dist/shared/safe-remove.js +11 -0
  328. package/dist/shared/schema.d.ts +443 -0
  329. package/dist/shared/schema.js +41 -2
  330. package/dist/shared/test-guard.d.ts +4 -0
  331. package/dist/shared/test-guard.js +37 -0
  332. package/dist/sync/claude-mem.js +3 -1
  333. package/dist/sync/history.d.ts +23 -12
  334. package/dist/sync/history.js +52 -20
  335. package/dist/sync/watcher.d.ts +8 -0
  336. package/dist/sync/watcher.js +23 -4
  337. package/package.json +6 -6
  338. package/sandbox/bootstrap.ps1 +258 -258
  339. package/sandbox/cpucap.ps1 +51 -0
  340. package/sandbox/logon.cmd +18 -16
  341. package/sandbox/openssl.cs +42 -0
  342. package/sandbox/ptyhost.cs +15 -4
  343. package/sandbox/trust.cmd +35 -35
  344. package/schemas/config.schema.json +100 -1
  345. package/schemas/sandbox-config.schema.json +17 -1
  346. package/dist/cli/commands/data.d.ts +0 -46
  347. package/dist/cli/commands/data.js +0 -249
  348. package/dist/cli/commands/setup.js +0 -152
  349. package/dist/cli/settings.d.ts +0 -11
  350. package/dist/cli/settings.js +0 -156
  351. package/dist/guest/toolchains/databases.js +0 -226
  352. package/dist/guest/toolchains/msvc.d.ts +0 -10
  353. package/dist/guest/toolchains/msvc.js +0 -216
  354. package/dist/guest/toolchains/native.d.ts +0 -12
  355. package/dist/guest/toolchains/native.js +0 -220
  356. package/dist/guest/toolchains/scripting.d.ts +0 -10
  357. package/dist/guest/toolchains/scripting.js +0 -137
  358. package/dist/selftest/guest.test.d.ts +0 -3
  359. package/dist/selftest/guest.test.js +0 -61
  360. package/dist/selftest/helpers.test.d.ts +0 -2
  361. package/dist/selftest/helpers.test.js +0 -125
  362. package/dist/selftest/host.test.d.ts +0 -2
  363. package/dist/selftest/host.test.js +0 -71
  364. package/dist/selftest/live.test.d.ts +0 -7
  365. package/dist/selftest/live.test.js +0 -127
  366. package/dist/selftest/security.test.d.ts +0 -3
  367. package/dist/selftest/security.test.js +0 -143
  368. /package/dist/guest/toolchains/{binstall.d.ts → native/binstall.d.ts} +0 -0
@@ -1,73 +1,80 @@
1
- // `sandbox data` target resolution and `sandbox repair` detection (half-installed toolchains, leftovers, links).
1
+ // `sandbox data` and `sandbox clean`: what a clean target may name, how sizes and removal treat links, and which old
2
+ // launch folders, logs and downloads are pruned.
2
3
  import fs from 'node:fs';
3
4
  import path from 'node:path';
4
- import { humanBytes, removeTree, resolveCleanTargets, sizeOf } from '../cli/commands/data.js';
5
- import { deadPidFiles, findHalfInstalled, findLinks, findStagingLeftovers, globalPackages, resolvedVersionsValid } from '../cli/commands/repair.js';
6
- import { ensureDir, errMsg } from '../core/util.js';
7
- export const dataTests = (t) => {
8
- const { check, write } = t;
9
- t.section('data: clean targets');
10
- const storage = path.join(t.temp, 'data-store');
11
- const dataPath = path.join(storage, 'data', 'default');
12
- const resolve = (...inputs) => resolveCleanTargets(inputs, dataPath, storage).map((r) => path.relative(storage, r.path));
13
- const rejected = (...inputs) => { try {
14
- resolveCleanTargets(inputs, dataPath, storage);
15
- return false;
16
- }
17
- catch (e) {
18
- return errMsg(e).length > 0;
19
- } };
20
- const d = (...parts) => path.join('data', 'default', ...parts);
21
- check(resolve('tools/go').join() === d('tools', 'go') && resolve('tools\\go').join() === d('tools', 'go') && resolve('Tools/agents/claude').join() === d('tools', 'agents', 'claude'), 'clean: tools/<name> and tools/agents/<name> resolve inside the data folder (either slash)');
22
- check(resolve('databases/postgres-18').join() === d('databases', 'postgres-18') && resolve('logs').join() === d('logs') && resolve('launches').join() === 'launches' && resolve('state/claude').join() === d('state', 'claude'), 'clean: databases/<name>, logs, launches, state/<agent>');
23
- check(resolve('tools', 'tools/go', 'tools/go').join() === d('tools') && resolve('all', 'logs').join() === [d(), 'launches'].join(), 'clean: nested and repeated targets collapse; all = the data folder + launches');
24
- check(Boolean(resolveCleanTargets(['state/claude'], dataPath, storage)[0].warning?.includes('login')) && Boolean(resolveCleanTargets(['all'], dataPath, storage)[0].warning), 'clean: state and all carry a warning');
25
- const bad = ['', '..', 'tools/..', 'tools/../../x', '../data', 'C:\\Windows', 'C:tools', '/etc', '\\\\server\\share', 'tools/*', 'tools/go:stream', 'tools/a/b', 'tools/agents/a/b', 'logs/guest.log', 'accounts', 'sync', 'windows', 'tools/.', 'tools/go.'];
26
- const accepted = bad.filter((b) => !rejected(b));
27
- check(accepted.length === 0 && rejected(), 'clean: rejects .., absolute paths, drive letters, UNC, wildcards, streams, too deep, unknown areas, nothing (accepted: ' + JSON.stringify(accepted) + ')');
28
- t.section('data: sizes and removal never follow links');
29
- const sized = path.join(t.temp, 'sized');
30
- write(path.join(sized, 'a.bin'), 'x'.repeat(1000));
31
- write(path.join(sized, 'sub', 'b.bin'), 'y'.repeat(24));
32
- const outside = path.join(t.temp, 'outside');
33
- write(path.join(outside, 'keep.txt'), 'z'.repeat(5000));
34
- let linked = false;
35
- try {
36
- fs.symlinkSync(outside, path.join(sized, 'sub', 'escape'), 'junction');
37
- linked = true;
38
- }
39
- catch { /* no junctions here */ }
40
- check(sizeOf(sized) === 1024 && humanBytes(1536) === '1.5 KB' && humanBytes(0) === '0 B' && humanBytes(3 * 1024 ** 3) === '3.0 GB', 'data: sizes count real files only' + (linked ? ' (a junction counts 0)' : ''));
41
- if (linked)
42
- check(findLinks(sized).length === 1, 'repair: finds a junction planted in the data folder');
43
- removeTree(sized, t.temp);
44
- check(!fs.existsSync(sized) && fs.existsSync(path.join(outside, 'keep.txt')), 'data: removing a folder never deletes what a link inside points to');
45
- t.section('repair: detection');
46
- const tools = path.join(t.temp, 'repair', 'tools');
47
- write(path.join(tools, 'go', '1.23.2', 'bin', 'go.exe'), 'x');
48
- ensureDir(path.join(tools, 'go', '1.22.8', 'src'));
49
- write(path.join(tools, 'java', 'jdk-21', 'bin', 'java.exe'), 'x');
50
- ensureDir(path.join(tools, 'java', 'jdk-17', 'lib'));
51
- write(path.join(tools, 'python', 'python-3.12.7', 'python.exe'), 'x');
52
- write(path.join(tools, 'nvm', 'settings.txt'), 'x');
53
- write(path.join(tools, 'nvm', 'v22.11.0', 'node.exe'), 'x');
54
- ensureDir(path.join(tools, 'nvm', 'v20.18.0'));
55
- write(path.join(tools, 'git', 'bin', 'bash.exe'), 'x');
56
- ensureDir(path.join(tools, 'dotnet', 'sdk'));
57
- ensureDir(path.join(tools, 'go', '1.24.0.staging-ab12cd'));
58
- ensureDir(path.join(tools, 'node-staging'));
59
- ensureDir(path.join(tools, 'rust.staging-0f'));
60
- const half = findHalfInstalled(tools).map((h) => path.relative(tools, h.path)).sort();
61
- check(half.join() === ['dotnet', 'java\\jdk-17', 'go\\1.22.8', 'nvm', 'nvm\\v20.18.0'].map((p) => p.split('\\').join(path.sep)).sort().join(), 'repair: half-installed toolchains = folder without its program (got ' + half.join(', ') + ')');
62
- const staging = findStagingLeftovers(tools).map((p) => path.relative(tools, p)).sort();
63
- check(staging.join() === [path.join('go', '1.24.0.staging-ab12cd'), 'node-staging', 'rust.staging-0f'].sort().join(), 'repair: interrupted downloads in tools and one level down (got ' + staging.join(', ') + ')');
64
- check(resolvedVersionsValid('{"go":"1.23.2"}') && resolvedVersionsValid('\uFEFF{}') && !resolvedVersionsValid('{"go":') && !resolvedVersionsValid('[1]'), 'repair: resolved-versions.json must be a JSON object');
65
- check(globalPackages('{"dependencies":{"agent-sandbox":{"version":"1.0.0"},"npm":{}}}').join() === 'agent-sandbox,npm' && globalPackages('nope').length === 0 && globalPackages('{"dependencies":3}').length === 0, 'repair: reads npm ls -g --json');
66
- const run = path.join(t.temp, 'repair', 'run');
67
- write(path.join(run, 'watcher-default.pid'), '4242');
68
- write(path.join(run, 'watcher-other.pid'), String(process.pid));
69
- write(path.join(run, 'watcher-junk.pid'), 'not a pid');
70
- write(path.join(run, 'hidden-windows.json'), '[]');
71
- const dead = deadPidFiles(run, (pid) => pid === process.pid).map((f) => path.basename(f)).sort();
72
- check(dead.join() === 'watcher-default.pid,watcher-junk.pid', 'repair: stale watcher pid files (dead process or junk) are found; a live one is kept');
5
+ import { resolveCleanTargets } from '../cli/commands/data/clean.js';
6
+ import { removeTree } from '../cli/commands/data/remove.js';
7
+ import { findLinks } from '../cli/commands/repair/finders.js';
8
+ import { humanBytes, sizeOf } from '../core/disk.js';
9
+ import { pruneStorage } from '../host/cleanup.js';
10
+ export const dataSuite = {
11
+ name: 'data',
12
+ title: 'data clean targets, sizes and removal with links, storage cleanup',
13
+ run: async (t) => {
14
+ t.section('clean targets');
15
+ const storage = t.at('data-store');
16
+ const dataPath = path.join(storage, 'data', 'default');
17
+ const resolve = (...inputs) => resolveCleanTargets(inputs, dataPath, storage).map((r) => path.relative(storage, r.path));
18
+ const inData = (...parts) => path.join('data', 'default', ...parts);
19
+ t.equal(resolve('tools/go'), [inData('tools', 'go')], 'tools/<name> resolves inside the data folder');
20
+ t.equal(resolve('tools\\go'), [inData('tools', 'go')], 'either slash works');
21
+ t.equal(resolve('Tools/agents/claude'), [inData('tools', 'agents', 'claude')], 'tools/agents/<name> resolves, whatever the case');
22
+ t.equal(resolve('databases/postgres-18'), [inData('databases', 'postgres-18')], 'databases/<name> resolves');
23
+ t.equal(resolve('logs'), [inData('logs')], 'logs resolves');
24
+ t.equal(resolve('launches'), ['launches'], 'launches resolves to the storage folder\'s launches');
25
+ t.equal(resolve('state/claude'), [inData('state', 'claude')], 'state/<agent> resolves');
26
+ t.equal(resolve('tools', 'tools/go', 'tools/go'), [inData('tools')], 'nested and repeated targets collapse');
27
+ t.equal(resolve('all', 'logs'), [inData(), 'launches'], '`all` is the data folder plus launches');
28
+ t.check(Boolean(resolveCleanTargets(['state/claude'], dataPath, storage)[0].warning?.includes('login')), 'cleaning state warns about the login');
29
+ t.check(Boolean(resolveCleanTargets(['all'], dataPath, storage)[0].warning), 'cleaning everything carries a warning');
30
+ const badTargets = ['', '..', 'tools/..', 'tools/../../x', '../data', 'C:\\Windows', 'C:tools', '/etc', '\\\\server\\share', 'tools/*', 'tools/go:stream', 'tools/a/b', 'tools/agents/a/b', 'logs/guest.log', 'accounts', 'sync', 'windows', 'tools/.', 'tools/go.'];
31
+ const accepted = badTargets.filter((target) => {
32
+ try {
33
+ resolveCleanTargets([target], dataPath, storage);
34
+ return true;
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ });
40
+ t.equal(accepted, [], 'rejected: .., absolute paths, drive letters, UNC, wildcards, streams, too deep, unknown areas');
41
+ await t.throws(() => resolveCleanTargets([], dataPath, storage), '', 'rejected: no target at all (that is not "everything")');
42
+ t.section('sizes and removal never follow links');
43
+ const sized = t.at('sized');
44
+ t.write(path.join(sized, 'a.bin'), 'x'.repeat(1000));
45
+ t.write(path.join(sized, 'sub', 'b.bin'), 'y'.repeat(24));
46
+ const outside = t.at('outside');
47
+ t.write(path.join(outside, 'keep.txt'), 'z'.repeat(5000));
48
+ let linked = false;
49
+ try {
50
+ fs.symlinkSync(outside, path.join(sized, 'sub', 'escape'), 'junction');
51
+ linked = true;
52
+ }
53
+ catch { /* no junctions here */ }
54
+ t.equal(sizeOf(sized), 1024, 'sizes count real files only' + (linked ? ' (a junction counts 0)' : ''));
55
+ t.equal([humanBytes(1536), humanBytes(0), humanBytes(3 * 1024 ** 3)], ['1.5 KB', '0 B', '3.0 GB'], 'sizes are shown in readable units');
56
+ if (linked)
57
+ t.equal(findLinks(sized).length, 1, 'repair finds a junction planted in the data folder');
58
+ removeTree(sized, t.temp);
59
+ t.check(!fs.existsSync(sized), 'a folder is removed');
60
+ t.check(fs.existsSync(path.join(outside, 'keep.txt')), 'removing a folder never deletes what a link inside points to');
61
+ t.section('storage cleanup');
62
+ const store = t.at('prune');
63
+ const aged = (p, days) => { const d = new Date(Date.now() - days * 86400000); fs.utimesSync(p, d, d); };
64
+ for (const [name, days] of [['old', 9], ['kept', 30], ['new', 1]]) {
65
+ aged(t.write(path.join(store, 'launches', name, 'agent.wsb'), 'x'), days);
66
+ aged(path.join(store, 'launches', name), days);
67
+ }
68
+ aged(t.write(path.join(store, 'data', 'd', 'logs', 'guest-old.log'), 'x'), 20);
69
+ t.write(path.join(store, 'data', 'd', 'logs', 'guest-new.log'), 'x');
70
+ const staging = t.dir('prune', 'data', 'd', 'tools', 'git.staging-ab12');
71
+ aged(staging, 2);
72
+ t.dir('prune', 'data', 'd', 'tools', 'node');
73
+ const pruned = pruneStorage(store, { keep: ['kept'] });
74
+ const left = (p) => fs.existsSync(path.join(store, p));
75
+ t.equal(pruned.launches, 1, 'cleanup removes one launch folder');
76
+ t.equal({ old: left('launches/old'), kept: left('launches/kept'), new: left('launches/new') }, { old: false, kept: true, new: true }, 'cleanup: launch folders older than 7 days go; the running launch and recent ones stay');
77
+ t.equal({ oldLog: left('data/d/logs/guest-old.log'), newLog: left('data/d/logs/guest-new.log') }, { oldLog: false, newLog: true }, 'cleanup: logs older than 14 days go');
78
+ t.equal({ interrupted: left('data/d/tools/git.staging-ab12'), realTool: left('data/d/tools/node') }, { interrupted: false, realTool: true }, 'cleanup: interrupted downloads go; real tools stay');
79
+ },
73
80
  };
@@ -1,2 +1,2 @@
1
- import type { Harness } from './harness.js';
2
- export declare const detectTests: (t: Harness) => void;
1
+ import type { Suite } from './suite.js';
2
+ export declare const detectSuite: Suite;
@@ -1,42 +1,58 @@
1
- // "Which databases does this project need?": detection from real-world file shapes, and the plan the host builds.
1
+ // "What does this project need?": databases and toolchains detected from project files, and the plans built from them.
2
2
  import { databasesFromServices, detectDatabases } from '../core/detect-databases.js';
3
3
  import { DEFAULTS } from '../core/config.js';
4
- import { databaseCatalog, planDatabases } from '../host/project-scan.js';
5
- import { voltaNode } from '../core/workspace.js';
4
+ import { databaseCatalog, planDatabases } from '../host/database-plan.js';
6
5
  import { decideToolchains, resolveToolchains } from '../core/detect-toolchains.js';
7
- import { parseReleasePage } from '../shared/download.js';
8
- const found = (files, host = []) => detectDatabases(files, host).map((d) => d.database).join(',');
9
- export const detectTests = (t) => {
10
- const { check } = t;
11
- t.section('database detection');
12
- const compose = { path: 'docker-compose.yml', text: 'services:\n db:\n image: postgres:16-alpine\n cache:\n image: "redis:7"\n dynamo:\n image: amazon/dynamodb-local:latest\n' };
13
- check(found([compose]) === 'postgres,redis,dynamodb', 'compose services: postgres, redis and DynamoDB Local images');
14
- check(found([{ path: 'package.json', text: '{"dependencies":{"pg":"^8.11.0","ioredis":"5","@aws-sdk/client-dynamodb":"3"}}' }]) === 'postgres,redis,dynamodb', 'package.json: pg, ioredis, @aws-sdk/client-dynamodb');
15
- check(found([{ path: 'package.json', text: '{"dependencies":{"pgadmin-ui":"1","mysql-logger":"2","express":"4"}}' }]) === '', 'package.json: names that only contain a driver name do not count');
16
- check(found([{ path: 'prisma/schema.prisma', text: 'datasource db {\n provider = "mysql"\n url = env("DATABASE_URL")\n}' }]) === 'mysql', 'Prisma: the datasource provider decides');
17
- check(found([{ path: 'requirements.txt', text: 'django==5.0\npsycopg2-binary==2.9\npymongo>=4\n' }]) === 'postgres,mongodb', 'Python requirements: psycopg2 and pymongo');
18
- check(found([{ path: 'Api/Api.csproj', text: '<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />' }]) === 'sqlserver', '.NET: EF Core SQL Server provider');
19
- check(found([{ path: '.env.example', text: 'DATABASE_URL=postgresql://user:pass@localhost:5432/app\nMONGO_URL=mongodb+srv://cluster/x\n' }]) === 'postgres,mongodb', '.env templates: connection-string schemes');
20
- check(found([{ path: 'template.yaml', text: 'Resources:\n Table:\n Type: AWS::DynamoDB::Table\n' }]) === 'dynamodb', 'SAM/CloudFormation: a DynamoDB table resource');
21
- check(found([{ path: 'go.mod', text: 'require (\n\tgithub.com/jackc/pgx/v5 v5.5.0\n\tgithub.com/aws/aws-sdk-go-v2/service/dynamodb v1.0.0\n)' }]) === 'postgres,dynamodb', 'go.mod: pgx and the DynamoDB SDK');
22
- check(found([{ path: 'package.json', text: '{"dependencies":{"knex":"3"}}' }]) === '' && found([{ path: 'package.json', text: '{"dependencies":{"knex":"3"}}' }], ['mysql']) === 'mysql', 'an ORM that names no database uses the one installed on this PC (and nothing without one)');
23
- check(databasesFromServices(['postgresql-x64-16', 'MySQL80', 'MSSQL$SQLEXPRESS', 'Spooler', 'Redis']).join() === 'postgres,mysql,sqlserver,redis', 'database services on this PC are recognised by name');
24
- const plan = planDatabases({ ...DEFAULTS.databases, mongodb: { ...DEFAULTS.databases.mongodb, enabled: true }, redis: { ...DEFAULTS.databases.redis, enabled: false } }, 'unused', { files: [compose], host: [] });
25
- check(plan.map((p) => p.database + '@' + p.version + ':' + p.port).join() === 'postgres@16:5432,mongodb@latest:27017,dynamodb@latest:8000' && plan.find((p) => p.database === 'dynamodb')?.adminPort === 8001, 'plan: "auto" from the project (compose pins Postgres 16), true always, false never; DynamoDB gets its admin UI');
26
- const catalog = databaseCatalog({ ...DEFAULTS.databases, postgres: { ...DEFAULTS.databases.postgres, enabled: false, port: 6543 } });
27
- check(catalog.map((p) => p.database).join() === 'postgres,mysql,mongodb,redis,dynamodb' && catalog[0].port === 6543, 'sbx-db catalog: every database that can run (not SQL Server), even ones off at boot, with their configured ports');
28
- const volta = t.write(t.temp + '\\volta-ws\\package.json', '{"name":"x","volta":{"node":"20.11.1","pnpm":"9.1.0"}}');
29
- t.write(t.temp + '\\no-volta-ws\\package.json', '{"name":"y","volta":{"node":"latest; rm -rf"}}');
30
- check(voltaNode(volta.replace(/\\package\.json$/, '')) === '20.11.1' && voltaNode(t.temp + '\\no-volta-ws') === null && voltaNode(t.temp + '\\missing') === null, 'Volta: package.json "volta.node" picks the Node version (only a plain version is accepted)');
31
- const tc = DEFAULTS.toolchains;
32
- const goApi = decideToolchains(tc, ['README.md', 'go.mod', 'api/main.go'], ['python', 'rust']);
33
- check(goApi.go.on && goApi.go.reason === 'go.mod' && !goApi.python.on && !goApi.rust.on && !goApi.dotnet.on && !goApi.java.on, 'toolchains: a Go project gets Go and nothing it does not use, whatever this PC has');
34
- const fresh = decideToolchains(tc, ['notes.txt'], ['python', 'dotnet']);
35
- check(fresh.python.on && fresh.dotnet.on && !fresh.go.on && fresh.python.reason.includes('this PC'), 'toolchains: a folder that is no project yet gets the languages this PC has');
36
- const rusty = decideToolchains({ ...tc, python: { ...tc.python, enabled: true } }, ['Cargo.toml'], []);
37
- check(rusty.rust.on && rusty.cpp.on && rusty.python.on && rusty.python.reason === 'enabled in config', 'toolchains: Rust brings its C/C++ linker; true in config always wins');
38
- check(resolveToolchains(tc, goApi).go.enabled === true && resolveToolchains(tc, goApi).python.enabled === false, 'the sandbox gets plain decisions instead of "auto"');
39
- const page = '<a href="/o/r/releases/download/v1/tool-win-x64.zip">x</a><span>sha256:' + 'a'.repeat(64) + '</span><a href="/o/r/releases/download/v1/tool-linux.tar.gz">y</a>';
40
- const assets = parseReleasePage(page);
41
- check(assets.length === 2 && assets[0].url === 'https://github.com/o/r/releases/download/v1/tool-win-x64.zip' && assets[0].sha256 === 'a'.repeat(64) && assets[1].sha256 === null, 'GitHub release page fallback (API rate limit): assets with their own sha256 only');
6
+ const found = (files, host = []) => detectDatabases(files, host).map((d) => d.database);
7
+ const file = (path, text) => ({ path, text });
8
+ export const detectSuite = {
9
+ name: 'detect',
10
+ title: 'database and toolchain detection from project files',
11
+ run: (t) => {
12
+ t.section('databases from project files');
13
+ const compose = file('docker-compose.yml', 'services:\n db:\n image: postgres:16-alpine\n cache:\n image: "redis:7"\n dynamo:\n image: amazon/dynamodb-local:latest\n');
14
+ t.equal(found([compose]), ['postgres', 'redis', 'dynamodb'], 'compose services: postgres, redis and DynamoDB Local images');
15
+ t.equal(found([file('package.json', '{"dependencies":{"pg":"^8.11.0","ioredis":"5","@aws-sdk/client-dynamodb":"3"}}')]), ['postgres', 'redis', 'dynamodb'], 'package.json: pg, ioredis, @aws-sdk/client-dynamodb');
16
+ t.equal(found([file('package.json', '{"dependencies":{"pgadmin-ui":"1","mysql-logger":"2","express":"4"}}')]), [], 'package.json: names that only contain a driver name do not count');
17
+ t.equal(found([file('prisma/schema.prisma', 'datasource db {\n provider = "mysql"\n url = env("DATABASE_URL")\n}')]), ['mysql'], 'Prisma: the datasource provider decides');
18
+ t.equal(found([file('requirements.txt', 'django==5.0\npsycopg2-binary==2.9\npymongo>=4\n')]), ['postgres', 'mongodb'], 'Python requirements: psycopg2 and pymongo');
19
+ t.equal(found([file('Api/Api.csproj', '<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />')]), ['sqlserver'], '.NET: EF Core SQL Server provider');
20
+ t.equal(found([file('.env.example', 'DATABASE_URL=postgresql://user:pass@localhost:5432/app\nMONGO_URL=mongodb+srv://cluster/x\n')]), ['postgres', 'mongodb'], '.env templates: connection-string schemes');
21
+ t.equal(found([file('template.yaml', 'Resources:\n Table:\n Type: AWS::DynamoDB::Table\n')]), ['dynamodb'], 'SAM/CloudFormation: a DynamoDB table resource');
22
+ t.equal(found([file('go.mod', 'require (\n\tgithub.com/jackc/pgx/v5 v5.5.0\n\tgithub.com/aws/aws-sdk-go-v2/service/dynamodb v1.0.0\n)')]), ['postgres', 'dynamodb'], 'go.mod: pgx and the DynamoDB SDK');
23
+ const knex = [file('package.json', '{"dependencies":{"knex":"3"}}')];
24
+ t.equal(found(knex), [], 'an ORM that names no database uses nothing when this PC has none');
25
+ t.equal(found(knex, ['mysql']), ['mysql'], 'an ORM that names no database uses the one installed on this PC');
26
+ t.equal(databasesFromServices(['postgresql-x64-16', 'MySQL80', 'MSSQL$SQLEXPRESS', 'Spooler', 'Redis']), ['postgres', 'mysql', 'sqlserver', 'redis'], 'database services on this PC are recognised by name');
27
+ // Hostile workspace files (a cloned repository is untrusted): detection must stay linear.
28
+ const hostile = [
29
+ file('.env', 'Server=a;Database=a;'.repeat(12000)),
30
+ file('compose.yaml', 'services:\n x:\n image: ' + 'postgres'.repeat(20000) + '!\n'),
31
+ file('appsettings.json', 'Host=a;'.repeat(30000)),
32
+ ];
33
+ const started = Date.now();
34
+ detectDatabases(hostile);
35
+ const took = Date.now() - started;
36
+ t.check(took < 2000, 'database detection stays fast on hostile workspace files (no catastrophic backtracking)', 'took ' + took + ' ms');
37
+ t.section('the database plan');
38
+ const plan = planDatabases({ ...DEFAULTS.databases, mongodb: { ...DEFAULTS.databases.mongodb, enabled: true }, redis: { ...DEFAULTS.databases.redis, enabled: false } }, 'unused', { files: [compose], host: [] });
39
+ t.equal(plan.map((p) => p.database + '@' + p.version + ':' + p.port), ['postgres@16:5432', 'mongodb@latest:27017', 'dynamodb@latest:8000'], 'plan: "auto" from the project (compose pins Postgres 16), true always, false never');
40
+ t.equal(plan.find((p) => p.database === 'dynamodb')?.adminPort, 8001, 'plan: DynamoDB gets its admin UI port');
41
+ const catalog = databaseCatalog({ ...DEFAULTS.databases, postgres: { ...DEFAULTS.databases.postgres, enabled: false, port: 6543 } });
42
+ t.equal(catalog.map((p) => p.database), ['postgres', 'mysql', 'mongodb', 'redis', 'dynamodb'], 'sbx-db catalog: every database that can run (not SQL Server), even ones off at boot');
43
+ t.equal(catalog[0].port, 6543, 'sbx-db catalog: configured ports are used');
44
+ t.section('toolchains from project files');
45
+ const tc = DEFAULTS.toolchains;
46
+ const goProject = decideToolchains(tc, ['README.md', 'go.mod', 'api/main.go'], ['python', 'rust']);
47
+ t.equal({ go: goProject.go.on, reason: goProject.go.reason }, { go: true, reason: 'go.mod' }, 'toolchains: a Go project gets Go (and says why)');
48
+ t.equal({ python: goProject.python.on, rust: goProject.rust.on, dotnet: goProject.dotnet.on, java: goProject.java.on }, { python: false, rust: false, dotnet: false, java: false }, 'toolchains: a Go project gets nothing it does not use, whatever this PC has');
49
+ const fresh = decideToolchains(tc, ['notes.txt'], ['python', 'dotnet']);
50
+ t.equal({ python: fresh.python.on, dotnet: fresh.dotnet.on, go: fresh.go.on }, { python: true, dotnet: true, go: false }, 'toolchains: a folder that is no project yet gets the languages this PC has');
51
+ t.includes(fresh.python.reason, 'this PC', 'toolchains: the reason names this PC');
52
+ const rusty = decideToolchains({ ...tc, python: { ...tc.python, enabled: true } }, ['Cargo.toml'], []);
53
+ t.equal({ rust: rusty.rust.on, cpp: rusty.cpp.on }, { rust: true, cpp: true }, 'toolchains: Rust brings its C/C++ linker');
54
+ t.equal({ python: rusty.python.on, reason: rusty.python.reason }, { python: true, reason: 'enabled in config' }, 'toolchains: true in config always wins');
55
+ const resolved = resolveToolchains(tc, goProject);
56
+ t.equal({ go: resolved.go.enabled, python: resolved.python.enabled }, { go: true, python: false }, 'the sandbox gets plain decisions instead of "auto"');
57
+ },
42
58
  };
@@ -2,6 +2,20 @@ import type { Config, HostProbe, ResolvedRun, RunRequest } from '../core/types.j
2
2
  import { generateSandbox } from '../host/generate.js';
3
3
  import type { Harness } from './harness.js';
4
4
  export declare const HOST_NODE_VERSION = "v22.3.0";
5
+ /** Colour codes are removed from captured output, so tests can match plain text. */
6
+ export declare const stripAnsi: (text: string) => string;
7
+ export interface NodeRun {
8
+ status: number | null;
9
+ out: string;
10
+ timedOut: boolean;
11
+ }
12
+ export interface NodeRunOptions {
13
+ cwd?: string;
14
+ env?: NodeJS.ProcessEnv;
15
+ timeoutMs?: number;
16
+ }
17
+ /** Runs `node <args>` and collects stdout + stderr (colours removed). Never throws; a hung process is killed at the timeout. */
18
+ export declare const runNode: (args: string[], options?: NodeRunOptions) => Promise<NodeRun>;
5
19
  export declare const createFixtures: (t: Harness) => {
6
20
  at: (...parts: string[]) => string;
7
21
  hostNode: string;
@@ -26,6 +40,8 @@ export declare const createFixtures: (t: Harness) => {
26
40
  sandbox: {
27
41
  memoryMb: number | "auto";
28
42
  maxMemoryMb: number;
43
+ maxMemoryPercent: number;
44
+ cpuLimit: number | "none";
29
45
  reserveHostMb: number;
30
46
  vGpu: boolean;
31
47
  networking: boolean;
@@ -56,6 +72,9 @@ export declare const createFixtures: (t: Harness) => {
56
72
  enabled: boolean;
57
73
  };
58
74
  };
75
+ downloads: {
76
+ requireChecksum: boolean;
77
+ };
59
78
  toolchains: {
60
79
  node: {
61
80
  version: string;
@@ -184,6 +203,10 @@ export declare const createFixtures: (t: Harness) => {
184
203
  auto: boolean;
185
204
  autoHide: boolean;
186
205
  };
206
+ agentCommands: {
207
+ enabled: boolean;
208
+ agents: string[] | "auto";
209
+ };
187
210
  secrets: {
188
211
  strip: boolean;
189
212
  allow: string[];
@@ -217,16 +240,33 @@ export declare const createFixtures: (t: Harness) => {
217
240
  hooks: {
218
241
  failFast: boolean;
219
242
  };
243
+ projects: {
244
+ all: boolean;
245
+ exclude: string[];
246
+ roots: string[];
247
+ askToAddRoot: boolean;
248
+ declinedRoots: string[];
249
+ wsbShare: boolean;
250
+ };
220
251
  $schema?: string | undefined;
221
252
  };
222
253
  probe: HostProbe;
223
254
  run: (over?: Partial<RunRequest>, config?: Config) => Promise<ResolvedRun>;
224
255
  generate: (r: ResolvedRun, args?: string[], p?: HostProbe) => ReturnType<typeof generateSandbox>;
256
+ launch: (over?: Partial<RunRequest>, options?: {
257
+ config?: Config;
258
+ args?: string[];
259
+ probe?: HostProbe;
260
+ }) => Promise<ReturnType<typeof generateSandbox>>;
225
261
  writeLocal: (dir: string, content: unknown) => void;
226
- runGuest: (launchPath: string, data: string, workspace: string, extra?: Record<string, string>) => {
227
- status: number | null;
228
- out: string;
262
+ historySides: (name: string, hostWorkspace: string, guestWorkspace: string) => {
263
+ hostClaudeDir: string;
264
+ guestClaudeDir: string;
265
+ hostWorkspace: string;
266
+ guestWorkspace: string;
229
267
  };
230
- readJson: (file: string) => any;
268
+ runGuest: (launchPath: string, data: string, workspace: string, extra?: Record<string, string>) => Promise<NodeRun>;
269
+ runBin: (name: string, args: string[], options?: NodeRunOptions) => Promise<NodeRun>;
270
+ readJson: <T = any>(file: string) => T;
231
271
  };
232
272
  export type Fixtures = ReturnType<typeof createFixtures>;
@@ -1,16 +1,27 @@
1
- // A fake "host PC" inside the temp folder: Node, claude.exe, a ~/.claude profile, workspaces, and helpers to resolve,
2
- // generate and run the guest against it.
3
- import fs from 'node:fs';
1
+ // A fake "host PC" inside the temp folder: Node, claude.exe, a ~/.claude profile, workspaces, and helpers to resolve a
2
+ // run, generate the sandbox for it, run the guest against it and start the CLI. Shared by all suites.
4
3
  import path from 'node:path';
5
- import { spawnSync } from 'node:child_process';
4
+ import { spawn } from 'node:child_process';
6
5
  import { DEFAULTS } from '../core/config.js';
7
6
  import { generateSandbox } from '../host/generate.js';
8
- import { ensureDir } from '../core/util.js';
9
7
  import { resolveRun } from '../core/workspace.js';
10
8
  export const HOST_NODE_VERSION = 'v22.3.0';
9
+ /** Colour codes are removed from captured output, so tests can match plain text. */
10
+ export const stripAnsi = (text) => text.replace(/\x1b\[[0-9;]*m/g, '');
11
+ /** Runs `node <args>` and collects stdout + stderr (colours removed). Never throws; a hung process is killed at the timeout. */
12
+ export const runNode = (args, options = {}) => new Promise((resolve) => {
13
+ const child = spawn(process.execPath, args, { cwd: options.cwd, env: options.env ?? process.env, windowsHide: true });
14
+ let out = '';
15
+ let timedOut = false;
16
+ const timer = setTimeout(() => { timedOut = true; child.kill(); }, options.timeoutMs ?? 120000);
17
+ child.stdout.on('data', (c) => { out += c.toString(); });
18
+ child.stderr.on('data', (c) => { out += c.toString(); });
19
+ child.on('error', (e) => { out += String(e); });
20
+ child.on('close', (status) => { clearTimeout(timer); resolve({ status: timedOut ? null : status, out: stripAnsi(out), timedOut }); });
21
+ });
11
22
  export const createFixtures = (t) => {
12
- const { temp, write } = t;
13
- const at = (...parts) => path.join(temp, ...parts);
23
+ const { at, write } = t;
24
+ // The PC: Node with npm, Claude Code, a ~/.claude profile (with a login that must never be copied) and ~/.claude.json.
14
25
  const hostNode = at('host-node');
15
26
  write(path.join(hostNode, 'node.exe'), 'x');
16
27
  write(path.join(hostNode, 'node_modules', 'npm', 'bin', 'npm-cli.js'), '');
@@ -22,16 +33,18 @@ export const createFixtures = (t) => {
22
33
  write(path.join(hostProfile, 'commands', 'review.md'), 'review cmd');
23
34
  write(path.join(hostProfile, 'plugins', 'installed_plugins.json'), JSON.stringify({ path: hostProfile + '\\plugins\\cache\\claude-mem' }));
24
35
  write(path.join(hostProfile, '.credentials.json'), '{ "token": "secret" }');
25
- const hostGlobalState = write(at('host-claude.json'), JSON.stringify({ hasCompletedOnboarding: true, mcpServers: { github: { command: 'npx' } } }));
36
+ const hostGlobalState = t.writeJson(at('host-claude.json'), { hasCompletedOnboarding: true, mcpServers: { github: { command: 'npx' } } });
37
+ // The install folder, two workspaces (B has a setup script), a folder to share, the storage root, and tools "from this PC".
26
38
  const install = at('install');
27
- ensureDir(path.join(install, 'sub'));
28
- const workA = at('work-a');
39
+ t.dir('install', 'sub');
40
+ const workA = t.dir('work-a');
29
41
  const workB = at('work-b');
30
- const shared = at('shared');
31
- for (const d of [workA, workB, shared, path.join(workB, 'assets'), at('storage')])
32
- ensureDir(d);
42
+ t.dir('work-b', 'assets');
43
+ const shared = t.dir('shared');
44
+ t.dir('storage');
33
45
  write(path.join(workB, 'setup.mjs'), "import fs from 'node:fs'; import path from 'node:path'; fs.writeFileSync(path.join(process.env.AGENT_DATA, 'hook-ran.txt'), process.env.AGENT_WORKSPACE);\n");
34
46
  write(at('host-tools', 'agents', 'claude', 'claude.exe'), 'fake-claude');
47
+ /** The default config, pointed at the fake PC. `copyFromHost = false` makes the sandbox download everything instead. */
35
48
  const makeConfig = (copyFromHost = true) => {
36
49
  const c = structuredClone(DEFAULTS);
37
50
  c.storage.root = at('storage');
@@ -47,17 +60,21 @@ export const createFixtures = (t) => {
47
60
  };
48
61
  const baseConfig = makeConfig();
49
62
  const probe = { nodeDir: hostNode, harnessExe: { dir: hostClaude, file: 'claude.exe' }, gitDir: null, nvmDir: null, hostNodeVersion: HOST_NODE_VERSION };
50
- const run = (over = {}, config = baseConfig) => resolveRun({ packageRoot: install, loaded: { config, configPath: null, base: temp }, cwd: workA, noAgent: false, noLocalConfig: false, trustConfig: false, scripts: [], ...over }, HOST_NODE_VERSION);
63
+ /** Resolves a run for `workA` (or whatever `over` says). */
64
+ const run = (over = {}, config = baseConfig) => resolveRun({ packageRoot: install, loaded: { config, configPath: null, base: t.temp }, cwd: workA, noAgent: false, noLocalConfig: false, trustConfig: false, scripts: [], ...over }, HOST_NODE_VERSION);
51
65
  const generate = (r, args = [], p = probe) => generateSandbox({ packageRoot: t.packageRoot, run: r, harnessArgs: args, probe: p });
66
+ /** Resolves a run and generates its sandbox (.wsb + launch folder + session.json) in one step. */
67
+ const launch = async (over = {}, options = {}) => generate(await run(over, options.config ?? baseConfig), options.args, options.probe);
68
+ /** Writes a project's `.sandbox-config.json` (objects are serialised; strings are written as they are). */
52
69
  const writeLocal = (dir, content) => { write(path.join(dir, '.sandbox-config.json'), typeof content === 'string' ? content : JSON.stringify(content)); };
70
+ /** The four folders history sync works with, for a workspace on the PC and its place in the sandbox. */
71
+ const historySides = (name, hostWorkspace, guestWorkspace) => ({ hostClaudeDir: at(name + '-host'), guestClaudeDir: at(name + '-guest'), hostWorkspace, guestWorkspace });
53
72
  /** Runs the guest's bundled main.js in dry-run mode against fake sandbox folders. */
54
- const runGuest = (launchPath, data, workspace, extra = {}) => {
55
- const r = spawnSync(process.execPath, [path.join(launchPath, 'runtime', 'main.js')], {
56
- encoding: 'utf8',
57
- env: { ...process.env, AGENT_LAUNCH_DIR: launchPath, AGENT_DATA_DIR: data, AGENT_HOST_TOOLS_DIR: at('host-tools'), AGENT_WORKSPACE: workspace, AGENT_LOCAL_DIR: at('local'), AGENT_MANAGED_DIR: at('managed'), AGENT_DRY_RUN: '1', ...extra },
58
- });
59
- return { status: r.status, out: (r.stdout ?? '') + (r.stderr ?? '') };
60
- };
61
- const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
62
- return { at, hostNode, hostClaude, hostProfile, hostGlobalState, install, workA, workB, shared, makeConfig, baseConfig, probe, run, generate, writeLocal, runGuest, readJson };
73
+ const runGuest = async (launchPath, data, workspace, extra = {}) => runNode([path.join(launchPath, 'runtime', 'main.js')], {
74
+ env: { ...process.env, AGENT_LAUNCH_DIR: launchPath, AGENT_DATA_DIR: data, AGENT_HOST_TOOLS_DIR: at('host-tools'), AGENT_WORKSPACE: workspace, AGENT_LOCAL_DIR: at('local'), AGENT_MANAGED_DIR: at('managed'), AGENT_DRY_RUN: '1', ...extra },
75
+ });
76
+ /** Runs a built command (dist/bin/<name>.js) as its own process. */
77
+ const runBin = (name, args, options = {}) => runNode([path.join(t.packageRoot, 'dist', 'bin', name + '.js'), ...args], options);
78
+ const readJson = t.readJson;
79
+ return { at, hostNode, hostClaude, hostProfile, hostGlobalState, install, workA, workB, shared, makeConfig, baseConfig, probe, run, generate, launch, writeLocal, historySides, runGuest, runBin, readJson };
63
80
  };
@@ -0,0 +1,2 @@
1
+ import type { Suite } from './suite.js';
2
+ export declare const generateSuite: Suite;
@@ -0,0 +1,63 @@
1
+ // What the host generates for a sandbox: the .wsb file, the launch folder (runtime, C# sources, boot scripts),
2
+ // session.json, the staged Claude profile, and the values read from this PC (git identity, free RAM).
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { LocalOverrides } from '../core/schema.js';
6
+ import { gitIdentity } from '../host/launch/stage.js';
7
+ import { buildWsb, resolveMemoryMb } from '../host/launch/wsb.js';
8
+ export const generateSuite = {
9
+ name: 'generate',
10
+ title: 'generated .wsb, launch folder and session.json',
11
+ run: async (t, f) => {
12
+ t.section('the .wsb file');
13
+ const gen = await f.launch({}, { args: ['--version'], probe: { ...f.probe, gitDir: f.shared, nvmDir: f.workB } });
14
+ const wsb = fs.readFileSync(gen.wsbPath, 'utf8');
15
+ t.includes(wsb, '<MemoryInMB>8192</MemoryInMB>', 'wsb: the configured memory');
16
+ t.includes(wsb, '<SandboxFolder>C:\\Workspace\\work-a</SandboxFolder>', 'wsb: the workspace is mapped to its guest folder');
17
+ t.includes(wsb, 'C:\\HostTools\\git', 'wsb: this PC\'s git is mapped');
18
+ t.includes(wsb, 'C:\\HostTools\\nvm', 'wsb: this PC\'s nvm is mapped');
19
+ t.excludes(wsb, 'Missing', 'wsb: an optional mapping whose folder is missing is skipped');
20
+ t.includes(wsb, 'logon.cmd</Command>', 'wsb: the logon command');
21
+ t.includes(wsb, '<vGPU>Disable</vGPU>', 'wsb: vGPU is off');
22
+ t.includes(buildWsb(f.baseConfig, 4096, [{ host: 'C:\\a&b', guest: 'C:\\X', readOnly: true }]), 'C:\\a&amp;b', 'wsb values are XML-escaped');
23
+ t.section('the launch folder');
24
+ const expectedFiles = ['package.json', 'runtime/main.js', 'runtime/daemon.js', 'runtime/view.js', 'runtime/gitguard.js', 'sandbox/bootstrap.ps1', 'sandbox/logon.cmd', 'sandbox/trust.cmd', 'sandbox/ptyhost.cs', 'sandbox/gitshim.cs'];
25
+ const missing = expectedFiles.filter((file) => !fs.existsSync(path.join(gen.launchPath, file)));
26
+ t.equal(missing, [], 'the launch folder has the runtime, the C# sources and the boot scripts');
27
+ t.equal(fs.readdirSync(path.join(gen.launchPath, 'runtime')).length, 5, 'the runtime is 5 self-contained files');
28
+ const logon = fs.readFileSync(path.join(gen.launchPath, 'sandbox', 'logon.cmd'), 'utf8');
29
+ t.check(logon.indexOf('trust.cmd') < logon.indexOf('wt.exe'), 'logon.cmd applies the sandbox trust settings before opening Windows Terminal');
30
+ t.includes(logon, 'tools\\terminal\\wt.exe', 'logon.cmd opens Windows Terminal from the tools folder');
31
+ t.includes(logon, '-NoExit', 'logon.cmd falls back to a PowerShell window');
32
+ t.section('session.json');
33
+ const session = f.readJson(gen.sessionPath);
34
+ t.equal({
35
+ launchId: session.launchId === gen.launchId,
36
+ workspace: session.workspace,
37
+ workspaceHost: session.workspaceHost,
38
+ firstArgument: session.harnessArguments[0],
39
+ gitFromHost: Boolean(session.hostTools.git),
40
+ nvmFromHost: Boolean(session.hostTools.nvm),
41
+ nodeSpec: session.versions.node.spec,
42
+ gitAccess: session.provisioning.git.access,
43
+ }, { launchId: true, workspace: 'C:\\Workspace\\work-a', workspaceHost: f.workA, firstArgument: '--version', gitFromHost: true, nvmFromHost: true, nodeSpec: '22.3.0', gitAccess: 'full' }, 'session.json carries the id, paths, agent arguments, host tools, versions and git access');
44
+ t.matches(session.hostTools.nodeVersion ?? '', /^\d+\.\d+\.\d+$/, 'session.json: the host Node version is a plain version');
45
+ t.equal(session.cpuLimitPercent, 80, 'session.json carries the CPU cap for cpucap.ps1 (default 80 %)');
46
+ t.check(!LocalOverrides.safeParse({ sandbox: { cpuLimit: 'none' } }).success, "a project's .sandbox-config.json cannot lift your CPU cap (sandbox.cpuLimit is yours only)");
47
+ t.section('what is copied from this PC');
48
+ const staged = path.join(gen.launchPath, 'claude-profile');
49
+ const stagedMissing = ['settings.json', 'CLAUDE.md', '.claude.json', 'plugins'].filter((name) => !fs.existsSync(path.join(staged, name)));
50
+ t.equal(stagedMissing, [], 'Claude settings, instructions, global state and plugins are staged');
51
+ t.check(!fs.existsSync(path.join(staged, '.credentials.json')), 'the Claude login is not staged');
52
+ const gitconfig = '[user]\n\tname = Josh\n\temail = "j@x.io"\n[credential]\n\thelper = store\n[url "https://TOKEN@github.com/"]\n\tinsteadOf = https://github.com/\n';
53
+ t.equal(gitIdentity(gitconfig), '[user]\n\tname = Josh\n\temail = j@x.io\n', 'only name/email from ~/.gitconfig reach the sandbox (no credential helpers or token URLs)');
54
+ t.equal(gitIdentity('[core]\n\tname = x\n'), '', 'a name outside [user] is not an identity');
55
+ const auto = { ...f.baseConfig.sandbox, memoryMb: 'auto' };
56
+ const MB = 1048576;
57
+ const pcRam = 64 * 1024 * MB;
58
+ t.equal(resolveMemoryMb(auto, 40000 * MB, pcRam), 31744, "memory 'auto' follows free RAM minus the reserve");
59
+ await t.throws(() => resolveMemoryMb(auto, 9000 * MB, pcRam), 'Not enough free RAM', "memory 'auto' refuses when too little RAM is free");
60
+ t.equal(resolveMemoryMb({ ...auto, maxMemoryPercent: 80 }, 30000 * MB, 32 * 1024 * MB), 21504, "memory 'auto' never takes more than maxMemoryPercent of the PC's RAM");
61
+ t.equal(resolveMemoryMb({ ...auto, maxMemoryPercent: 50 }, 60000 * MB, pcRam), 32768, "memory 'auto' is capped at half the PC's RAM with maxMemoryPercent 50 (free RAM and maxMemoryMb still apply)");
62
+ },
63
+ };
@@ -0,0 +1,2 @@
1
+ import type { Suite } from './suite.js';
2
+ export declare const gitAccessSuite: Suite;
@@ -0,0 +1,26 @@
1
+ // Git access modes ("full", "readonly", "none"): which git commands the guard allows and the matching Claude deny rules.
2
+ import { gitDecision } from '../guest/gitguard.js';
3
+ import { gitDenyRules } from '../guest/gitrules.js';
4
+ export const gitAccessSuite = {
5
+ name: 'git-access',
6
+ title: 'git access modes and Claude deny rules',
7
+ run: (t) => {
8
+ t.section('readonly');
9
+ const allowed = (args, mode = 'readonly') => gitDecision(args, mode).allow;
10
+ const reading = ['status', 'log --oneline', 'diff HEAD~1', 'show HEAD', 'blame a.ts', 'branch', 'branch -a', 'branch --contains HEAD', 'tag', 'tag -l v*', 'remote -v', 'config --get user.name', 'stash list', '-C sub status', '--no-pager log'];
11
+ t.equal(reading.filter((command) => !allowed(command.split(' '))), [], 'readonly: reading commands are allowed');
12
+ const writing = ['commit -m x', 'push', 'add .', 'checkout main', 'switch -c x', 'reset --hard', 'stash', 'stash pop', 'branch new', 'branch -D x', 'tag v1', 'merge x', 'rebase main', 'fetch', 'pull', 'config user.name x', 'remote add o u', '-c core.hooksPath=x status', '--git-dir=x status', 'clean -fd', 'apply p.diff'];
13
+ t.equal(writing.filter((command) => allowed(command.split(' '))), [], 'readonly: anything that writes (or overrides config) is refused');
14
+ t.section('none and full');
15
+ t.check(!allowed(['status'], 'none') && !allowed(['--version'], 'none'), 'none: every git call is refused');
16
+ t.check(allowed(['commit'], 'full'), 'full: everything is allowed');
17
+ t.section('Claude managed deny rules');
18
+ const readonlyRules = gitDenyRules('readonly');
19
+ const noneRules = gitDenyRules('none');
20
+ t.equal(gitDenyRules('full').length, 0, 'full: no deny rules');
21
+ t.includes(readonlyRules.join('\n'), 'Bash(git push:*)', 'readonly: pushing is denied');
22
+ t.check(!readonlyRules.some((r) => r.startsWith('Read(')), 'readonly: reading .git is not denied');
23
+ t.check(noneRules.includes('Bash(git:*)'), 'none: every git command is denied');
24
+ t.check(noneRules.includes('Read(**/.git/**)'), 'none: reading .git is denied too');
25
+ },
26
+ };
@@ -0,0 +1,2 @@
1
+ import type { Suite } from './suite.js';
2
+ export declare const guestBootSuite: Suite;