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
@@ -0,0 +1,2 @@
1
+ import type { Suite } from './suite.js';
2
+ export declare const accountsSuite: Suite;
@@ -0,0 +1,44 @@
1
+ // Claude accounts: saving logins from the sandbox or this PC, switching, and the names that are refused.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { activateAccount, activeAccount, defaultAccount, listAccounts, readAccount, removeAccount, saveAccount, setDefaultAccount } from '../host/accounts.js';
5
+ export const accountsSuite = {
6
+ name: 'accounts',
7
+ title: 'Claude accounts (save, switch, remove, safe names)',
8
+ run: async (t, f) => {
9
+ t.section('saving and switching');
10
+ const storage = t.at('acct-storage');
11
+ const dataA = path.join(storage, 'data', 'x');
12
+ const sandboxClaude = path.join(dataA, 'state', 'claude');
13
+ t.write(path.join(sandboxClaude, '.credentials.json'), '{"claudeAiOauth":{"refreshToken":"personal-1"}}');
14
+ t.write(path.join(sandboxClaude, '.claude.json'), '{"oauthAccount":{"emailAddress":"me@personal"},"other":1}');
15
+ saveAccount(storage, dataA, 'personal', null);
16
+ t.write(t.at('host-login', '.credentials.json'), '{"claudeAiOauth":{"refreshToken":"work-1"}}');
17
+ t.write(t.at('host-login.json'), '{"oauthAccount":{"emailAddress":"me@work"}}');
18
+ saveAccount(storage, dataA, 'work', { claudeDir: t.at('host-login'), globalStateFile: t.at('host-login.json') });
19
+ t.equal(listAccounts(storage, dataA).length, 2, 'accounts can be saved from the sandbox and from this PC');
20
+ t.equal(defaultAccount(storage), 'personal', 'the first account saved becomes the default');
21
+ t.equal(activeAccount(dataA), 'personal', 'the first account saved is the active one');
22
+ t.check(!fs.existsSync(path.join(sandboxClaude, '.agent-sandbox-account')), 'which account is active is not recorded inside the sandbox...');
23
+ t.check(fs.existsSync(path.join(storage, 'accounts', 'index.json')), '... it is recorded host-side');
24
+ t.write(path.join(sandboxClaude, '.credentials.json'), '{"claudeAiOauth":{"refreshToken":"personal-2"}}');
25
+ activateAccount(storage, dataA, 'work');
26
+ const stateAfter = f.readJson(path.join(sandboxClaude, '.claude.json'));
27
+ t.includes(fs.readFileSync(path.join(sandboxClaude, '.credentials.json'), 'utf8'), 'work-1', 'switching swaps in the other account\'s credentials');
28
+ t.equal(stateAfter.oauthAccount.emailAddress, 'me@work', 'switching swaps the signed-in account');
29
+ t.equal(stateAfter.other, 1, 'switching keeps the rest of the sandbox state');
30
+ t.equal(activeAccount(dataA), 'work', 'the new account is active');
31
+ t.includes(JSON.stringify(readAccount(storage, 'personal')?.credentials), 'personal-2', 'the outgoing account\'s refreshed tokens are saved back before switching');
32
+ t.section('removing and refusing');
33
+ setDefaultAccount(storage, 'work');
34
+ removeAccount(storage, 'work');
35
+ t.equal(defaultAccount(storage), null, 'removing the default account clears the default');
36
+ await t.throws(() => activateAccount(storage, dataA, 'nope'), 'Unknown account', 'an unknown account cannot be activated');
37
+ await t.throws(() => saveAccount(storage, dataA, '../x', null), 'Invalid account name', 'a path-like account name is rejected');
38
+ const victim = t.dir('victim');
39
+ const otherData = t.at('acct-storage', 'data', 'd');
40
+ const fromHost = { claudeDir: victim, globalStateFile: victim };
41
+ await t.throws(() => saveAccount(t.at('acct-storage'), otherData, 'index', fromHost), 'Invalid account name', 'an account cannot be called "index" (it would overwrite the index)');
42
+ await t.throws(() => saveAccount(t.at('acct-storage'), otherData, 'con', fromHost), 'Invalid account name', 'an account cannot be named after a device (it would open a device)');
43
+ },
44
+ };
@@ -0,0 +1,2 @@
1
+ import type { Suite } from './suite.js';
2
+ export declare const agentCommandsSuite: Suite;
@@ -0,0 +1,47 @@
1
+ // Agent commands (Beta): which agents get a forwarding command, what the files contain, and that "off" changes nothing.
2
+ // Everything happens in the test's temp folder with a made-up PATH; your real PATH is never read or written.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { DEFAULTS } from '../core/config.js';
6
+ import { agentCommands, commandsDir, forwardingCmd, forwardingSh, realAgent, reconcileAgentCommands } from '../host/agent-commands.js';
7
+ export const agentCommandsSuite = {
8
+ name: 'agent-commands',
9
+ title: 'agent commands (Beta): claude, codex ... forwarded into a hidden sandbox',
10
+ run: async (t) => {
11
+ const storage = t.at('agent-cmd-storage');
12
+ const npmDir = t.at('agent-cmd-npm');
13
+ const localBin = t.at('agent-cmd-local');
14
+ t.write(path.join(npmDir, 'codex.cmd'), '@echo codex');
15
+ t.write(path.join(npmDir, 'gemini.cmd'), '@echo gemini');
16
+ t.write(path.join(localBin, 'claude.exe'), 'MZ');
17
+ // A forwarding file of our own, first on PATH, must never count as the real agent.
18
+ t.write(path.join(commandsDir(storage), 'claude.cmd'), '@rem windows-development-sandbox agent command');
19
+ const PATH = [commandsDir(storage), npmDir, localBin].join(path.delimiter);
20
+ const on = { ...DEFAULTS, agentCommands: { enabled: true, agents: 'auto' } };
21
+ t.section('finding agents');
22
+ t.equal(realAgent('claude', commandsDir(storage), PATH), path.join(localBin, 'claude.exe'), 'the real agent is found on PATH, skipping this tool\'s own forwarding files');
23
+ t.equal(realAgent('nope', commandsDir(storage), PATH), null, 'an agent that is not installed is not found');
24
+ const commandsOf = (config, searchPath) => agentCommands(config, storage, searchPath).map((a) => a.command).sort();
25
+ t.equal(commandsOf(on, PATH), ['claude', 'codex', 'gemini'], '"auto": every agent installed on this PC gets a command, agents not installed none');
26
+ t.equal(commandsOf({ ...on, agentCommands: { enabled: true, agents: ['codex'] } }, PATH), ['codex'], 'a list limits it to the named agents');
27
+ const unsafeDir = t.at('agent%cmd^dir');
28
+ t.write(path.join(unsafeDir, 'aider.cmd'), '@echo');
29
+ t.check(!agentCommands(on, storage, unsafeDir).some((a) => a.command === 'aider'), 'an agent in a folder whose path has % or ^ (which cmd.exe would expand) is left alone');
30
+ t.section('the forwarding files');
31
+ const claude = { harness: 'claude', command: 'claude', real: path.join(localBin, 'claude.exe') };
32
+ const cmd = forwardingCmd(claude, 'C:\\node\\node.exe', 'C:\\tool\\dist\\bin\\sandbox.js');
33
+ t.includes(cmd, '"C:\\node\\node.exe" "C:\\tool\\dist\\bin\\sandbox.js" launch --harness claude --headless --terminal -- %*', 'the .cmd starts the agent in a hidden sandbox attached to this terminal...');
34
+ t.includes(cmd, 'if "%SANDBOX_AGENT_DIRECT%"=="1" goto direct', '... unless SANDBOX_AGENT_DIRECT=1...');
35
+ t.includes(cmd, 'call "' + claude.real + '" %*', '... which runs the real agent');
36
+ const sh = forwardingSh({ ...claude, real: 'C:\\Users\\me\\it\'s\\claude.exe' }, 'C:\\node\\node.exe', 'C:\\tool\\sandbox.js');
37
+ t.check(sh.startsWith('#!/bin/sh'), 'Git Bash gets a shell script');
38
+ t.includes(sh, "exec '/c/node/node.exe' '/c/tool/sandbox.js' launch --harness claude --headless --terminal -- \"$@\"", 'the shell script starts the same sandbox, with POSIX paths');
39
+ t.includes(sh, "'/c/Users/me/it'\\''s/claude.exe'", 'the shell script quotes awkward paths safely');
40
+ t.section('off changes nothing');
41
+ // Off with nothing set up: nothing to do, no PATH edit (which would be refused during the self-test anyway).
42
+ const fresh = t.at('agent-cmd-fresh');
43
+ const off = reconcileAgentCommands(DEFAULTS, fresh, 'C:\\node\\node.exe', 'C:\\tool\\sandbox.js');
44
+ t.equal({ on: off.on, changed: off.changed, wrote: fs.existsSync(commandsDir(fresh)) }, { on: false, changed: false, wrote: false }, 'off (the default) writes nothing and leaves your PATH alone');
45
+ await t.throws(() => reconcileAgentCommands(on, t.at('agent-cmd-on'), 'C:\\node\\node.exe', 'C:\\tool\\sandbox.js'), /self-test|PATH/i, 'turning it on edits your PATH, which the self-test refuses (it never changes this PC)');
46
+ },
47
+ };
@@ -0,0 +1,2 @@
1
+ import type { Suite } from './suite.js';
2
+ export declare const channelSuite: Suite;
@@ -0,0 +1,37 @@
1
+ // The host <-> sandbox channel and the terminal protocol: everything arriving from the sandbox is validated, sized and
2
+ // filtered before this PC acts on it.
3
+ import { safeId } from '../shared/rundir.js';
4
+ import { GuestRequest, SessionStatus } from '../shared/schema.js';
5
+ import { encodeFrame, FrameReader, FrameType, fixWindowsKeys, helloFrame, MAX_FRAME_BYTES, OutputFilter } from '../shared/pty-client.js';
6
+ export const channelSuite = {
7
+ name: 'channel',
8
+ title: 'request/status validation and the terminal protocol (frames, OSC 52, keys)',
9
+ run: async (t) => {
10
+ t.section('requests and session status');
11
+ const validRequest = (r) => GuestRequest.safeParse(r).success;
12
+ const validSession = (s) => SessionStatus.safeParse(s).success;
13
+ await t.throws(() => safeId('../evil'), 'invalid id', 'a path-like id is rejected');
14
+ t.check(!validRequest({ kind: 'shell', id: 'a' }), 'unknown request kinds are rejected');
15
+ t.check(!validRequest({ kind: 'exec', id: 'a/b', command: ['x'], cwd: 'C:\\', timeoutSeconds: 1 }), 'request ids must be safe file names');
16
+ t.check(validRequest({ kind: 'ping', id: 'abc', createdAt: 'x' }), 'a known request kind with a safe id is accepted');
17
+ t.check(!validRequest({ kind: 'ping', id: 'abc', createdAt: 'x', extra: 1 }), 'requests with unknown keys are rejected');
18
+ const goodSession = { id: 's1', launchId: 'l', harness: 'claude', cwd: 'C:\\Workspace', message: 'ok', phase: 'running', updatedAt: 'x', startedAt: 'x', attach: { addresses: ['172.20.1.5'], port: 5000, token: 't' } };
19
+ t.check(validSession(goodSession), 'a well-formed session status is accepted');
20
+ t.check(!validSession({ ...goodSession, attach: { ...goodSession.attach, port: 70000 } }), 'a session status with an impossible port is rejected');
21
+ t.check(!validSession({ ...goodSession, phase: 'pwned' }), 'a session status with an unknown phase is rejected');
22
+ t.section('terminal frames');
23
+ const filter = new OutputFilter();
24
+ const filtered = Buffer.concat([
25
+ filter.push(Buffer.from('a\x1b]52;c;ZXZpbA==\x07b\x1b]0;title\x07')),
26
+ filter.push(Buffer.from('c\x1b]5')),
27
+ filter.push(Buffer.from('2;c;cGF5\x1b\\d')),
28
+ ]).toString('latin1');
29
+ t.equal(filtered, 'a' + 'b\x1b]0;title\x07' + 'c' + 'd', 'clipboard writes (OSC 52) from the sandbox are stripped, even split across chunks; titles pass');
30
+ const oversizedHeader = Buffer.concat([Buffer.from([16]), Buffer.from([0, 0, 0, 0x7f])]);
31
+ await t.throws(() => new FrameReader().push(oversizedHeader), 'frame too large', 'a frame announcing too much data is rejected instead of buffered');
32
+ t.equal(new FrameReader().push(encodeFrame(16, Buffer.alloc(MAX_FRAME_BYTES))).length, 1, 'a frame of exactly the maximum size is accepted');
33
+ const hello = new FrameReader().push(helloFrame('tok', 100, 30, 2))[0];
34
+ t.equal({ type: hello.type, cols: hello.payload.readInt16LE(0), rows: hello.payload.readInt16LE(2), flags: hello.payload[4], token: hello.payload.subarray(5).toString() }, { type: FrameType.Hello, cols: 100, rows: 30, flags: 2, token: 'tok' }, 'a hello frame carries size, flags and token');
35
+ t.equal(fixWindowsKeys(Buffer.from([0x61, 0x08, 0x7f, 0x1b])), Buffer.from([0x61, 0x7f, 0x08, 0x1b]), 'Windows console Backspace (^H) reaches the agent as DEL, so it deletes one character, not a word');
36
+ },
37
+ };
@@ -1,2 +1,2 @@
1
- import type { Harness } from './harness.js';
2
- export declare const cliTests: (t: Harness) => void;
1
+ import type { Suite } from './suite.js';
2
+ export declare const cliSuite: Suite;
@@ -1,30 +1,118 @@
1
- // The command manifest, parser and menu helpers: one definition drives both the flags and the menu.
1
+ // The command manifest, the argument parser, the interactive menu helpers and the settings screen. One definition
2
+ // (cli/manifest.ts) drives both the flags and the menu.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
2
5
  import { buildProgram, parseCli } from '../cli/args.js';
