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,50 @@
1
+ // Links (junctions and symlinks) planted by a compromised sandbox in the folders this PC reads and writes: the host
2
+ // must never write, delete or walk through them.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { activateAccount, saveAccount } from '../host/accounts.js';
6
+ import { resetRunDir, sendRequest } from '../host/channel.js';
7
+ import { pruneStorage } from '../host/cleanup.js';
8
+ import { assertNoLinks } from '../core/util.js';
9
+ import { syncHistoryIn } from '../sync/history.js';
10
+ export const linksSuite = {
11
+ name: 'links',
12
+ title: 'links planted by the sandbox are never followed',
13
+ run: async (t) => {
14
+ const victim = t.at('victim');
15
+ const precious = t.write(path.join(victim, 'precious.txt'), 'keep me');
16
+ const storage = t.at('evil-storage');
17
+ const data = t.dir('evil-storage', 'data', 'd');
18
+ t.dir('evil-storage', 'data', 'd', 'state');
19
+ t.section('writes under the data folder');
20
+ fs.symlinkSync(victim, path.join(data, 'state', 'claude'), 'junction');
21
+ await t.throws(() => assertNoLinks(data, path.join(data, 'state', 'claude', 'x.json')), 'is a link', 'any host write under the data folder through a junction is refused');
22
+ await t.throws(() => activateAccount(storage, data, 'any'), 'Unknown account', 'accounts never read through a sandbox junction');
23
+ await t.throws(() => saveAccount(storage, data, 'a', null), 'is a link', 'accounts never write through a sandbox junction');
24
+ t.section('history sync');
25
+ const sides = { hostClaudeDir: t.at('link-host'), guestClaudeDir: t.at('link-guest'), hostWorkspace: 'D:\\Code\\my-app', guestWorkspace: 'C:\\Workspace\\my-app' };
26
+ t.dir('link-guest', 'projects');
27
+ fs.symlinkSync(victim, path.join(sides.guestClaudeDir, 'projects', 'C--Workspace-my-app'), 'junction');
28
+ t.write(path.join(sides.hostClaudeDir, 'projects', 'D--Code-my-app', 's.jsonl'), '{}\n');
29
+ syncHistoryIn(sides);
30
+ t.check(!fs.existsSync(path.join(victim, 's.jsonl')), 'history sync never writes through a junction inside the sandbox state');
31
+ fs.symlinkSync(precious, path.join(sides.guestClaudeDir, 'history.jsonl'), 'file');
32
+ t.write(path.join(sides.hostClaudeDir, 'history.jsonl'), '{"display":"x","timestamp":1}\n');
33
+ syncHistoryIn(sides);
34
+ t.equal(fs.readFileSync(precious, 'utf8'), 'keep me', 'a file symlink planted in place of history.jsonl is replaced, not written through');
35
+ t.section('the run folder and cleanup');
36
+ t.dir('evil-storage', 'data', 'd', 'run');
37
+ fs.symlinkSync(victim, path.join(data, 'run', 'requests'), 'junction');
38
+ await t.throws(() => sendRequest(data, { kind: 'ping', id: 'p1', createdAt: '' }), 'is a link', 'requests are never written through a junction');
39
+ t.check(!fs.existsSync(path.join(victim, 'p1.json')), 'nothing was written into the junction\'s target');
40
+ fs.symlinkSync(victim, path.join(data, 'run', 'sessions'), 'junction');
41
+ resetRunDir(data);
42
+ t.check(!fs.existsSync(path.join(data, 'run', 'sessions')), 'resetting the run folder removes a junction itself...');
43
+ t.check(fs.existsSync(precious), '... never its target');
44
+ fs.symlinkSync(victim, path.join(data, 'logs'), 'junction');
45
+ const longAgo = new Date(Date.now() - 60 * 86400000);
46
+ fs.utimesSync(precious, longAgo, longAgo);
47
+ pruneStorage(storage);
48
+ t.check(fs.existsSync(precious), 'cleanup never walks into a junction (old files on this PC stay)');
49
+ },
50
+ };
@@ -0,0 +1,2 @@
1
+ import type { Suite } from './suite.js';
2
+ export declare const liveDaemonSuite: Suite;
@@ -0,0 +1,91 @@
1
+ // Live: the real guest session daemon + ptyhost (ConPTY), driven through the run folder exactly like the host does, with
2
+ // cmd.exe standing in for the agent. One daemon serves all the checks below, in order (compiling ptyhost is slow).
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { queryViewers } from '../shared/pty-client.js';
6
+ import { copyCmdAsFakeAgent, openViewer, sees, startDaemon, tail, viewersWhen, WAIT } from './live.js';
7
+ export const liveDaemonSuite = {
8
+ name: 'live-daemon',
9
+ title: 'live: session daemon + ptyhost (start, attach, viewers, exec, restart, exit)',
10
+ live: true,
11
+ timeoutMs: 360000,
12
+ run: async (t, f) => {
13
+ const gen = await f.launch();
14
+ const data = t.at('live-data');
15
+ const workspace = f.workA;
16
+ copyCmdAsFakeAgent(data);
17
+ const session = f.readJson(gen.sessionPath);
18
+ Object.assign(session, { workspace, attach: { enabled: true } });
19
+ Object.assign(session.harness, { arguments: ['/d', '/k', 'echo LIVE-AGENT-READY'], resumeArguments: ['/d', '/k', 'echo LIVE-AGENT-RESUMED'] });
20
+ t.writeJson(gen.sessionPath, session);
21
+ const daemon = startDaemon(t, f, { launchPath: gen.launchPath, data, workspace });
22
+ const viewers = [];
23
+ try {
24
+ t.section('live: starting a session');
25
+ daemon.request({ kind: 'session', id: 'live1', harness: 'claude', args: [], cwd: workspace });
26
+ const started = await daemon.started('live1');
27
+ const running = started?.phase === 'running' && Boolean(started.attach?.port);
28
+ t.check(running, 'the daemon compiles ptyhost and starts a session on request', 'phase: ' + started?.phase + ' ' + (started?.message ?? '(no status written)') + '\n' + daemon.diagnostics());
29
+ if (!started?.attach)
30
+ return;
31
+ const attach = started.attach;
32
+ const local = { ...attach, addresses: ['127.0.0.1'] };
33
+ t.section('live: attached clients');
34
+ const viewer = await openViewer(attach);
35
+ viewers.push(viewer);
36
+ const sawReady = await sees(t, viewer, 'LIVE-AGENT-READY');
37
+ t.check(Boolean(sawReady), 'an attached client sees the agent\'s screen', 'screen so far: ' + tail(viewer.output()) + '\n' + daemon.diagnostics());
38
+ viewer.type('echo TYPED-%USERNAME:~0,0%OK');
39
+ const sawTyped = await sees(t, viewer, 'TYPED-OK');
40
+ t.check(Boolean(sawTyped), 'keystrokes reach the agent', 'screen so far: ' + tail(viewer.output()));
41
+ const intruder = await openViewer(attach, { token: 'wrong-token', cols: 80, rows: 24 });
42
+ viewers.push(intruder);
43
+ const denied = await t.waitFor(() => intruder.error(), WAIT.viewers);
44
+ t.check(Boolean(denied) && denied.includes('denied'), 'a client with the wrong token is refused', 'error frame: ' + JSON.stringify(denied) + ', connection closed: ' + intruder.closed());
45
+ t.check(!intruder.output().includes('LIVE-AGENT'), 'a client with the wrong token sees nothing of the session', tail(intruder.output()));
46
+ t.section('live: counting viewers');
47
+ // Each count is polled: a query that gets no answer yet is asked again, and the number must settle on the expected value.
48
+ const one = await viewersWhen(t, attach, (v) => v.viewers === 1);
49
+ t.check(one !== null, 'a single attached terminal is counted as one viewer', 'last answer: ' + JSON.stringify(await queryViewers(local, 5000)));
50
+ t.equal(one?.remote, 0, 'a viewer on loopback (inside the sandbox) is not counted as remote');
51
+ const second = await openViewer(attach, { cols: 60, rows: 20 });
52
+ viewers.push(second);
53
+ const two = await viewersWhen(t, attach, (v) => v.viewers === 2);
54
+ t.check(two !== null, 'a second attached terminal is counted too', 'last answer: ' + JSON.stringify(await queryViewers(local, 5000)));
55
+ second.close();
56
+ const backToOne = await viewersWhen(t, attach, (v) => v.viewers === 1);
57
+ t.check(backToOne !== null, 'a closed terminal stops counting at once', 'last answer: ' + JSON.stringify(await queryViewers(local, 5000)));
58
+ t.equal(backToOne?.remote, 0, 'the remaining viewer is still not remote');
59
+ t.equal(daemon.status('live1')?.phase, 'running', 'the session keeps running when a terminal closes');
60
+ t.equal(await queryViewers({ ...local, token: 'wrong-token' }, 10000), null, 'the viewer query needs the session token too');
61
+ t.section('live: exec and requests');
62
+ daemon.request({ kind: 'exec', id: 'exec1', command: ['echo', 'hello from exec'], cwd: workspace, timeoutSeconds: 30 });
63
+ const exec = await daemon.response('exec1');
64
+ t.check(Boolean(exec?.ok && exec.stdout?.includes('hello from exec')), 'exec runs a command in the sandbox and returns its output', JSON.stringify(exec));
65
+ daemon.request({ kind: 'exec', id: 'exec2', command: ['dir'], cwd: 'C:\\Windows', timeoutSeconds: 30 });
66
+ const outside = await daemon.response('exec2');
67
+ t.check(outside?.ok === false && outside.message.includes('not inside the sandbox workspace'), 'exec refuses folders outside the workspace', JSON.stringify(outside));
68
+ daemon.request({ kind: 'shell', id: 'evil', command: 'calc' });
69
+ daemon.request({ kind: 'ping', id: 'ping1' });
70
+ const ping = await daemon.response('ping1');
71
+ t.check(Boolean(ping?.ok), 'ping is answered', JSON.stringify(ping));
72
+ // Requests are handled in order, so by the time the ping is answered the unknown one has been dealt with.
73
+ t.check(!fs.existsSync(path.join(daemon.run, 'responses', 'evil.json')), 'an unknown request kind gets no answer...');
74
+ t.check(!fs.existsSync(path.join(daemon.run, 'requests', 'evil.json')), '... and is deleted');
75
+ t.section('live: restart and exit');
76
+ daemon.request({ kind: 'restart', id: 'restart1', harness: 'claude' });
77
+ const sawResumed = await sees(t, viewer, 'LIVE-AGENT-RESUMED');
78
+ t.check(Boolean(sawResumed), 'restart relaunches the agent with its resume arguments while attached', 'screen so far: ' + tail(viewer.output()) + '\n' + daemon.diagnostics());
79
+ viewer.type('exit 3');
80
+ const gotExit = await t.waitFor(() => viewer.exitCode() !== null, WAIT.exit);
81
+ const ended = await t.waitFor(() => { const s = daemon.status('live1'); return s?.phase === 'exited' ? s : null; }, WAIT.exit);
82
+ t.equal(gotExit ? viewer.exitCode() : null, 3, 'the agent\'s exit code reaches the attached client');
83
+ t.equal(ended?.exitCode, 3, 'the agent\'s exit code reaches the session status');
84
+ }
85
+ finally {
86
+ for (const v of viewers)
87
+ v.close();
88
+ await daemon.stop();
89
+ }
90
+ },
91
+ };
@@ -0,0 +1,2 @@
1
+ import type { Suite } from './suite.js';
2
+ export declare const liveGitSuite: Suite;
@@ -0,0 +1,60 @@
1
+ // Live: compiles gitshim.cs with the .NET Framework compiler and runs real git through it in a scratch repository,
2
+ // as git.exe and through Git Bash, in each git access mode.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { findOnPath } from '../core/util.js';
7
+ import { CSC } from './live.js';
8
+ export const liveGitSuite = {
9
+ name: 'live-git',
10
+ title: 'live: the git access guard (compiled git.exe stand-in, Git Bash)',
11
+ live: true,
12
+ run: async (t, f) => {
13
+ const realGit = findOnPath(['git.exe']);
14
+ if (!realGit) {
15
+ t.skip('git guard (no git.exe on PATH)');
16
+ return;
17
+ }
18
+ const gen = await f.launch();
19
+ const shimDir = t.dir('gitshim');
20
+ const compiled = spawnSync(CSC, ['/nologo', '/optimize', '/out:' + path.join(shimDir, 'git.exe'), path.join(gen.launchPath, 'sandbox', 'gitshim.cs')], { encoding: 'utf8' });
21
+ t.check(compiled.status === 0, 'gitshim.cs compiles with the .NET Framework compiler', compiled.stdout + compiled.stderr);
22
+ if (compiled.status !== 0)
23
+ return;
24
+ const repo = t.dir('guard-repo');
25
+ spawnSync(realGit, ['init', '-q'], { cwd: repo });
26
+ fs.writeFileSync(path.join(repo, 'a.txt'), 'hello');
27
+ const guard = path.join(gen.launchPath, 'runtime', 'gitguard.js');
28
+ const env = (access) => ({
29
+ ...process.env,
30
+ PATH: shimDir + path.delimiter + process.env.PATH,
31
+ AGENT_GIT_ACCESS: access,
32
+ AGENT_REAL_GIT: access === 'readonly' ? realGit : '',
33
+ AGENT_GUARD_NODE: process.execPath,
34
+ AGENT_GUARD_SCRIPT: guard,
35
+ GIT_CONFIG_NOSYSTEM: '1',
36
+ });
37
+ // `git` is found through PATH, where the shim comes first (that is the point of the test).
38
+ const git = (access, args) => spawnSync('git', args, { cwd: repo, env: env(access), encoding: 'utf8' });
39
+ t.section('git.exe stand-in');
40
+ const status = git('readonly', ['status', '--porcelain']);
41
+ t.check(status.status === 0 && status.stdout.includes('a.txt'), 'readonly: `git status` works through the git.exe stand-in (exact arguments preserved)', 'exit ' + status.status + ' ' + status.stdout + status.stderr);
42
+ const add = git('readonly', ['add', 'a.txt']);
43
+ t.check(add.status === 128, 'readonly: `git add` is refused with exit code 128', 'exit ' + add.status);
44
+ t.includes(add.stderr, 'read-only', 'readonly: the refusal says why');
45
+ t.includes(git('readonly', ['status', '--porcelain']).stdout, '??', 'readonly: after the refused `git add` nothing has changed');
46
+ const none = git('none', ['status']);
47
+ t.check(none.status === 128 && none.stderr.includes('disabled'), 'none: every git call is refused', 'exit ' + none.status + ' ' + none.stderr);
48
+ t.section('Git Bash');
49
+ const gitRoot = path.dirname(path.dirname(realGit));
50
+ const bash = [path.join(gitRoot, 'bin', 'bash.exe'), path.join(gitRoot, 'usr', 'bin', 'bash.exe')].find((b) => fs.existsSync(b));
51
+ if (!bash) {
52
+ t.skip('Git Bash check (no bash.exe next to git)');
53
+ return;
54
+ }
55
+ const posix = (p) => p.replace(/\\/g, '/');
56
+ const bashEnv = t.write(t.at('gitshim', 'bashenv.sh'), 'git() { "' + posix(process.execPath) + '" "' + posix(guard) + '" "$@"; }\nexport -f git\n');
57
+ const viaBash = spawnSync(bash, ['-c', 'git commit -m x'], { cwd: repo, env: { ...env('readonly'), BASH_ENV: posix(bashEnv) }, encoding: 'utf8' });
58
+ t.check(viaBash.status === 128 && viaBash.stderr.includes('read-only'), 'readonly: git inside Git Bash goes through the guard too (BASH_ENV)', 'exit ' + viaBash.status + ' ' + viaBash.stderr);
59
+ },
60
+ };
@@ -0,0 +1,2 @@
1
+ import type { Suite } from './suite.js';
2
+ export declare const liveProjectsSuite: Suite;
@@ -0,0 +1,127 @@
1
+ // Live: the real session daemon + ptyhost with two projects at once. The launch folder (A) and a project in a root
2
+ // (B) each get two sessions requested at the same moment; each runs in its own folder with its own environment; B is
3
+ // set up once for both; viewers attach, detach and reattach, two at a time; exec and restart follow the project.
4
+ // Toolchain installers run as a dry run (no downloads); everything else is real. cmd.exe stands in for the agent.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { projectSpec } from '../host/join.js';
8
+ import { resizeFrame } from '../shared/pty-client.js';
9
+ import { readJsonFile } from '../shared/rundir.js';
10
+ import { copyCmdAsFakeAgent, openViewer, sees, startDaemon, tail, viewersWhen } from './live.js';
11
+ export const liveProjectsSuite = {
12
+ name: 'live-projects',
13
+ title: 'live: two projects, several sessions, several viewers',
14
+ live: true,
15
+ timeoutMs: 480000,
16
+ run: async (t, f) => {
17
+ const gen = await f.launch();
18
+ const data = t.at('live-multi-data');
19
+ const root = t.dir('live-root');
20
+ const projB = t.dir('live-root', 'projB');
21
+ copyCmdAsFakeAgent(data);
22
+ const session = f.readJson(gen.sessionPath);
23
+ // B's root appears at its own path here (like guestWorkspace "mirror"), so host and guest paths are the same.
24
+ Object.assign(session, { workspace: f.workA, attach: { enabled: true }, projectsDir: t.at('live-projects'), roots: [root], projectId: 'work-a' });
25
+ Object.assign(session.harness, { arguments: ['/d', '/k', 'echo AGENT-READY'], resumeArguments: ['/d', '/k', 'echo AGENT-RESUMED'] });
26
+ t.writeJson(gen.sessionPath, session);
27
+ const local = t.at('live-multi-local');
28
+ const daemon = startDaemon(t, f, { launchPath: gen.launchPath, data, workspace: f.workA, env: { AGENT_LOCAL_DIR: local, AGENT_DRY_RUN: '1', AGENT_MANAGED_DIR: t.at('live-managed') } });
29
+ const viewers = [];
30
+ const view = async (attach, cols = 100) => {
31
+ const v = await openViewer(attach, { cols });
32
+ viewers.push(v);
33
+ return v;
34
+ };
35
+ try {
36
+ t.section('live: four sessions at once');
37
+ const cfg = structuredClone(f.baseConfig);
38
+ cfg.toolchains.environment = { LIVE_B: 'b-env' };
39
+ const spec = projectSpec(await f.run({ cwd: projB, trustConfig: true }, cfg), { name: 'projB', host: projB, guest: projB, primary: false, mechanism: 'root', registeredAt: '' }, false);
40
+ // All four at the same moment: two in the launch folder, two in B (whose setup they share).
41
+ daemon.request({ kind: 'session', id: 'a1', harness: 'claude', args: [], cwd: f.workA });
42
+ daemon.request({ kind: 'session', id: 'b1', harness: 'claude', args: [], cwd: projB, project: spec });
43
+ daemon.request({ kind: 'session', id: 'b2', harness: 'claude', args: [], cwd: projB, project: spec });
44
+ daemon.request({ kind: 'session', id: 'a2', harness: 'claude', args: [], cwd: f.workA });
45
+ const [a1, a2, b1, b2] = await Promise.all(['a1', 'a2', 'b1', 'b2'].map((id) => daemon.started(id)));
46
+ const all = [a1, a2, b1, b2];
47
+ const notRunning = all.filter((s) => s?.phase !== 'running' || !s.attach);
48
+ t.check(notRunning.length === 0, 'four sessions requested at once all start (two per project)', notRunning.map((s) => s?.id + ': ' + s?.phase + ' ' + s?.message).join('\n') + '\n' + daemon.diagnostics());
49
+ if (notRunning.length > 0)
50
+ return;
51
+ t.equal([a1.project, b1.project, b2.project], ['work-a', 'projB', 'projB'], 'each session knows its project');
52
+ const log = fs.readdirSync(path.join(data, 'logs')).filter((n) => n.startsWith('daemon')).map((n) => fs.readFileSync(path.join(data, 'logs', n), 'utf8')).join('\n');
53
+ t.equal((log.match(/Project projB: setting up/g) ?? []).length, 1, 'two sessions for the same new project at once share one setup (no second provisioning run)');
54
+ t.equal(readJsonFile(path.join(daemon.run, 'projects', 'projB.json'))?.phase, 'ready', 'the project\'s setup status is written for the host (sandbox status)');
55
+ t.section('live: each session has its own folder and environment');
56
+ const views = await Promise.all(all.map((s) => view(s.attach)));
57
+ for (const v of views)
58
+ await sees(t, v, 'AGENT-READY');
59
+ for (const v of views)
60
+ v.type('echo P=%AGENT_PROJECT% W=%AGENT_WORKSPACE% B=[%LIVE_B%]');
61
+ const bSees = await sees(t, views[2], 'P=projB W=' + projB + ' B=[b-env]');
62
+ t.check(Boolean(bSees), 'B\'s first session runs with B\'s workspace, project and variables', tail(views[2].output()));
63
+ t.check(Boolean(await sees(t, views[3], 'B=[b-env]')), 'B\'s second session runs with B\'s variables', tail(views[3].output()));
64
+ // (The daemon here runs without the boot's environment, so A's own AGENT_* variables are not set at all.)
65
+ t.check(Boolean(await sees(t, views[0], 'B=[%LIVE_B%]')), 'A\'s first session is untouched by B (B\'s variables never reach it)', tail(views[0].output()));
66
+ t.check(Boolean(await sees(t, views[1], 'B=[%LIVE_B%]')), 'A\'s second session is untouched by B', tail(views[1].output()));
67
+ views[2].type('echo CWD=%CD%.');
68
+ t.check(Boolean(await sees(t, views[2], 'CWD=' + projB + '.')), 'B\'s session starts in B\'s folder', tail(views[2].output()));
69
+ t.section('live: several viewers on one session');
70
+ const second = await view(b1.attach, 60);
71
+ const two = await viewersWhen(t, b1.attach, (v) => v.viewers === 2);
72
+ t.check(two !== null, 'a second viewer attaches to the same session');
73
+ second.socket.write(resizeFrame(90, 25));
74
+ views[2].type('echo TWO-VIEWERS-OK');
75
+ t.check(Boolean(await sees(t, views[2], 'TWO-VIEWERS-OK')), 'the first viewer sees what it typed (after the second one resized)', tail(views[2].output()));
76
+ t.check(Boolean(await sees(t, second, 'TWO-VIEWERS-OK')), 'the second viewer sees it too', tail(second.output()));
77
+ second.type('echo FROM-SECOND');
78
+ t.check(Boolean(await sees(t, views[2], 'FROM-SECOND')), 'input from the second viewer reaches the agent and the first viewer', tail(views[2].output()));
79
+ views[2].close();
80
+ second.close();
81
+ const none = await viewersWhen(t, b1.attach, (v) => v.viewers === 0);
82
+ t.check(none !== null, 'closing every viewer is noticed');
83
+ t.equal(daemon.status('b1')?.phase, 'running', 'closing every viewer never ends the session');
84
+ for (let round = 1; round <= 3; round++) {
85
+ const again = await view(b1.attach);
86
+ again.type('echo REATTACHED-' + round);
87
+ t.check(Boolean(await sees(t, again, 'REATTACHED-' + round)), 'reattach ' + round + ': a new viewer works on the same session', tail(again.output()));
88
+ again.close();
89
+ }
90
+ t.section('live: exec, restart and refusals follow the projects');
91
+ daemon.request({ kind: 'exec', id: 'xb', command: ['echo', '%LIVE_B%'], cwd: projB, timeoutSeconds: 30 });
92
+ daemon.request({ kind: 'exec', id: 'xa', command: ['echo', '[%LIVE_B%]'], cwd: f.workA, timeoutSeconds: 30 });
93
+ const [xb, xa] = await Promise.all([daemon.response('xb'), daemon.response('xa')]);
94
+ t.check(Boolean(xb?.stdout?.includes('b-env')), 'exec in B\'s folder gets B\'s environment', JSON.stringify(xb));
95
+ t.check(Boolean(xa?.stdout?.includes('[%LIVE_B%]')), 'exec in A\'s folder gets A\'s', JSON.stringify(xa));
96
+ daemon.request({ kind: 'session', id: 'bad1', harness: 'claude', args: [], cwd: f.workB, project: { ...spec, name: 'outside', workspace: f.workB } });
97
+ const bad = await daemon.started('bad1');
98
+ t.check(bad?.phase === 'failed' && bad.message.includes('not visible inside the sandbox'), 'a project outside the launch folder, the roots and C:\\Projects is refused', JSON.stringify(bad));
99
+ daemon.request({ kind: 'session', id: 'bad2', harness: 'claude', args: [], cwd: f.workA, project: spec });
100
+ const claimed = await daemon.started('bad2');
101
+ t.includes(claimed?.message ?? '', 'not inside project projB', 'a session cannot claim a project its folder is not in');
102
+ daemon.request({ kind: 'restart', id: 'rb', harness: null, cwd: projB });
103
+ t.equal((await daemon.response('rb'))?.message, 'restarted 2 session(s)', 'restart scoped to a project restarts only its sessions');
104
+ t.check(fs.existsSync(path.join(local, 'projects', 'projB', 'shell.cmd')), 'sbx-shell projB has a shell script');
105
+ t.includes(fs.readFileSync(path.join(local, 'projects', 'projB', 'env.cmd'), 'utf8'), 'LIVE_B=b-env', 'sbx-shell projB opens with B\'s environment');
106
+ t.check(fs.existsSync(path.join(local, 'bin', 'sbx-shell.cmd')), 'sbx-shell is on the sandbox\'s PATH');
107
+ }
108
+ finally {
109
+ for (const v of viewers)
110
+ v.close();
111
+ // End every agent: a fresh viewer types exit into each still-running session.
112
+ for (const id of ['a1', 'a2', 'b1', 'b2']) {
113
+ const s = daemon.status(id);
114
+ if (s?.phase !== 'running' || !s.attach)
115
+ continue;
116
+ try {
117
+ const v = await openViewer(s.attach);
118
+ v.type('exit');
119
+ await t.waitFor(() => v.exitCode() !== null, 10000);
120
+ v.close();
121
+ }
122
+ catch { /* already gone */ }
123
+ }
124
+ await daemon.stop();
125
+ }
126
+ },
127
+ };
@@ -0,0 +1,65 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ import type net from 'node:net';
3
+ import type { GuestResponse, SessionStatus } from '../core/types.js';
4
+ import { type Endpoint, type Viewers } from '../shared/pty-client.js';
5
+ import type { Fixtures } from './fixtures.js';
6
+ import type { Harness } from './harness.js';
7
+ export declare const CSC: string;
8
+ /** Live suites need Windows (ConPTY) and the .NET Framework compiler that ships with it. */
9
+ export declare const liveTestsAvailable: () => boolean;
10
+ /** Deadlines, in ms. Generous on purpose: they only matter when something is really wrong. */
11
+ export declare const WAIT: {
12
+ readonly start: 120000;
13
+ readonly screen: 45000;
14
+ readonly response: 60000;
15
+ readonly viewers: 45000;
16
+ readonly exit: 45000;
17
+ };
18
+ export declare const tail: (text: string, chars?: number) => string;
19
+ /** Connects, retrying until `ms`: a connection attempt can time out while the machine is busy. */
20
+ export declare const connectPatiently: (endpoint: Endpoint, ms?: number) => Promise<net.Socket>;
21
+ /** One connection to a session's ptyhost, collecting what the server sends. */
22
+ export interface Viewer {
23
+ socket: net.Socket;
24
+ /** Everything the agent has printed since this viewer connected. */
25
+ output: () => string;
26
+ /** The exit code frame, once the agent ended. */
27
+ exitCode: () => number | null;
28
+ /** The error frame ("access denied"), if the server sent one. */
29
+ error: () => string;
30
+ /** True once the server closed the connection. */
31
+ closed: () => boolean;
32
+ type: (text: string) => void;
33
+ close: () => void;
34
+ }
35
+ /** Connects like a terminal tab would (`token` can be wrong on purpose) and says hello. */
36
+ export declare const openViewer: (attach: Endpoint, options?: {
37
+ token?: string;
38
+ cols?: number;
39
+ rows?: number;
40
+ }) => Promise<Viewer>;
41
+ /** Polls until the viewer's screen shows `needle`. */
42
+ export declare const sees: (t: Harness, viewer: Viewer, needle: string, ms?: number) => Promise<true | null>;
43
+ /** Polls the session's viewer count until `accept` is satisfied; null when it never was. A query that gets no answer is retried. */
44
+ export declare const viewersWhen: (t: Harness, attach: Endpoint, accept: (v: Viewers) => boolean, ms?: number) => Promise<Viewers | null>;
45
+ export declare const copyCmdAsFakeAgent: (dataPath: string) => void;
46
+ /** A real session daemon (guest runtime) working on a generated launch folder, driven through the run folder like the host does. */
47
+ export interface LiveDaemon {
48
+ run: string;
49
+ child: ChildProcess;
50
+ request: (body: Record<string, unknown>) => void;
51
+ response: (id: string, ms?: number) => Promise<GuestResponse | null>;
52
+ status: (id: string) => SessionStatus | null;
53
+ /** Waits until a session has left "starting". */
54
+ started: (id: string, ms?: number) => Promise<SessionStatus | null>;
55
+ /** The daemon's stdout/stderr and its log files, for failure messages. */
56
+ diagnostics: () => string;
57
+ /** Ends the daemon. */
58
+ stop: () => Promise<void>;
59
+ }
60
+ export declare const startDaemon: (t: Harness, f: Fixtures, options: {
61
+ launchPath: string;
62
+ data: string;
63
+ workspace: string;
64
+ env?: Record<string, string>;
65
+ }) => LiveDaemon;
@@ -0,0 +1,109 @@
1
+ // Helpers for the live suites: they run the real session daemon, ptyhost (ConPTY) and the compiled git shim on Windows.
2
+ //
3
+ // Timing rule: a live test never sleeps for a fixed time and never gives up early. It polls until the condition holds,
4
+ // with a generous deadline (a busy machine, a cold csc.exe or an antivirus scan can make any step 10x slower), and a
5
+ // failure prints what was seen (the viewer's screen, the daemon's log) so the cause is visible.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { spawn } from 'node:child_process';
9
+ import { ensureDir, sleep } from '../core/util.js';
10
+ import { connect, encodeFrame, FrameReader, FrameType, helloFrame, queryViewers } from '../shared/pty-client.js';
11
+ import { readJsonFile, writeJsonAtomic } from '../shared/rundir.js';
12
+ const WINDIR = process.env.WINDIR ?? 'C:\\Windows';
13
+ export const CSC = path.join(WINDIR, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe');
14
+ /** Live suites need Windows (ConPTY) and the .NET Framework compiler that ships with it. */
15
+ export const liveTestsAvailable = () => process.platform === 'win32' && fs.existsSync(CSC);
16
+ /** Deadlines, in ms. Generous on purpose: they only matter when something is really wrong. */
17
+ export const WAIT = { start: 120000, screen: 45000, response: 60000, viewers: 45000, exit: 45000 };
18
+ export const tail = (text, chars = 500) => (text.length > chars ? '...' + text.slice(-chars) : text).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
19
+ /** Connects, retrying until `ms`: a connection attempt can time out while the machine is busy. */
20
+ export const connectPatiently = async (endpoint, ms = 60000) => {
21
+ const end = Date.now() + ms;
22
+ for (;;) {
23
+ try {
24
+ return await connect(endpoint, 10000);
25
+ }
26
+ catch (e) {
27
+ if (Date.now() >= end)
28
+ throw e;
29
+ await sleep(250);
30
+ }
31
+ }
32
+ };
33
+ /** Connects like a terminal tab would (`token` can be wrong on purpose) and says hello. */
34
+ export const openViewer = async (attach, options = {}) => {
35
+ const socket = await connectPatiently({ ...attach, addresses: ['127.0.0.1'] });
36
+ const reader = new FrameReader();
37
+ let output = '';
38
+ let exitCode = null;
39
+ let error = '';
40
+ let closed = false;
41
+ socket.on('data', (chunk) => {
42
+ for (const frame of reader.push(chunk)) {
43
+ if (frame.type === FrameType.Output)
44
+ output += frame.payload.toString();
45
+ else if (frame.type === FrameType.Exit)
46
+ exitCode = frame.payload.readInt32LE(0);
47
+ else if (frame.type === FrameType.Error)
48
+ error = frame.payload.toString();
49
+ }
50
+ });
51
+ socket.on('error', () => undefined);
52
+ socket.on('close', () => { closed = true; });
53
+ socket.write(helloFrame(options.token ?? attach.token, options.cols ?? 100, options.rows ?? 30));
54
+ return {
55
+ socket,
56
+ output: () => output,
57
+ exitCode: () => exitCode,
58
+ error: () => error,
59
+ closed: () => closed,
60
+ type: (text) => { socket.write(encodeFrame(FrameType.Input, Buffer.from(text + '\r'))); },
61
+ close: () => socket.destroy(),
62
+ };
63
+ };
64
+ /** Polls until the viewer's screen shows `needle`. */
65
+ export const sees = (t, viewer, needle, ms = WAIT.screen) => t.waitFor(() => viewer.output().includes(needle), ms);
66
+ /** Polls the session's viewer count until `accept` is satisfied; null when it never was. A query that gets no answer is retried. */
67
+ export const viewersWhen = (t, attach, accept, ms = WAIT.viewers) => t.waitFor(async () => {
68
+ const v = await queryViewers({ ...attach, addresses: ['127.0.0.1'] }, 5000);
69
+ return v && accept(v) ? v : null;
70
+ }, ms);
71
+ export const copyCmdAsFakeAgent = (dataPath) => {
72
+ const agentDir = path.join(dataPath, 'tools', 'agents', 'claude');
73
+ ensureDir(agentDir);
74
+ fs.copyFileSync(path.join(WINDIR, 'System32', 'cmd.exe'), path.join(agentDir, 'claude.exe'));
75
+ };
76
+ export const startDaemon = (t, f, options) => {
77
+ const { launchPath, data } = options;
78
+ const output = t.at(path.basename(data) + '-daemon.out');
79
+ const fd = fs.openSync(output, 'w');
80
+ const env = { ...process.env, AGENT_LAUNCH_DIR: launchPath, AGENT_DATA_DIR: data, AGENT_WORKSPACE: options.workspace, AGENT_LOCAL_DIR: f.at(path.basename(data) + '-local'), AGENT_LOG_PREFIX: 'daemon', AGENT_NO_TABS: '1', ...options.env };
81
+ const child = spawn(process.execPath, [path.join(launchPath, 'runtime', 'daemon.js')], { env, stdio: ['ignore', fd, fd], windowsHide: true });
82
+ let exited = false;
83
+ child.on('exit', () => { exited = true; });
84
+ const run = path.join(data, 'run');
85
+ const status = (id) => readJsonFile(path.join(run, 'sessions', id + '.json'));
86
+ return {
87
+ run,
88
+ child,
89
+ request: (body) => writeJsonAtomic(path.join(run, 'requests', body.id + '.json'), { createdAt: new Date().toISOString(), ...body }),
90
+ response: (id, ms = WAIT.response) => t.waitFor(() => readJsonFile(path.join(run, 'responses', id + '.json')), ms),
91
+ status,
92
+ started: (id, ms = WAIT.start) => t.waitFor(() => { const s = status(id); return s && s.phase !== 'starting' ? s : null; }, ms),
93
+ diagnostics: () => {
94
+ const logs = path.join(data, 'logs');
95
+ const files = fs.existsSync(logs) ? fs.readdirSync(logs).filter((n) => n.startsWith('daemon')) : [];
96
+ const logText = files.map((n) => fs.readFileSync(path.join(logs, n), 'utf8')).join('\n');
97
+ return 'daemon output: ' + tail(fs.readFileSync(output, 'utf8'), 600) + '\ndaemon log: ' + tail(logText, 1200);
98
+ },
99
+ stop: async () => {
100
+ if (!exited)
101
+ child.kill();
102
+ await t.waitFor(() => exited, 10000);
103
+ try {
104
+ fs.closeSync(fd);
105
+ }
106
+ catch { /* already closed */ }
107
+ },
108
+ };
109
+ };
@@ -0,0 +1,2 @@
1
+ import type { Suite } from './suite.js';
2
+ export declare const localConfigSuite: Suite;