3
6
  import { COMMANDS } from '../cli/manifest.js';
4
- import { commandLine, readHistory, recordHistory, splitArgs } from '../cli/menu.js';
5
- import { editConfigText } from '../cli/settings.js';
6
- export const cliTests = (t) => {
7
- const { check } = t;
8
- t.section('CLI: manifest, parser, menu');
9
- const program = buildProgram(() => undefined);
10
- const visible = COMMANDS.filter((c) => !c.hidden).map((c) => c.name);
11
- check(visible.every((name) => program.commands.some((c) => c.name() === name)) && program.commands.find((c) => c.name() === 'launch').options.some((o) => o.long === '--terminal'), 'every manifest command and option exists in the parser');
12
- const start = parseCli(['start', '--headless']);
13
- check(start.command === 'launch' && start.noAgent && start.headless === true, '`start` is launch without an agent');
14
- const clean = parseCli(['data', 'clean', 'tools/go', 'logs', '--yes']);
15
- check(clean.command === 'data' && clean.positionals.join() === 'clean,tools/go,logs' && clean.yes, 'data clean takes targets and --yes');
16
- check(parseCli(['start', '--database', 'postgres,redis', '--database', 'mongodb']).databases.join() === 'postgres,redis,mongodb' && parseCli(['db', 'start', 'redis']).positionals.join() === 'start,redis', '--database takes lists and repeats; `db` takes an action and a name');
17
- check(parseCli(['launch', '--harness', 'opencode', '--', '--continue']).passthrough.join() === '--continue', 'agent arguments after -- reach the agent');
18
- check(JSON.stringify(splitArgs('--model "big one" -c \'x y\'')) === '["--model","big one","-c","x y"]' && commandLine(['exec', 'echo', 'a b']) === 'sandbox exec echo "a b"', 'menu: arguments split like a shell, command lines quoted');
19
- const original = '{\n // my notes stay\n "sandbox": { "headless": true }\n}\n';
20
- const set = editConfigText(original, ['attach', 'auto'], true);
21
- const nested = 'text' in set ? editConfigText(set.text, ['databases', 'postgres', 'port'], 6543) : set;
22
- const reset = 'text' in nested ? editConfigText(nested.text, ['sandbox', 'headless'], undefined) : nested;
23
- const invalid = editConfigText(original, ['sandbox', 'memoryMb'], 12);
24
- check('text' in reset && reset.text.includes('// my notes stay') && reset.text.includes('"auto": true') && reset.text.includes('"port": 6543') && !reset.text.includes('headless') && 'problems' in invalid && invalid.problems.join().includes('memoryMb'), 'settings screen: changes keep the file\'s comments, nested keys are created, reset removes a key, invalid values are not saved');
25
- recordHistory(['launch', '--harness', 'claude']);
26
- recordHistory(['status']);
27
- recordHistory(['launch', '--harness', 'claude']);
28
- recordHistory(['menu']);
29
- check(readHistory().map((h) => h.argv.join(' ')).join('|') === 'launch --harness claude|status', 'history: newest first, no duplicates, the menu itself is not recorded');
7
+ import { commandLine, readHistory, recordHistory } from '../cli/history.js';
8
+ import { optionProblem, splitArgs } from '../cli/menu.js';
9
+ import { cleanTargetOf } from '../cli/commands/data/collect.js';
10
+ import { ago, colorEnabled, shorten, shortenMiddle } from '../cli/colors.js';
11
+ import { hoistConfig, withoutConfig } from '../cli/index.js';
12
+ import { closest, validNodeSpec, validPythonSpec } from '../cli/validate.js';
13
+ import { editConfigText, SECTION_TITLES } from '../cli/settings/index.js';
14
+ import { childrenOf, isSection, jsonExample, leavesOf, promptKind } from '../cli/settings/schema.js';
15
+ import { Config, LocalOverrides } from '../core/schema.js';
16
+ export const cliSuite = {
17
+ name: 'cli',
18
+ title: 'command manifest, argument parser, menu helpers, settings screen',
19
+ run: async (t) => {
20
+ t.section('manifest and parser');
21
+ const program = buildProgram(() => undefined);
22
+ const visible = COMMANDS.filter((c) => !c.hidden).map((c) => c.name);
23
+ const missingFromParser = visible.filter((name) => !program.commands.some((c) => c.name() === name));
24
+ t.equal(missingFromParser, [], 'every manifest command exists in the parser');
25
+ t.check(program.commands.find((c) => c.name() === 'launch').options.some((o) => o.long === '--terminal'), 'every manifest option exists in the parser (launch --terminal)');
26
+ const packageJson = JSON.parse(fs.readFileSync(path.join(t.packageRoot, 'package.json'), 'utf8'));
27
+ t.equal(program.version(), packageJson.version, '--version prints the package version');
28
+ t.check(program.options.some((o) => o.short === '-v' && o.long === '--version'), '-v is a short form of --version');
29
+ const start = parseCli(['start', '--headless']);
30
+ t.equal({ command: start.command, noAgent: start.noAgent, headless: start.headless }, { command: 'launch', noAgent: true, headless: true }, '`start` is launch without an agent');
31
+ const clean = parseCli(['data', 'clean', 'tools/go', 'logs', '--yes']);
32
+ t.equal({ command: clean.command, positionals: clean.positionals, yes: clean.yes }, { command: 'data', positionals: ['clean', 'tools/go', 'logs'], yes: true }, 'data clean takes targets and --yes');
33
+ t.equal(parseCli(['start', '--database', 'postgres,redis', '--database', 'mongodb']).databases, ['postgres', 'redis', 'mongodb'], '--database takes lists and can repeat');
34
+ t.equal(parseCli(['db', 'start', 'redis']).positionals, ['start', 'redis'], '`db` takes an action and a name');
35
+ t.equal(parseCli(['launch', '--harness', 'opencode', '--', '--continue']).passthrough, ['--continue'], 'agent arguments after -- reach the agent');
36
+ t.section('parser: agent wrappers (claude-sbx ...) and agent-sandbox');
37
+ const wrapped = parseCli(['--terminal', '--continue', '--headless', '--node', '20', '--', '--terminal'], true);
38
+ t.equal({ command: wrapped.command, terminal: wrapped.terminal, headless: wrapped.headless, node: wrapped.node, passthrough: wrapped.passthrough }, { command: 'launch', terminal: true, headless: true, node: '20', passthrough: ['--continue', '--terminal'] }, 'claude-sbx: sandbox flags are consumed, everything else goes to the agent');
39
+ await t.throws(() => parseCli(['status', '--bogus']), 'unknown option', 'agent-sandbox: unknown options are errors');
40
+ t.equal(parseCli(['account', 'use', 'work', '--no-restart']).positionals, ['use', 'work'], 'agent-sandbox: positionals are collected');
41
+ t.equal(parseCli(['clean', '--days=3']).days, 3, 'agent-sandbox: --opt=value works');
42
+ const agentFlags = parseCli(['--model', 'opus', '-p', 'fix it', '--json', '-f', 'x', '--timeout', '5', '--resume', 'abc'], true);
43
+ t.equal(agentFlags.passthrough, ['--model', 'opus', '-p', 'fix it', '--json', '-f', 'x', '--timeout', '5', '--resume', 'abc'], 'claude-sbx: any flag that is not a launch option goes to the agent, in order (even ones agent-sandbox uses elsewhere)');
44
+ t.equal({ json: agentFlags.json, follow: agentFlags.follow, timeout: agentFlags.timeout }, { json: false, follow: false, timeout: 600 }, 'claude-sbx: the agent\'s flags do not set agent-sandbox options');
45
+ const reserved = parseCli(['--sbx-terminal', '--terminal', '--sbx-node=18'], true);
46
+ t.equal({ terminal: reserved.terminal, node: reserved.node, passthrough: reserved.passthrough }, { terminal: true, node: '18', passthrough: [] }, '--sbx-<option> always means agent-sandbox');
47
+ t.equal(parseCli(['launch', '--continue', '--sbx-headless']).passthrough, ['--continue'], 'agent-sandbox launch passes agent flags too');
48
+ await t.throws(() => parseCli(['--sbx-nope'], true), 'Unknown option: --sbx-nope', 'an unknown --sbx-<option> is an error');
49
+ t.section('menu helpers');
50
+ t.equal(splitArgs('--model "big one" -c \'x y\''), ['--model', 'big one', '-c', 'x y'], 'menu: arguments split like a shell');
51
+ t.equal(commandLine(['exec', 'echo', 'a b']), 'sandbox exec echo "a b"', 'menu: command lines are quoted');
52
+ const optionOf = (flag) => COMMANDS.flatMap((cmd) => cmd.options).find((o) => o.flag === flag);
53
+ t.check(optionProblem(optionOf('--node'), 'abc') !== undefined, 'menu: a bad Node version is rejected while typing');
54
+ t.check(optionProblem(optionOf('--node'), '22') === undefined, 'menu: a good Node version is accepted');
55
+ t.check(optionProblem(optionOf('--workspace'), path.join(t.packageRoot, 'no-such-folder-here')) !== undefined, 'menu: a missing folder is rejected');
56
+ t.check(optionProblem(optionOf('--workspace'), t.packageRoot) === undefined, 'menu: an existing folder is accepted');
57
+ t.check(optionProblem(optionOf('--days'), '0') !== undefined, 'menu: a number below the minimum is rejected');
58
+ const rowOf = (area, name) => ({ area, name, path: 'x', bytes: 1 });
59
+ t.equal([cleanTargetOf(rowOf('tools', 'go')), cleanTargetOf(rowOf('tools', 'agents/claude')), cleanTargetOf(rowOf('databases', 'databases/pg')), cleanTargetOf(rowOf('host', 'launches'))], ['tools/go', 'tools/agents/claude', 'databases/pg', 'launches'], 'data menu: removable rows become checkbox targets');
60
+ t.equal([cleanTargetOf(rowOf('host', 'accounts')), cleanTargetOf(rowOf('tools', '(files in tools)'))], [null, null], 'data menu: accounts and loose files are never offered');
61
+ t.equal(COMMANDS.find((cmd) => cmd.name === 'data').options.find((o) => o.flag === '--yes')?.noMenu, true, 'data menu: --yes is never offered');
62
+ t.section('--config placement, history, validation, text helpers');
63
+ t.equal(hoistConfig(['--config', 'a.json', 'status', '--json']), ['status', '--config', 'a.json', '--json'], '--config before the command is moved behind it');
64
+ t.equal(hoistConfig(['--config=a.json']), ['menu', '--config', 'a.json'], '--config alone opens the menu with it');
65
+ t.equal(withoutConfig(['status', '--config', 'a.json', '--json']), ['status', '--json'], 'the recorded history drops --config');
66
+ recordHistory(['launch', '--harness', 'claude']);
67
+ recordHistory(['status']);
68
+ recordHistory(['launch', '--harness', 'claude']);
69
+ recordHistory(['menu']);
70
+ t.equal(readHistory().map((h) => h.argv.join(' ')), ['launch --harness claude', 'status'], 'history: newest first, no duplicates, the menu itself is not recorded');
71
+ t.check(validNodeSpec('22') && validNodeSpec('v22.1.0') && validNodeSpec('lts/*') && !validNodeSpec('abc'), 'validation: Node version syntax');
72
+ t.check(validPythonSpec('3.12') && !validPythonSpec('2.7'), 'validation: Python version syntax');
73
+ t.equal(closest('cluade', ['claude', 'codex']), 'claude', 'validation: did-you-mean for agent names');
74
+ t.equal(closest('zzzzzz', ['claude']), undefined, 'validation: nothing is suggested for an unrelated name');
75
+ const env = (vars, isTty) => colorEnabled(vars, isTty);
76
+ t.check(env({}, true), 'colours: on for a terminal');
77
+ t.check(!env({}, false), 'colours: off for a pipe');
78
+ t.check(!env({ NO_COLOR: '1' }, true) && !env({ TERM: 'dumb' }, true), 'colours: off for NO_COLOR and TERM=dumb');
79
+ t.check(env({ FORCE_COLOR: '1' }, false) && !env({ FORCE_COLOR: '0' }, true), 'colours: FORCE_COLOR overrides');
80
+ t.equal(shorten('abcdefghij', 6), 'abcde…', 'text helpers: shorten');
81
+ t.equal(shortenMiddle('C:\\Users\\me\\project', 11), 'C:\\Us…oject', 'text helpers: shorten in the middle');
82
+ t.equal([ago(0, 10 * 60 * 1000), ago(0, 5000), ago(0, 3 * 86400 * 1000)], ['10 min ago', 'just now', '3 d ago'], 'text helpers: relative time');
83
+ t.section('settings screen');
84
+ const original = '{\n // my notes stay\n "sandbox": { "headless": true }\n}\n';
85
+ const set = editConfigText(original, ['attach', 'auto'], true);
86
+ const nested = 'text' in set ? editConfigText(set.text, ['databases', 'postgres', 'port'], 6543) : set;
87
+ const reset = 'text' in nested ? editConfigText(nested.text, ['sandbox', 'headless'], undefined) : nested;
88
+ t.check('text' in reset, 'editing a config file gives new text');
89
+ const resetText = 'text' in reset ? reset.text : '';
90
+ t.includes(resetText, '// my notes stay', 'settings screen: changes keep the file\'s comments');
91
+ t.includes(resetText, '"auto": true', 'settings screen: a new setting is added');
92
+ t.includes(resetText, '"port": 6543', 'settings screen: nested keys are created');
93
+ t.excludes(resetText, 'headless', 'settings screen: reset removes a key');
94
+ const invalid = editConfigText(original, ['sandbox', 'memoryMb'], 12);
95
+ t.check('problems' in invalid && invalid.problems.join().includes('memoryMb'), 'settings screen: invalid values are not saved', JSON.stringify(invalid));
96
+ const missing = editConfigText('{ "storage": { "root": "C:\\\\x" } }', ['agentCommands', 'enabled'], undefined);
97
+ t.check('text' in missing && missing.text.includes('"root"'), 'removing a setting the file does not have changes nothing (no crash)');
98
+ const projectEdit = editConfigText('{}', ['downloads', 'requireChecksum'], true, 'project');
99
+ const projectBad = editConfigText('{}', ['storage', 'root'], 'C:\\x', 'project');
100
+ t.check('text' in projectEdit && projectEdit.text.includes('requireChecksum'), 'settings screen: a project file accepts project settings');
101
+ t.check('problems' in projectBad, 'settings screen: a project file is validated against the project schema (storage cannot be set there)');
102
+ // Every setting must be reachable in the settings screen: each leaf has a prompt, each top-level section a title.
103
+ const userLeaves = leavesOf(Config);
104
+ const projectLeaves = leavesOf(LocalOverrides);
105
+ const sectionNames = (schema) => childrenOf(schema).filter(([, s]) => isSection(s)).map(([name]) => name);
106
+ const kinds = new Set(userLeaves.map((l) => promptKind(l.schema)));
107
+ t.check(userLeaves.length > 100 && projectLeaves.length > 50, 'settings screen: every setting is a leaf (' + userLeaves.length + ' settings, ' + projectLeaves.length + ' in a project file)');
108
+ const missingKinds = ['boolean', 'enum', 'union', 'number', 'string', 'string-list', 'json'].filter((k) => !kinds.has(k));
109
+ t.equal(missingKinds, [], 'settings screen: every kind of setting has a prompt');
110
+ t.check(userLeaves.some((l) => l.key.join('.') === 'downloads.requireChecksum'), 'settings screen: downloads.requireChecksum is reachable in your settings');
111
+ t.check(projectLeaves.some((l) => l.key.join('.') === 'downloads.requireChecksum'), 'settings screen: downloads.requireChecksum is reachable in a project file');
112
+ const untitled = [...sectionNames(Config), ...sectionNames(LocalOverrides)].filter((name) => !SECTION_TITLES[name]);
113
+ t.equal(untitled, [], 'settings screen: every top-level section has a title');
114
+ const jsonLeaves = userLeaves.filter((l) => promptKind(l.schema) === 'json');
115
+ t.check(jsonLeaves.length > 0 && jsonLeaves.every((l) => jsonExample(l.key, l.schema).length > 5), 'settings screen: JSON settings show an example of the expected shape');
116
+ t.includes(jsonExample(['harnesses'], jsonLeaves[0].schema), 'npmPackage', 'settings screen: the agent definition example shows its fields');
117
+ },
30
118
  };
@@ -0,0 +1,2 @@
1
+ import type { Suite } from './suite.js';
2
+ export declare const cmdlineSuite: Suite;
@@ -0,0 +1,45 @@
1
+ // How the sandbox builds command lines for npm-installed agents (.cmd files run through cmd.exe): & | < > ^ % and quotes
2
+ // must reach the agent unchanged.
3
+ import { spawnSync } from 'node:child_process';
4
+ import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { isWin } from '../core/util.js';
7
+ import { runNode } from './fixtures.js';
8
+ // Odd quote counts flip cmd's quote state for every later argument: those come after them on purpose.
9
+ const TRICKY_ARGUMENTS = ['https://x?a=1&b=2', '50% off', 'a|b<c>d', 'x^y', 'say "hi" & go', 'plain', '%PATH%', 'a"b&c', 'q"x|y^z"w', 'e"5%', 'k&v', '100%'];
10
+ export const cmdlineSuite = {
11
+ name: 'cmdline',
12
+ title: 'cmd.exe quoting for npm-installed agents',
13
+ run: async (t) => {
14
+ t.section('command lines');
15
+ // guest/cmdline.ts is loaded in a child process with a throwaway launch/data folder (it reads session.json on load).
16
+ const quoteDir = t.at('quote');
17
+ t.write(path.join(quoteDir, 'launch', 'session.json'), '{}');
18
+ const agentCmd = t.write(path.join(quoteDir, 'agent.cmd'), '@"' + process.execPath + '" -e "console.log(JSON.stringify(process.argv.slice(1)))" %*\r\n');
19
+ const script = [
20
+ 'const m = await import(process.argv[1]); const [cmd, tricky] = JSON.parse(process.argv[2]);',
21
+ 'console.log(JSON.stringify({ normal: m.commandLine("C:\\\\W\\\\cmd.exe", ["/d", "/c", "C:\\\\a\\\\agent.cmd", "--model", "big one", "plain", ""]), direct: m.commandLine("C:\\\\a\\\\agent.exe", ["a&b", "50%"]),',
22
+ ' url: m.cmdArgs(["https://x?a=1&b=2"]), pct: m.cmdArgs(["50%"]), live: m.commandLine("cmd.exe", ["/d", "/c", cmd, ...tricky]).slice(8) }));',
23
+ ].join('\n');
24
+ const probe = await runNode(['--input-type=module', '-e', script, pathToFileURL(path.join(t.packageRoot, 'dist', 'guest', 'cmdline.js')).href, JSON.stringify([agentCmd, TRICKY_ARGUMENTS])], {
25
+ env: { ...process.env, AGENT_LAUNCH_DIR: path.join(quoteDir, 'launch'), AGENT_DATA_DIR: path.join(quoteDir, 'data') },
26
+ });
27
+ let quoting = {};
28
+ try {
29
+ quoting = JSON.parse(probe.out);
30
+ }
31
+ catch { /* reported below */ }
32
+ const whyEmpty = quoting.normal ? '' : 'the helper printed: ' + probe.out.trim().slice(0, 300);
33
+ t.check(quoting.normal === 'C:\\W\\cmd.exe /d /c C:\\a\\agent.cmd --model "big one" plain ""', 'ordinary arguments are quoted as before', whyEmpty + '\nactual: ' + quoting.normal);
34
+ t.check(quoting.direct === 'C:\\a\\agent.exe a&b 50%', 'a program started directly gets & and % unchanged', 'actual: ' + quoting.direct);
35
+ t.equal(quoting.url, '"https://x?a=1&b=2"', 'cmd /c arguments: & | < > ^ are quoted');
36
+ t.equal(quoting.pct, '"50"%""', 'cmd /c arguments: % is split out so no %name% expands');
37
+ // (A temp folder with spaces would also need cmd's own quote rules, so this end-to-end run skips those.)
38
+ if (isWin && quoting.live && !/\s/.test(agentCmd)) {
39
+ const echoed = spawnSync(process.env.ComSpec ?? 'cmd.exe', [quoting.live], { encoding: 'utf8', windowsVerbatimArguments: true, windowsHide: true });
40
+ t.equal(echoed.stdout.trim(), JSON.stringify(TRICKY_ARGUMENTS), 'cmd /d /c <agent>.cmd passes &, |, <, >, ^, % and quotes through to the agent unchanged');
41
+ }
42
+ else
43
+ t.skip('end-to-end cmd.exe quoting run (not Windows, or the temp path has spaces)');
44
+ },
45
+ };
@@ -1,3 +1,2 @@
1
- import type { Fixtures } from './fixtures.js';
2
- import type { Harness } from './harness.js';
3
- export declare const configTests: (t: Harness, f: Fixtures) => Promise<void>;
1
+ import type { Suite } from './suite.js';
2
+ export declare const configSuite: Suite;
@@ -1,74 +1,38 @@
1
- // Config loading, argument parsing, workspace resolution and .sandbox-config.json trust.
2
- import fs from 'node:fs';
1
+ // Loading the settings: built-in defaults, a user config file, and the example config that ships with the tool.
3
2
  import path from 'node:path';
4
- import { parseCli as parseArgs } from '../cli/args.js';
5
- import { loadConfig } from '../core/config.js';
6
- import { ensureDir } from '../core/util.js';
7
- import { autoGuestWorkspace } from '../core/workspace.js';
8
- export const configTests = async (t, f) => {
9
- const { check, rejects, write, temp } = t;
10
- t.section('config');
11
- const builtIn = loadConfig();
12
- check(builtIn.configPath === null && builtIn.config.guestWorkspace === 'auto' && !builtIn.config.sandbox.headless && !builtIn.config.sandbox.vGpu && builtIn.config.provisioning.git.access === 'full', 'no user config: built-in defaults (auto guest folder, headless off, vGPU off, git full)');
13
- const custom = loadConfig(write(path.join(temp, 'cfg.json'), JSON.stringify({ sandbox: { headless: true }, toolchains: { rust: { enabled: false } }, mappings: [{ host: 'rel-folder', guest: 'C:\\Rel', readOnly: true }] })));
14
- check(custom.config.sandbox.headless && custom.config.toolchains.rust.enabled === false && custom.config.toolchains.python.enabled === 'auto', 'a config file only needs the keys it changes (deep merge over defaults)');
15
- check(await rejects(() => loadConfig(path.join(temp, 'missing.json')), 'Config file not found'), 'an explicit --config that does not exist is an error');
16
- check(loadConfig(path.join(t.packageRoot, 'config.example.json')).config.defaultHarness === 'claude', 'config.example.json is valid');
17
- ensureDir(path.join(temp, 'rel-folder'));
18
- const relRun = await f.run({}, { ...custom.config, storage: f.baseConfig.storage });
19
- check(relRun.config.mappings.some((m) => m.host === path.join(temp, 'rel-folder')), 'relative mappings in the user config resolve against the config file\'s folder');
20
- t.section('arguments');
21
- const wrapped = parseArgs(['--terminal', '--continue', '--headless', '--node', '20', '--', '--terminal'], true);
22
- check(wrapped.command === 'launch' && wrapped.terminal && wrapped.headless === true && wrapped.node === '20' && wrapped.passthrough.join(' ') === '--continue --terminal', 'claude-sbx: sandbox flags are consumed, everything else goes to the agent');
23
- check(await rejects(() => parseArgs(['status', '--bogus']), 'unknown option') && parseArgs(['account', 'use', 'work', '--no-restart']).positionals.join() === 'use,work' && parseArgs(['clean', '--days=3']).days === 3, 'agent-sandbox: unknown options are errors; positionals and --opt=value work');
24
- const agentFlags = parseArgs(['--model', 'opus', '-p', 'fix it', '--json', '-f', 'x', '--timeout', '5', '--resume', 'abc'], true);
25
- check(agentFlags.passthrough.join(' ') === '--model opus -p fix it --json -f x --timeout 5 --resume abc' && !agentFlags.json && !agentFlags.follow && agentFlags.timeout === 600, 'claude-sbx: only launch options are ours; any other flag (even ones agent-sandbox uses elsewhere) goes to the agent, in order');
26
- const reserved = parseArgs(['--sbx-terminal', '--terminal', '--sbx-node=18'], true);
27
- check(reserved.terminal && reserved.node === '18' && reserved.passthrough.join(' ') === '' && parseArgs(['launch', '--continue', '--sbx-headless']).passthrough.join() === '--continue' && await rejects(() => parseArgs(['--sbx-nope'], true), 'Unknown option: --sbx-nope'), '--sbx-<option> always means agent-sandbox; agent-sandbox launch passes agent flags too');
28
- t.section('workspace');
29
- const rA = await f.run();
30
- check(rA.workspaceHost === f.workA && rA.config.guestWorkspace === 'C:\\Workspace\\work-a' && rA.harnessName === 'claude' && rA.dataPath.endsWith(path.join('data', 'testdata')), 'any folder is the workspace; guest path is C:\\Workspace\\<folder name>');
31
- check(autoGuestWorkspace('C:\\x\\My Proj (2)') === 'C:\\Workspace\\My Proj _2_' && autoGuestWorkspace('D:\\...') === 'C:\\Workspace\\project', 'auto guest folder names are sanitised');
32
- check(rA.versions.node.spec === '22.3.0' && rA.versions.node.source === 'this PC' && rA.versions.python.spec === '3', 'default Node version is this PC\'s; Python defaults to latest 3.x');
33
- write(path.join(f.workA, '.nvmrc'), 'lts/iron\n');
34
- write(path.join(f.workA, '.python-version'), '3.11\n');
35
- const rV = await f.run();
36
- check(rV.versions.node.spec === 'lts/iron' && rV.versions.node.source === '.nvmrc' && rV.versions.python.spec === '3.11', '.nvmrc and .python-version are respected');
37
- check((await f.run({ nodeVersion: '18' })).versions.node.source === 'command line', '--node beats .nvmrc');
38
- fs.rmSync(path.join(f.workA, '.nvmrc'));
39
- fs.rmSync(path.join(f.workA, '.python-version'));
40
- write(path.join(f.workA, '.node-version'), 'v20.11.1');
41
- check((await f.run()).versions.node.spec === 'v20.11.1', '.node-version is respected');
42
- fs.rmSync(path.join(f.workA, '.node-version'));
43
- check(await rejects(() => f.run({ cwd: f.install }), 'install folder') && await rejects(() => f.run({ cwd: temp }), 'must not be able to change') && await rejects(() => f.run({ cwd: path.join(temp, 'storage') }), 'storage folder'), 'refuses the install folder, its parents and the storage folder');
44
- const originalWindir = process.env.WINDIR;
45
- ensureDir(path.join(temp, 'winroot', 'sys'));
46
- process.env.WINDIR = path.join(temp, 'winroot');
47
- const sysRefused = await rejects(() => f.run({ cwd: path.join(temp, 'winroot', 'sys') }), 'system folder');
48
- process.env.WINDIR = originalWindir;
49
- check(sysRefused && await rejects(() => f.run({ cwd: path.parse(temp).root }), 'whole drive'), 'refuses system folders and drive roots');
50
- t.section('.sandbox-config.json');
51
- const localB = { harness: 'codex', sandbox: { memoryMb: 4096, networking: false, headless: true }, toolchains: { node: { version: '18' }, rust: { enabled: false } }, mappings: [{ host: './assets', guest: 'C:\\Assets', readOnly: true }, { host: '../shared', guest: 'C:\\Shared', readOnly: false }], scripts: ['./setup.mjs'] };
52
- f.writeLocal(f.workB, localB);
53
- check(await rejects(() => f.run({ cwd: f.workB }), '--trust-config') && await rejects(() => f.run({ cwd: f.workB }), 'READ/WRITE'), 'an untrusted .sandbox-config.json is refused and its effects are listed');
54
- check(await rejects(() => f.run({ cwd: f.workB, ask: async () => false }), 'has not been approved'), 'answering "no" refuses it');
55
- const rB = await f.run({ cwd: f.workB, ask: async () => true });
56
- check(rB.harnessName === 'codex' && rB.config.sandbox.memoryMb === 4096 && rB.config.sandbox.headless && !rB.config.toolchains.rust.enabled && rB.config.toolchains.python.enabled && rB.versions.node.source === '.sandbox-config.json', 'approved local settings apply over the defaults (incl. toolchains, headless)');
57
- check(rB.config.mappings.some((m) => m.host === f.shared && !m.readOnly) && rB.scripts.includes(path.join(f.workB, 'setup.mjs')), 'relative paths resolve against the folder of .sandbox-config.json');
58
- check((await f.run({ cwd: f.workB })).harnessName === 'codex' && (await f.run({ cwd: f.workB, noLocalConfig: true })).harnessName === 'claude' && (await f.run({ cwd: f.workB, harness: 'gemini' })).harnessName === 'gemini', 'approval is remembered; --no-local-config and --harness win');
59
- check((await f.run({ cwd: f.workB, headless: false })).config.sandbox.headless === false, '--window beats a local headless setting');
60
- f.writeLocal(f.workB, { ...localB, sandbox: { memoryMb: 6144 } });
61
- check(await rejects(() => f.run({ cwd: f.workB }), 'has not been approved'), 'changing the file requires approval again');
62
- const dirC = path.join(temp, 'work-c');
63
- ensureDir(dirC);
64
- const bad = async (content, needle) => { f.writeLocal(dirC, content); return rejects(() => f.run({ cwd: dirC, trustConfig: true }), needle); };
65
- check(await bad({ storage: { root: 'C:\\x' } }, 'storage is not allowed') && await bad({ harnesses: {} }, 'harnesses is not allowed'), 'local file cannot change storage or agent executables');
66
- check(await bad({ claudeProfile: { hostDir: 'C:\\x' } }, 'claudeProfile.hostDir is not allowed') && await bad({ toolchains: { node: { hostDir: 'x' } } }, 'toolchains.node.hostDir is not allowed'), 'local file cannot point at host folders');
67
- check(await bad({ sandbox: { memoryMb: 12 } }, 'sandbox.memoryMb:') && await bad({ mappings: [{ host: 'x', guest: 'C:\\Windows\\x', readOnly: true }] }, 'reserved or invalid guest path') && await bad({ provisioning: { git: { access: 'admin' } } }, 'provisioning.git.access:'), 'values are type-checked; reserved guest paths and unknown git modes are rejected');
68
- check(await bad('{\n // a comment\n "readOnly": tru\n}', 'line 3') && await bad({ _comment: 'old style' }, '_comment is not allowed'), 'broken JSON is reported with line and column; unknown keys are rejected');
69
- f.writeLocal(dirC, '// comments and trailing commas are fine\n{\n "readOnly": true, /* inline */\n}\n');
70
- check((await f.run({ cwd: dirC, trustConfig: true })).config.readOnly === true, '.sandbox-config.json may contain comments and trailing commas');
71
- f.writeLocal(dirC, { claudeProfile: { copyCredentials: true }, network: { blockPrivateRanges: false }, provisioning: { git: { access: 'full' } } });
72
- check(await rejects(() => f.run({ cwd: dirC }), 'copies your Claude LOGIN') && await rejects(() => f.run({ cwd: dirC }), 'turns OFF the LAN block') && await rejects(() => f.run({ cwd: dirC }), 'commit and push'), 'risky settings are called out before approval');
73
- fs.rmSync(path.join(dirC, '.sandbox-config.json'));
3
+ import _ from 'lodash';
4
+ import { DEFAULTS, loadConfig } from '../core/config.js';
5
+ import { readJsonc } from '../core/util.js';
6
+ export const configSuite = {
7
+ name: 'config',
8
+ title: 'built-in defaults, user config file, config.example.json',
9
+ run: async (t, f) => {
10
+ t.section('built-in defaults');
11
+ const builtIn = loadConfig();
12
+ t.equal({
13
+ configPath: builtIn.configPath,
14
+ guestWorkspace: builtIn.config.guestWorkspace,
15
+ headless: builtIn.config.sandbox.headless,
16
+ vGpu: builtIn.config.sandbox.vGpu,
17
+ gitAccess: builtIn.config.provisioning.git.access,
18
+ }, { configPath: null, guestWorkspace: 'auto', headless: false, vGpu: false, gitAccess: 'full' }, 'with no user config the built-in defaults apply (auto guest folder, headless off, vGPU off, git full)');
19
+ t.section('a user config file');
20
+ const customFile = t.writeJson(t.at('cfg.json'), {
21
+ sandbox: { headless: true },
22
+ toolchains: { rust: { enabled: false } },
23
+ mappings: [{ host: 'rel-folder', guest: 'C:\\Rel', readOnly: true }],
24
+ });
25
+ const custom = loadConfig(customFile);
26
+ t.equal({ headless: custom.config.sandbox.headless, rust: custom.config.toolchains.rust.enabled, python: custom.config.toolchains.python.enabled }, { headless: true, rust: false, python: 'auto' }, 'a config file only needs the keys it changes (deep merge over the defaults)');
27
+ await t.throws(() => loadConfig(t.at('missing.json')), 'Config file not found', 'an explicit --config that does not exist is an error');
28
+ t.dir('rel-folder');
29
+ const relativeRun = await f.run({}, { ...custom.config, storage: f.baseConfig.storage });
30
+ const mappedHosts = relativeRun.config.mappings.map((m) => m.host);
31
+ t.check(mappedHosts.includes(t.at('rel-folder')), 'relative mappings in the user config resolve against the config file\'s folder', 'mapped hosts: ' + JSON.stringify(mappedHosts));
32
+ t.section('config.example.json');
33
+ const examplePath = path.join(t.packageRoot, 'config.example.json');
34
+ t.equal(loadConfig(examplePath).config.defaultHarness, 'claude', 'config.example.json is valid');
35
+ const example = _.omit(readJsonc(examplePath), '$schema');
36
+ t.check(_.isEqual(example, DEFAULTS), 'config.example.json lists exactly the built-in defaults', 'the differences are in: ' + Object.keys(DEFAULTS).filter((key) => !_.isEqual(example[key], DEFAULTS[key])).join(', '));
37
+ },
74
38
  };
@@ -1,2 +1,2 @@
1
- import type { Harness } from './harness.js';
2
- export declare const dataTests: (t: Harness) => void;
1
+ import type { Suite } from './suite.js';
2
+ export declare const dataSuite: Suite;