clawmini 0.0.7 → 0.0.9

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 (367) hide show
  1. package/.changeset/README.md +8 -0
  2. package/.changeset/config.json +14 -0
  3. package/.github/workflows/release.yml +49 -0
  4. package/CHANGELOG.md +36 -0
  5. package/README.md +5 -4
  6. package/dist/adapter-discord/index.d.mts.map +1 -1
  7. package/dist/adapter-discord/index.mjs +465 -282
  8. package/dist/adapter-discord/index.mjs.map +1 -1
  9. package/dist/adapter-google-chat/index.mjs +367 -243
  10. package/dist/adapter-google-chat/index.mjs.map +1 -1
  11. package/dist/cli/index.mjs +684 -24
  12. package/dist/cli/index.mjs.map +1 -1
  13. package/dist/cli/lite.mjs +43 -13
  14. package/dist/cli/lite.mjs.map +1 -1
  15. package/dist/cli/{propose-policy.mjs → manage-policies.mjs} +270 -47
  16. package/dist/cli/manage-policies.mjs.map +1 -0
  17. package/dist/cli/run-host.d.mts +1 -0
  18. package/dist/cli/run-host.mjs +3090 -0
  19. package/dist/cli/run-host.mjs.map +1 -0
  20. package/dist/config-CPFQIGdG.mjs +57 -0
  21. package/dist/config-CPFQIGdG.mjs.map +1 -0
  22. package/dist/config-Dvl-Pov4.mjs +76 -0
  23. package/dist/config-Dvl-Pov4.mjs.map +1 -0
  24. package/dist/daemon/index.d.mts.map +1 -1
  25. package/dist/daemon/index.mjs +970 -332
  26. package/dist/daemon/index.mjs.map +1 -1
  27. package/dist/supervisor-actions-CiW56eLi.mjs +843 -0
  28. package/dist/supervisor-actions-CiW56eLi.mjs.map +1 -0
  29. package/dist/turn-log-buffer-DRgW53gl.mjs +767 -0
  30. package/dist/turn-log-buffer-DRgW53gl.mjs.map +1 -0
  31. package/dist/web/_app/immutable/chunks/{Drm9vgeP.js → 3AZlWB6U.js} +1 -1
  32. package/dist/web/_app/immutable/chunks/BhRSsUCh.js +2 -0
  33. package/dist/web/_app/immutable/chunks/BiLeM2i1.js +1 -0
  34. package/{web/.svelte-kit/output/client/_app/immutable/chunks/CME08kGM.js → dist/web/_app/immutable/chunks/BmBj85Ll.js} +1 -1
  35. package/dist/web/_app/immutable/chunks/BrERcKAH.js +1 -0
  36. package/dist/web/_app/immutable/chunks/Bv9252RM.js +1 -0
  37. package/dist/web/_app/immutable/chunks/CIXNBPKi.js +1 -0
  38. package/dist/web/_app/immutable/chunks/DISKL3GN.js +2 -0
  39. package/dist/web/_app/immutable/chunks/{Zeh-C-mx.js → DcpaLzmX.js} +1 -1
  40. package/dist/web/_app/immutable/chunks/DnQ3vS13.js +1 -0
  41. package/dist/web/_app/immutable/chunks/KsloHTKS.js +1 -0
  42. package/{web/.svelte-kit/output/client/_app/immutable/chunks/Ck-be5J2.js → dist/web/_app/immutable/chunks/RsHsUj-8.js} +2 -2
  43. package/dist/web/_app/immutable/chunks/{G_zz-Gou.js → wpfV79dV.js} +1 -1
  44. package/dist/web/_app/immutable/entry/app.CIw1Qj0n.js +2 -0
  45. package/dist/web/_app/immutable/entry/start.Di0-Jhte.js +1 -0
  46. package/dist/web/_app/immutable/nodes/{0.CYS8iApT.js → 0.DYyUA1au.js} +1 -1
  47. package/dist/web/_app/immutable/nodes/1.D-3QEMMZ.js +1 -0
  48. package/dist/web/_app/immutable/nodes/{2.BnwnD1Ki.js → 2.4olHnH7U.js} +1 -1
  49. package/{web/.svelte-kit/output/client/_app/immutable/nodes/3.Dr0ot9sV.js → dist/web/_app/immutable/nodes/3.4w0bE-m2.js} +3 -3
  50. package/dist/web/_app/immutable/nodes/4.CZvjhVHt.js +60 -0
  51. package/dist/web/_app/immutable/nodes/{5.BBGQ_i84.js → 5.DLbPVJY2.js} +1 -1
  52. package/dist/web/_app/version.json +1 -1
  53. package/dist/web/index.html +12 -12
  54. package/dist/workspace-oWmVh5mi.mjs +1001 -0
  55. package/dist/workspace-oWmVh5mi.mjs.map +1 -0
  56. package/docs/23_adapter_slash_autocomplete/development_log.md +19 -0
  57. package/docs/23_adapter_slash_autocomplete/notes.md +18 -0
  58. package/docs/23_adapter_slash_autocomplete/prd.md +46 -0
  59. package/docs/23_adapter_slash_autocomplete/questions.md +6 -0
  60. package/docs/23_adapter_slash_autocomplete/tickets.md +21 -0
  61. package/docs/24_subagent_job_policy_fixes/development_log.md +22 -0
  62. package/docs/24_subagent_job_policy_fixes/notes.md +28 -0
  63. package/docs/24_subagent_job_policy_fixes/prd.md +59 -0
  64. package/docs/24_subagent_job_policy_fixes/questions.md +3 -0
  65. package/docs/24_subagent_job_policy_fixes/tickets.md +49 -0
  66. package/docs/25_e2e_test_improvements/development_log.md +30 -0
  67. package/docs/25_e2e_test_improvements/notes.md +29 -0
  68. package/docs/25_e2e_test_improvements/prd.md +43 -0
  69. package/docs/25_e2e_test_improvements/questions.md +12 -0
  70. package/docs/25_e2e_test_improvements/tickets-2.md +22 -0
  71. package/docs/25_e2e_test_improvements/tickets.md +22 -0
  72. package/docs/25_policy_cwd/development_log.md +30 -0
  73. package/docs/25_policy_cwd/notes.md +28 -0
  74. package/docs/25_policy_cwd/prd.md +77 -0
  75. package/docs/25_policy_cwd/questions.md +6 -0
  76. package/docs/25_policy_cwd/tickets.md +77 -0
  77. package/docs/CLI_REFERENCE.md +3 -1
  78. package/docs/PHILOSOPHY.md +35 -0
  79. package/docs/adapter-visibility/SPEC.md +461 -0
  80. package/docs/adapter-visibility/SPEC_v2.md +202 -0
  81. package/docs/auto-update/SPEC.md +344 -0
  82. package/docs/backups/SPEC.md +296 -0
  83. package/docs/backups/clawmini.gitignore +69 -0
  84. package/docs/guides/assets/clawmini-avatar.png +0 -0
  85. package/docs/guides/backups.md +332 -0
  86. package/docs/guides/discord_adapter_setup.md +1 -1
  87. package/docs/guides/google_chat_adapter_setup.md +81 -0
  88. package/docs/unified-startup/SPEC.md +203 -0
  89. package/e2e/_helpers/test-environment.test.ts +49 -0
  90. package/e2e/_helpers/test-environment.ts +548 -0
  91. package/e2e/adapters/_google-chat-fixtures.ts +340 -0
  92. package/{src/cli/e2e → e2e/adapters}/adapter-discord.test.ts +22 -23
  93. package/e2e/adapters/adapter-google-chat-downtime.test.ts +157 -0
  94. package/e2e/adapters/adapter-google-chat-inbound.test.ts +697 -0
  95. package/e2e/adapters/adapter-google-chat-outbound.test.ts +297 -0
  96. package/e2e/adapters/adapter-google-chat-roundtrip.test.ts +56 -0
  97. package/e2e/adapters/adapter-google-chat-threads.test.ts +1078 -0
  98. package/e2e/agents/custom-api-env.test.ts +80 -0
  99. package/e2e/agents/export-lite-func.test.ts +104 -0
  100. package/e2e/agents/fallbacks.test.ts +124 -0
  101. package/e2e/agents/interrupt.test.ts +50 -0
  102. package/e2e/agents/no-reply-necessary.test.ts +57 -0
  103. package/e2e/agents/session-timeout-subagents.test.ts +76 -0
  104. package/e2e/agents/subagent-authorization.test.ts +246 -0
  105. package/e2e/agents/subagent-env.test.ts +49 -0
  106. package/e2e/agents/subagent-lifecycle.test.ts +782 -0
  107. package/e2e/agents/subagents-depth.test.ts +47 -0
  108. package/e2e/cli/agents.test.ts +176 -0
  109. package/e2e/cli/auto-update.test.ts +741 -0
  110. package/e2e/cli/basic.test.ts +44 -0
  111. package/{src/cli/e2e → e2e/cli}/export-lite.test.ts +16 -12
  112. package/e2e/cli/init-gitignore.test.ts +86 -0
  113. package/e2e/cli/init.test.ts +76 -0
  114. package/e2e/cli/messages.test.ts +363 -0
  115. package/e2e/cli/serve.test.ts +76 -0
  116. package/{src/cli/e2e → e2e/cli}/skills.test.ts +11 -10
  117. package/{src/cli/e2e → e2e/daemon}/daemon.test.ts +57 -195
  118. package/e2e/jobs/agent-jobs.test.ts +216 -0
  119. package/e2e/jobs/cron.test.ts +64 -0
  120. package/e2e/jobs/restart.test.ts +108 -0
  121. package/e2e/policies/approval-session.test.ts +69 -0
  122. package/e2e/policies/auto-create-policies-file.test.ts +35 -0
  123. package/e2e/policies/builtin-manage-policies.test.ts +184 -0
  124. package/e2e/policies/builtin-run-host.test.ts +180 -0
  125. package/e2e/policies/environment-policies.test.ts +177 -0
  126. package/e2e/policies/manage-policies.test.ts +566 -0
  127. package/e2e/policies/output-size.test.ts +98 -0
  128. package/e2e/policies/policies-context-cwd.test.ts +160 -0
  129. package/e2e/policies/relative-script-path.test.ts +60 -0
  130. package/e2e/policies/requests-show.test.ts +135 -0
  131. package/e2e/policies/requests.test.ts +208 -0
  132. package/e2e/policies/slash-policies.test.ts +308 -0
  133. package/e2e/policies/startup-cleanup.test.ts +48 -0
  134. package/e2e/routers/session-timeout.test.ts +106 -0
  135. package/e2e/routers/slash-model.test.ts +152 -0
  136. package/e2e/routers/slash-new.test.ts +50 -0
  137. package/e2e/routers/slash-restart-adapter.test.ts +96 -0
  138. package/e2e/routers/slash-restart.test.ts +114 -0
  139. package/e2e/routers/slash-shutdown.test.ts +55 -0
  140. package/e2e/routers/slash-stop.test.ts +232 -0
  141. package/e2e/routers/slash-upgrade.test.ts +88 -0
  142. package/{src/cli/e2e → e2e/sandbox}/environments.test.ts +14 -13
  143. package/eslint.config.js +6 -0
  144. package/napkin.md +1 -1
  145. package/package.json +8 -3
  146. package/src/adapter-discord/commands.test.ts +42 -0
  147. package/src/adapter-discord/commands.ts +33 -0
  148. package/src/adapter-discord/config.ts +12 -0
  149. package/src/adapter-discord/forwarder.test.ts +499 -21
  150. package/src/adapter-discord/forwarder.ts +343 -124
  151. package/src/adapter-discord/inbound-cache.test.ts +47 -0
  152. package/src/adapter-discord/inbound-cache.ts +37 -0
  153. package/src/adapter-discord/index.test.ts +67 -2
  154. package/src/adapter-discord/index.ts +84 -216
  155. package/src/adapter-discord/interactions.test.ts +54 -3
  156. package/src/adapter-discord/interactions.ts +97 -53
  157. package/src/adapter-discord/processMessage.ts +239 -0
  158. package/src/adapter-discord/state.ts +1 -0
  159. package/src/adapter-google-chat/auth.test.ts +9 -5
  160. package/src/adapter-google-chat/auth.ts +29 -23
  161. package/src/adapter-google-chat/cards.ts +7 -2
  162. package/src/adapter-google-chat/client.test.ts +37 -2
  163. package/src/adapter-google-chat/client.ts +138 -38
  164. package/src/adapter-google-chat/config.ts +19 -0
  165. package/src/adapter-google-chat/forwarder.test.ts +81 -56
  166. package/src/adapter-google-chat/forwarder.ts +394 -185
  167. package/src/adapter-google-chat/inbound-cache.test.ts +61 -0
  168. package/src/adapter-google-chat/inbound-cache.ts +36 -0
  169. package/src/adapter-google-chat/state.test.ts +1 -0
  170. package/src/adapter-google-chat/state.ts +9 -1
  171. package/src/adapter-google-chat/subscriptions.ts +8 -6
  172. package/src/cli/builtin-policies.ts +44 -0
  173. package/src/cli/commands/agents.ts +59 -5
  174. package/src/cli/commands/down.ts +54 -2
  175. package/src/cli/commands/environments.ts +8 -2
  176. package/src/cli/commands/init.ts +31 -0
  177. package/src/cli/commands/logs.ts +116 -0
  178. package/src/cli/commands/policies.ts +6 -4
  179. package/src/cli/commands/serve.test.ts +67 -0
  180. package/src/cli/commands/serve.ts +284 -0
  181. package/src/cli/commands/up.ts +122 -2
  182. package/src/cli/commands/web-api/agents.ts +3 -2
  183. package/src/cli/index.ts +4 -0
  184. package/src/cli/install-detection.test.ts +72 -0
  185. package/src/cli/install-detection.ts +48 -0
  186. package/src/cli/lite.ts +54 -22
  187. package/src/cli/manage-policies-utils.ts +104 -0
  188. package/src/cli/manage-policies.ts +291 -0
  189. package/src/cli/run-host.ts +45 -0
  190. package/src/cli/supervisor-actions.ts +267 -0
  191. package/src/cli/supervisor-control.test.ts +129 -0
  192. package/src/cli/supervisor-control.ts +155 -0
  193. package/src/cli/supervisor-pid.ts +68 -0
  194. package/src/cli/supervisor.ts +277 -0
  195. package/src/daemon/agent/agent-context.ts +11 -11
  196. package/src/daemon/agent/agent-session.ts +8 -1
  197. package/src/daemon/agent/chat-logger.test.ts +78 -9
  198. package/src/daemon/agent/chat-logger.ts +25 -5
  199. package/src/daemon/agent/turn-registry.test.ts +89 -0
  200. package/src/daemon/agent/turn-registry.ts +94 -0
  201. package/src/daemon/agent/types.ts +2 -0
  202. package/src/daemon/api/agent-policy-endpoints.ts +263 -0
  203. package/src/daemon/api/agent-router.ts +47 -126
  204. package/src/daemon/api/index.test.ts +1 -0
  205. package/src/daemon/api/policy-request.test.ts +7 -5
  206. package/src/daemon/api/router-utils.ts +6 -5
  207. package/src/daemon/api/subagent-router.ts +110 -74
  208. package/src/daemon/api/subagent-utils.test.ts +60 -0
  209. package/src/daemon/api/subagent-utils.ts +113 -87
  210. package/src/daemon/api/user-router.ts +34 -8
  211. package/src/daemon/auth.ts +1 -0
  212. package/src/daemon/cron.test.ts +62 -4
  213. package/src/daemon/cron.ts +42 -16
  214. package/src/daemon/events.ts +65 -0
  215. package/src/daemon/index.ts +24 -1
  216. package/src/daemon/message-interruption.test.ts +1 -0
  217. package/src/daemon/message-jobs.test.ts +1 -0
  218. package/src/daemon/message.ts +78 -14
  219. package/src/daemon/observation.test.ts +26 -18
  220. package/src/daemon/pending-replies.test.ts +112 -0
  221. package/src/daemon/pending-replies.ts +162 -0
  222. package/src/daemon/policy-request-service.ts +3 -1
  223. package/src/daemon/policy-utils.test.ts +66 -1
  224. package/src/daemon/policy-utils.ts +126 -1
  225. package/src/daemon/request-store.ts +31 -0
  226. package/src/daemon/routers/session-timeout.ts +4 -0
  227. package/src/daemon/routers/slash-model.test.ts +344 -0
  228. package/src/daemon/routers/slash-model.ts +207 -0
  229. package/src/daemon/routers/slash-policies.test.ts +38 -32
  230. package/src/daemon/routers/slash-policies.ts +84 -33
  231. package/src/daemon/routers/slash-restart.test.ts +69 -0
  232. package/src/daemon/routers/slash-restart.ts +36 -0
  233. package/src/daemon/routers/slash-shutdown.test.ts +50 -0
  234. package/src/daemon/routers/slash-shutdown.ts +28 -0
  235. package/src/daemon/routers/slash-upgrade.test.ts +116 -0
  236. package/src/daemon/routers/slash-upgrade.ts +76 -0
  237. package/src/daemon/routers/types.ts +7 -0
  238. package/src/daemon/routers.ts +16 -0
  239. package/src/shared/adapters/blockquote.test.ts +28 -0
  240. package/src/shared/adapters/blockquote.ts +20 -0
  241. package/src/shared/adapters/filtering.test.ts +224 -10
  242. package/src/shared/adapters/filtering.ts +95 -7
  243. package/src/shared/adapters/inbound-cache.test.ts +48 -0
  244. package/src/shared/adapters/inbound-cache.ts +54 -0
  245. package/src/shared/adapters/turn-log-buffer.ts +266 -0
  246. package/src/shared/adapters/turn-log.test.ts +389 -0
  247. package/src/shared/adapters/turn-log.ts +357 -0
  248. package/src/shared/agent-utils.ts +12 -5
  249. package/src/shared/chats.test.ts +4 -0
  250. package/src/shared/chats.ts +9 -0
  251. package/src/shared/config.ts +16 -1
  252. package/src/shared/lite.ts +76 -2
  253. package/src/shared/policies.ts +26 -0
  254. package/src/shared/template-manifest.ts +267 -0
  255. package/src/shared/utils/shell.ts +61 -0
  256. package/src/shared/version.ts +34 -0
  257. package/src/shared/workspace.test.ts +217 -0
  258. package/src/shared/workspace.ts +626 -48
  259. package/templates/environments/cladding/allowlist-domain.mjs +125 -0
  260. package/templates/environments/cladding/env.json +21 -1
  261. package/templates/environments/cladding/run-with-network.mjs +54 -0
  262. package/templates/environments/macos-proxy/allowlist-domain.mjs +95 -0
  263. package/templates/environments/macos-proxy/env.json +8 -1
  264. package/templates/environments/macos-proxy/proxy.mjs +42 -13
  265. package/templates/gemini/template.json +5 -0
  266. package/templates/gemini-claw/template.json +13 -0
  267. package/templates/skills/clawmini-requests/SKILL.md +69 -10
  268. package/templates/skills/run-host/SKILL.md +51 -0
  269. package/templates/skills/skill-creator/SKILL.md +4 -3
  270. package/templates/skills/skill-creator/scripts/validate.sh +52 -0
  271. package/tsdown.config.ts +10 -1
  272. package/vitest.config.ts +2 -2
  273. package/web/.svelte-kit/ambient.d.ts +292 -176
  274. package/web/.svelte-kit/generated/server/internal.js +1 -1
  275. package/web/.svelte-kit/output/client/.vite/manifest.json +127 -137
  276. package/web/.svelte-kit/output/client/_app/immutable/chunks/{Drm9vgeP.js → 3AZlWB6U.js} +1 -1
  277. package/web/.svelte-kit/output/client/_app/immutable/chunks/BhRSsUCh.js +2 -0
  278. package/web/.svelte-kit/output/client/_app/immutable/chunks/BiLeM2i1.js +1 -0
  279. package/{dist/web/_app/immutable/chunks/CME08kGM.js → web/.svelte-kit/output/client/_app/immutable/chunks/BmBj85Ll.js} +1 -1
  280. package/web/.svelte-kit/output/client/_app/immutable/chunks/BrERcKAH.js +1 -0
  281. package/web/.svelte-kit/output/client/_app/immutable/chunks/Bv9252RM.js +1 -0
  282. package/web/.svelte-kit/output/client/_app/immutable/chunks/CIXNBPKi.js +1 -0
  283. package/web/.svelte-kit/output/client/_app/immutable/chunks/DISKL3GN.js +2 -0
  284. package/web/.svelte-kit/output/client/_app/immutable/chunks/{Zeh-C-mx.js → DcpaLzmX.js} +1 -1
  285. package/web/.svelte-kit/output/client/_app/immutable/chunks/DnQ3vS13.js +1 -0
  286. package/web/.svelte-kit/output/client/_app/immutable/chunks/KsloHTKS.js +1 -0
  287. package/{dist/web/_app/immutable/chunks/Ck-be5J2.js → web/.svelte-kit/output/client/_app/immutable/chunks/RsHsUj-8.js} +2 -2
  288. package/web/.svelte-kit/output/client/_app/immutable/chunks/{G_zz-Gou.js → wpfV79dV.js} +1 -1
  289. package/web/.svelte-kit/output/client/_app/immutable/entry/app.CIw1Qj0n.js +2 -0
  290. package/web/.svelte-kit/output/client/_app/immutable/entry/start.Di0-Jhte.js +1 -0
  291. package/web/.svelte-kit/output/client/_app/immutable/nodes/{0.CYS8iApT.js → 0.DYyUA1au.js} +1 -1
  292. package/web/.svelte-kit/output/client/_app/immutable/nodes/1.D-3QEMMZ.js +1 -0
  293. package/web/.svelte-kit/output/client/_app/immutable/nodes/{2.BnwnD1Ki.js → 2.4olHnH7U.js} +1 -1
  294. package/{dist/web/_app/immutable/nodes/3.Dr0ot9sV.js → web/.svelte-kit/output/client/_app/immutable/nodes/3.4w0bE-m2.js} +3 -3
  295. package/web/.svelte-kit/output/client/_app/immutable/nodes/4.CZvjhVHt.js +60 -0
  296. package/web/.svelte-kit/output/client/_app/immutable/nodes/{5.BBGQ_i84.js → 5.DLbPVJY2.js} +1 -1
  297. package/web/.svelte-kit/output/client/_app/version.json +1 -1
  298. package/web/.svelte-kit/output/server/.vite/manifest.json +12 -10
  299. package/web/.svelte-kit/output/server/chunks/Icon.js +1 -1
  300. package/web/.svelte-kit/output/server/chunks/client.js +1 -1
  301. package/web/.svelte-kit/output/server/chunks/exports.js +1 -1
  302. package/web/.svelte-kit/output/server/chunks/index-server.js +2 -1
  303. package/web/.svelte-kit/output/server/chunks/internal.js +1 -1
  304. package/web/.svelte-kit/output/server/chunks/render-context.js +77 -0
  305. package/web/.svelte-kit/output/server/chunks/root.js +739 -788
  306. package/web/.svelte-kit/output/server/chunks/shared.js +234 -21
  307. package/web/.svelte-kit/output/server/index.js +126 -90
  308. package/web/.svelte-kit/output/server/manifest-full.js +1 -1
  309. package/web/.svelte-kit/output/server/manifest.js +1 -1
  310. package/web/.svelte-kit/output/server/nodes/0.js +1 -1
  311. package/web/.svelte-kit/output/server/nodes/1.js +1 -1
  312. package/web/.svelte-kit/output/server/nodes/2.js +1 -1
  313. package/web/.svelte-kit/output/server/nodes/3.js +1 -1
  314. package/web/.svelte-kit/output/server/nodes/4.js +1 -1
  315. package/web/.svelte-kit/output/server/nodes/5.js +1 -1
  316. package/web/.svelte-kit/output/server/remote-entry.js +245 -81
  317. package/web/.svelte-kit/tsconfig.json +4 -1
  318. package/dist/cli/propose-policy.mjs.map +0 -1
  319. package/dist/lite-CBxOT1y5.mjs +0 -241
  320. package/dist/lite-CBxOT1y5.mjs.map +0 -1
  321. package/dist/routing-D8rTxtaV.mjs +0 -245
  322. package/dist/routing-D8rTxtaV.mjs.map +0 -1
  323. package/dist/web/_app/immutable/chunks/B6YN0Nuq.js +0 -1
  324. package/dist/web/_app/immutable/chunks/BmRlVmv6.js +0 -1
  325. package/dist/web/_app/immutable/chunks/CK9JZLaG.js +0 -2
  326. package/dist/web/_app/immutable/chunks/Ck3rYNON.js +0 -1
  327. package/dist/web/_app/immutable/chunks/DMtIqaiV.js +0 -2
  328. package/dist/web/_app/immutable/chunks/DhD271EB.js +0 -1
  329. package/dist/web/_app/immutable/chunks/DpuLqk8d.js +0 -1
  330. package/dist/web/_app/immutable/chunks/DsIToJCP.js +0 -1
  331. package/dist/web/_app/immutable/chunks/bBmtyQMj.js +0 -1
  332. package/dist/web/_app/immutable/entry/app.CJmSwntr.js +0 -2
  333. package/dist/web/_app/immutable/entry/start.ZpUrT2ak.js +0 -1
  334. package/dist/web/_app/immutable/nodes/1.Bli0Hqzn.js +0 -1
  335. package/dist/web/_app/immutable/nodes/4.oBhvQhcA.js +0 -60
  336. package/dist/workspace-BJmJBfKi.mjs +0 -456
  337. package/dist/workspace-BJmJBfKi.mjs.map +0 -1
  338. package/src/cli/e2e/agents.test.ts +0 -140
  339. package/src/cli/e2e/basic.test.ts +0 -43
  340. package/src/cli/e2e/cron.test.ts +0 -132
  341. package/src/cli/e2e/export-lite-func.test.ts +0 -206
  342. package/src/cli/e2e/fallbacks.test.ts +0 -175
  343. package/src/cli/e2e/init.test.ts +0 -77
  344. package/src/cli/e2e/messages.test.ts +0 -332
  345. package/src/cli/e2e/propose-policy.test.ts +0 -203
  346. package/src/cli/e2e/requests.test.ts +0 -180
  347. package/src/cli/e2e/session-timeout.test.ts +0 -192
  348. package/src/cli/e2e/slash-new.test.ts +0 -93
  349. package/src/cli/e2e/subagents.test.ts +0 -106
  350. package/src/cli/e2e/utils.ts +0 -66
  351. package/src/cli/propose-policy.ts +0 -91
  352. package/web/.svelte-kit/output/client/_app/immutable/chunks/B6YN0Nuq.js +0 -1
  353. package/web/.svelte-kit/output/client/_app/immutable/chunks/BmRlVmv6.js +0 -1
  354. package/web/.svelte-kit/output/client/_app/immutable/chunks/CK9JZLaG.js +0 -2
  355. package/web/.svelte-kit/output/client/_app/immutable/chunks/Ck3rYNON.js +0 -1
  356. package/web/.svelte-kit/output/client/_app/immutable/chunks/DMtIqaiV.js +0 -2
  357. package/web/.svelte-kit/output/client/_app/immutable/chunks/DhD271EB.js +0 -1
  358. package/web/.svelte-kit/output/client/_app/immutable/chunks/DpuLqk8d.js +0 -1
  359. package/web/.svelte-kit/output/client/_app/immutable/chunks/DsIToJCP.js +0 -1
  360. package/web/.svelte-kit/output/client/_app/immutable/chunks/bBmtyQMj.js +0 -1
  361. package/web/.svelte-kit/output/client/_app/immutable/entry/app.CJmSwntr.js +0 -2
  362. package/web/.svelte-kit/output/client/_app/immutable/entry/start.ZpUrT2ak.js +0 -1
  363. package/web/.svelte-kit/output/client/_app/immutable/nodes/1.Bli0Hqzn.js +0 -1
  364. package/web/.svelte-kit/output/client/_app/immutable/nodes/4.oBhvQhcA.js +0 -60
  365. package/web/.svelte-kit/output/server/chunks/false.js +0 -4
  366. /package/dist/cli/{propose-policy.d.mts → manage-policies.d.mts} +0 -0
  367. /package/{src/cli/e2e → e2e/_helpers}/global-setup.ts +0 -0
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { userRouter as appRouter } from './api/index.js';
3
- import { daemonEvents, DAEMON_EVENT_MESSAGE_APPENDED } from './events.js';
3
+ import { daemonEvents, emitMessageAppended } from './events.js';
4
4
  import * as daemonChats from './chats.js';
5
5
 
6
6
  vi.mock('./chats.js', async (importOriginal) => {
@@ -49,8 +49,8 @@ describe('Daemon Message Observation', () => {
49
49
  const result = (await iterator.next()).value;
50
50
 
51
51
  expect(result).toHaveLength(2);
52
- expect(result![0]!.id).toBe('2');
53
- expect(result![1]!.id).toBe('3');
52
+ expect(result![0]).toMatchObject({ kind: 'message', message: { id: '2' } });
53
+ expect(result![1]).toMatchObject({ kind: 'message', message: { id: '3' } });
54
54
  });
55
55
 
56
56
  it('waitForMessages should wait for a new message if none are available after lastMessageId', async () => {
@@ -67,16 +67,20 @@ describe('Daemon Message Observation', () => {
67
67
 
68
68
  const waitPromise = iterator.next();
69
69
 
70
- const newMessage = { id: '2', role: 'log', content: 'hi', timestamp: '...' };
70
+ const newMessage = {
71
+ id: '2',
72
+ role: 'log' as const,
73
+ content: 'hi',
74
+ timestamp: '...',
75
+ } as unknown as import('./chats.js').ChatMessage;
71
76
 
72
- // Simulate message arrival
73
- setTimeout(() => {
74
- daemonEvents.emit(DAEMON_EVENT_MESSAGE_APPENDED, { chatId: 'chat-1', message: newMessage });
75
- }, 10);
77
+ // Simulate message arrival via the shared emit helper so the merged
78
+ // chat-stream channel receives it alongside the legacy one.
79
+ setTimeout(() => emitMessageAppended('chat-1', newMessage), 10);
76
80
 
77
81
  const result = await waitPromise;
78
82
  expect(result.value).toHaveLength(1);
79
- expect(result.value![0]!.id).toBe('2');
83
+ expect(result.value![0]).toMatchObject({ kind: 'message', message: { id: '2' } });
80
84
  });
81
85
 
82
86
  it('waitForMessages should ignore messages for other chats while waiting', async () => {
@@ -95,10 +99,12 @@ describe('Daemon Message Observation', () => {
95
99
  iterator.next().then((res: any) => (yieldedValue = res.value));
96
100
 
97
101
  // Simulate message for another chat
98
- daemonEvents.emit(DAEMON_EVENT_MESSAGE_APPENDED, {
99
- chatId: 'other-chat',
100
- message: { id: 'x', role: 'user', content: 'wrong', timestamp: '...' },
101
- });
102
+ emitMessageAppended('other-chat', {
103
+ id: 'x',
104
+ role: 'user',
105
+ content: 'wrong',
106
+ timestamp: '...',
107
+ } as unknown as import('./chats.js').ChatMessage);
102
108
 
103
109
  // Wait a tick
104
110
  await new Promise((resolve) => setTimeout(resolve, 10));
@@ -106,13 +112,15 @@ describe('Daemon Message Observation', () => {
106
112
  expect(yieldedValue).toBeNull(); // Should still be waiting
107
113
 
108
114
  // Now simulate the correct chat
109
- daemonEvents.emit(DAEMON_EVENT_MESSAGE_APPENDED, {
110
- chatId: 'chat-1',
111
- message: { id: 'y', role: 'user', content: 'right', timestamp: '...' },
112
- });
115
+ emitMessageAppended('chat-1', {
116
+ id: 'y',
117
+ role: 'user',
118
+ content: 'right',
119
+ timestamp: '...',
120
+ } as unknown as import('./chats.js').ChatMessage);
113
121
 
114
122
  await new Promise((resolve) => setTimeout(resolve, 10));
115
123
  expect(yieldedValue).toHaveLength(1);
116
- expect(yieldedValue![0]!.id).toBe('y');
124
+ expect(yieldedValue![0]).toMatchObject({ kind: 'message', message: { id: 'y' } });
117
125
  });
118
126
  });
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ import {
7
+ enqueuePendingReply,
8
+ dequeuePendingReply,
9
+ readPendingReplies,
10
+ getPendingRepliesPath,
11
+ drainPendingReplies,
12
+ } from './pending-replies.js';
13
+
14
+ describe('pending-replies queue', () => {
15
+ let tmp: string;
16
+
17
+ beforeEach(() => {
18
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmini-pending-'));
19
+ fs.mkdirSync(path.join(tmp, '.clawmini'), { recursive: true });
20
+ });
21
+
22
+ afterEach(() => {
23
+ fs.rmSync(tmp, { recursive: true, force: true });
24
+ });
25
+
26
+ it('returns empty list when file does not exist', () => {
27
+ expect(readPendingReplies(tmp)).toEqual([]);
28
+ });
29
+
30
+ it('round-trips entries via enqueue + readPendingReplies', () => {
31
+ enqueuePendingReply({ chatId: 'chat-1', kind: 'restart-complete' }, tmp);
32
+ enqueuePendingReply({ chatId: 'chat-2', kind: 'upgrade-complete', messageId: 'msg-x' }, tmp);
33
+
34
+ expect(fs.existsSync(getPendingRepliesPath(tmp))).toBe(true);
35
+ expect(readPendingReplies(tmp)).toEqual([
36
+ { chatId: 'chat-1', kind: 'restart-complete' },
37
+ { chatId: 'chat-2', kind: 'upgrade-complete', messageId: 'msg-x' },
38
+ ]);
39
+ });
40
+
41
+ it('dequeuePendingReply removes the matching entry and deletes the file when empty', () => {
42
+ enqueuePendingReply({ chatId: 'chat-1', kind: 'upgrade-complete', messageId: 'm-1' }, tmp);
43
+ expect(dequeuePendingReply((e) => e.messageId === 'm-1', tmp)).toBe(true);
44
+ expect(fs.existsSync(getPendingRepliesPath(tmp))).toBe(false);
45
+ });
46
+
47
+ it('dequeuePendingReply returns false when no entry matches', () => {
48
+ enqueuePendingReply({ chatId: 'chat-1', kind: 'restart-complete' }, tmp);
49
+ expect(dequeuePendingReply((e) => e.messageId === 'nope', tmp)).toBe(false);
50
+ expect(readPendingReplies(tmp)).toHaveLength(1);
51
+ });
52
+
53
+ it('treats a corrupt file as empty and removes it on drain', async () => {
54
+ fs.writeFileSync(getPendingRepliesPath(tmp), 'not json');
55
+ await drainPendingReplies('1.2.3', tmp);
56
+ expect(fs.existsSync(getPendingRepliesPath(tmp))).toBe(false);
57
+ });
58
+
59
+ it('drainPendingReplies appends a SystemMessage for each entry', async () => {
60
+ enqueuePendingReply({ chatId: 'default', kind: 'restart-complete' }, tmp);
61
+ enqueuePendingReply({ chatId: 'default', kind: 'upgrade-complete' }, tmp);
62
+ enqueuePendingReply(
63
+ {
64
+ chatId: 'default',
65
+ kind: 'upgrade-failed',
66
+ requestedVersion: '9.9.9',
67
+ reason: 'npm install -g exited with code 1',
68
+ },
69
+ tmp
70
+ );
71
+
72
+ await drainPendingReplies('1.2.3', tmp);
73
+
74
+ const chatLog = path.join(tmp, '.clawmini', 'chats', 'default', 'chat.jsonl');
75
+ expect(fs.existsSync(chatLog)).toBe(true);
76
+ const lines = fs
77
+ .readFileSync(chatLog, 'utf-8')
78
+ .split('\n')
79
+ .filter((l) => l.trim());
80
+ expect(lines).toHaveLength(3);
81
+ const parsed = lines.map((l) => JSON.parse(l) as { role: string; content: string });
82
+ expect(parsed[0]?.role).toBe('system');
83
+ expect(parsed[0]?.content).toBe('Clawmini restarted (v1.2.3).');
84
+ expect(parsed[1]?.content).toBe('Clawmini upgraded to v1.2.3.');
85
+ expect(parsed[2]?.content).toBe(
86
+ 'Clawmini upgrade to v9.9.9 failed: npm install -g exited with code 1'
87
+ );
88
+ expect(fs.existsSync(getPendingRepliesPath(tmp))).toBe(false);
89
+ });
90
+
91
+ it('drain consumes entries one at a time, leaving the rest on disk if a delivery throws', async () => {
92
+ // Make the second delivery throw by giving it an invalid chatId. The
93
+ // shared chats helper rejects ids containing path separators.
94
+ enqueuePendingReply({ chatId: 'a', kind: 'restart-complete' }, tmp);
95
+ enqueuePendingReply({ chatId: '../escape', kind: 'restart-complete' }, tmp);
96
+ enqueuePendingReply({ chatId: 'c', kind: 'restart-complete' }, tmp);
97
+
98
+ // Silence the expected error log from the failing entry.
99
+ const err = vi.spyOn(console, 'error').mockImplementation(() => {});
100
+ try {
101
+ await drainPendingReplies('1.0.0', tmp);
102
+ } finally {
103
+ err.mockRestore();
104
+ }
105
+
106
+ // All three are consumed (the failing one is dropped after logging) and
107
+ // the file is removed. The two valid chats got their messages.
108
+ expect(fs.existsSync(getPendingRepliesPath(tmp))).toBe(false);
109
+ expect(fs.existsSync(path.join(tmp, '.clawmini', 'chats', 'a', 'chat.jsonl'))).toBe(true);
110
+ expect(fs.existsSync(path.join(tmp, '.clawmini', 'chats', 'c', 'chat.jsonl'))).toBe(true);
111
+ });
112
+ });
@@ -0,0 +1,162 @@
1
+ import fs from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import path from 'node:path';
4
+
5
+ import { getClawminiDir } from '../shared/workspace.js';
6
+ import type { SystemMessage } from '../shared/chats.js';
7
+ import { appendMessage } from './chats.js';
8
+
9
+ export type PendingReplyKind = 'restart-complete' | 'upgrade-complete' | 'upgrade-failed';
10
+
11
+ export interface PendingReply {
12
+ chatId: string;
13
+ kind: PendingReplyKind;
14
+ /** Original user messageId, recorded so the SystemMessage can reference it. */
15
+ messageId?: string;
16
+ /** Requested target version (set for upgrade-* kinds). */
17
+ requestedVersion?: string;
18
+ /** Failure reason; set for upgrade-failed. */
19
+ reason?: string;
20
+ }
21
+
22
+ interface PendingRepliesFile {
23
+ version: 1;
24
+ entries: PendingReply[];
25
+ }
26
+
27
+ export function getPendingRepliesPath(startDir = process.cwd()): string {
28
+ return path.join(getClawminiDir(startDir), 'pending-replies.json');
29
+ }
30
+
31
+ function readFileSafe(filePath: string): PendingRepliesFile | null {
32
+ if (!fs.existsSync(filePath)) return null;
33
+ try {
34
+ const raw = fs.readFileSync(filePath, 'utf-8');
35
+ const parsed = JSON.parse(raw) as PendingRepliesFile;
36
+ if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.entries)) return null;
37
+ return parsed;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function writeAtomic(filePath: string, data: PendingRepliesFile): void {
44
+ // tmp + rename so a crash never leaves a half-written file. The target file
45
+ // is on the same filesystem as the workspace, so rename is atomic.
46
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
47
+ const tmpPath = `${filePath}.${process.pid}.tmp`;
48
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
49
+ fs.renameSync(tmpPath, filePath);
50
+ }
51
+
52
+ function tryUnlink(filePath: string): void {
53
+ if (!fs.existsSync(filePath)) return;
54
+ try {
55
+ fs.unlinkSync(filePath);
56
+ } catch {
57
+ // best-effort
58
+ }
59
+ }
60
+
61
+ export function enqueuePendingReply(entry: PendingReply, startDir = process.cwd()): void {
62
+ const filePath = getPendingRepliesPath(startDir);
63
+ const existing = readFileSafe(filePath);
64
+ const entries = existing?.entries ?? [];
65
+ entries.push(entry);
66
+ writeAtomic(filePath, { version: 1, entries });
67
+ }
68
+
69
+ /**
70
+ * Remove the first entry that matches the predicate. Used to roll back an
71
+ * enqueued entry when the supervisor reports the action could not be started.
72
+ */
73
+ export function dequeuePendingReply(
74
+ predicate: (entry: PendingReply) => boolean,
75
+ startDir = process.cwd()
76
+ ): boolean {
77
+ const filePath = getPendingRepliesPath(startDir);
78
+ const existing = readFileSafe(filePath);
79
+ if (!existing) return false;
80
+ const idx = existing.entries.findIndex(predicate);
81
+ if (idx === -1) return false;
82
+ existing.entries.splice(idx, 1);
83
+ if (existing.entries.length === 0) {
84
+ tryUnlink(filePath);
85
+ } else {
86
+ writeAtomic(filePath, existing);
87
+ }
88
+ return true;
89
+ }
90
+
91
+ export function readPendingReplies(startDir = process.cwd()): PendingReply[] {
92
+ return readFileSafe(getPendingRepliesPath(startDir))?.entries ?? [];
93
+ }
94
+
95
+ function renderMessage(entry: PendingReply, runtimeVersion: string): string {
96
+ switch (entry.kind) {
97
+ case 'restart-complete':
98
+ return `Clawmini restarted (v${runtimeVersion}).`;
99
+ case 'upgrade-complete':
100
+ return `Clawmini upgraded to v${runtimeVersion}.`;
101
+ case 'upgrade-failed': {
102
+ const target = entry.requestedVersion ? ` to v${entry.requestedVersion}` : '';
103
+ const reason = entry.reason ? `: ${entry.reason}` : '.';
104
+ return `Clawmini upgrade${target} failed${reason}`;
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Append a SystemMessage for each pending reply. Called from the daemon
111
+ * startup path so adapters (which reconnect via tRPC subscription with
112
+ * lastMessageId) replay the message after the daemon comes back online.
113
+ *
114
+ * Crash-safe: each entry is consumed only after its SystemMessage is
115
+ * appended. A crash mid-loop leaves the un-delivered entries on disk for the
116
+ * next daemon start to drain.
117
+ */
118
+ export async function drainPendingReplies(
119
+ runtimeVersion: string,
120
+ startDir = process.cwd()
121
+ ): Promise<void> {
122
+ const filePath = getPendingRepliesPath(startDir);
123
+ const data = readFileSafe(filePath);
124
+ if (!data) {
125
+ // A corrupt file is treated as empty — remove it so it doesn't trip the
126
+ // next read, but otherwise no-op.
127
+ tryUnlink(filePath);
128
+ return;
129
+ }
130
+
131
+ const remaining: PendingReply[] = [...data.entries];
132
+ while (remaining.length > 0) {
133
+ const entry = remaining[0]!;
134
+ const sysMsg: SystemMessage = {
135
+ id: randomUUID(),
136
+ role: 'system',
137
+ content: renderMessage(entry, runtimeVersion),
138
+ timestamp: new Date().toISOString(),
139
+ sessionId: undefined,
140
+ event: 'router',
141
+ displayRole: 'agent',
142
+ ...(entry.messageId ? { messageId: entry.messageId } : {}),
143
+ };
144
+ try {
145
+ await appendMessage(entry.chatId, sysMsg, startDir);
146
+ } catch (err) {
147
+ // A delivery failure (e.g. chatId no longer exists) shouldn't block the
148
+ // rest of the queue from draining. Drop the entry after logging — the
149
+ // alternative is an infinite redelivery loop on the next start.
150
+ console.error(
151
+ `Failed to deliver pending reply to chat ${entry.chatId}:`,
152
+ err instanceof Error ? err.message : err
153
+ );
154
+ }
155
+ remaining.shift();
156
+ if (remaining.length === 0) {
157
+ tryUnlink(filePath);
158
+ } else {
159
+ writeAtomic(filePath, { version: 1, entries: remaining });
160
+ }
161
+ }
162
+ }
@@ -22,7 +22,8 @@ export class PolicyRequestService {
22
22
  chatId: string,
23
23
  agentId: string,
24
24
  skipSave: boolean = false,
25
- subagentId?: string
25
+ subagentId?: string,
26
+ cwd?: string
26
27
  ): Promise<PolicyRequest> {
27
28
  const allRequests = await this.store.list();
28
29
  const pendingCount = allRequests.filter((r) => r.state === 'Pending').length;
@@ -47,6 +48,7 @@ export class PolicyRequestService {
47
48
  commandName,
48
49
  args,
49
50
  fileMappings: snapshotMappings,
51
+ ...(cwd ? { cwd } : {}),
50
52
  state: skipSave ? 'Approved' : 'Pending',
51
53
  createdAt: Date.now(),
52
54
  chatId,
@@ -2,7 +2,14 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
- import { createSnapshot, interpolateArgs, executeSafe, MAX_SNAPSHOT_SIZE } from './policy-utils.js';
5
+ import {
6
+ createSnapshot,
7
+ interpolateArgs,
8
+ executeSafe,
9
+ MAX_SNAPSHOT_SIZE,
10
+ translateSandboxPath,
11
+ assertPathInsideDir,
12
+ } from './policy-utils.js';
6
13
 
7
14
  describe('policy-utils', () => {
8
15
  let tempDir: string;
@@ -135,4 +142,62 @@ describe('policy-utils', () => {
135
142
  expect(result.stdout.trim()).toBe('hello && echo injected');
136
143
  });
137
144
  });
145
+
146
+ describe('translateSandboxPath', () => {
147
+ it('strips baseDir from sandboxCwd and resolves against hostTargetDir', () => {
148
+ const result = translateSandboxPath('/app/src/components', '/app', '/Users/host/env');
149
+ expect(result).toBe(path.resolve('/Users/host/env', 'src/components'));
150
+ });
151
+
152
+ it('handles sandboxCwd exactly matching baseDir', () => {
153
+ const result = translateSandboxPath('/app', '/app', '/Users/host/env');
154
+ expect(result).toBe(path.resolve('/Users/host/env'));
155
+ });
156
+
157
+ it('treats sandboxCwd as relative to hostTargetDir when it does not start with baseDir', () => {
158
+ // Does not throw — translation is pure. Validation is the caller's job.
159
+ const result = translateSandboxPath('/other/path', '/app', '/Users/host/env');
160
+ expect(result).toBe(path.resolve('/Users/host/env', 'other/path'));
161
+ });
162
+
163
+ it('does not validate path traversal (caller validates)', () => {
164
+ // `..` segments collapse via path.resolve but no security error is thrown.
165
+ const result = translateSandboxPath('/app/../../etc/passwd', '/app', '/Users/host/env');
166
+ expect(result).toBe(path.resolve('/Users/host/env', '../../etc/passwd'));
167
+ });
168
+ });
169
+
170
+ describe('assertPathInsideDir', () => {
171
+ it('does not throw when cwd is inside boundaryDir', () => {
172
+ const inside = path.join(agentDir, 'sub');
173
+ expect(() => assertPathInsideDir(inside, agentDir)).not.toThrow();
174
+ });
175
+
176
+ it('allows cwd equal to boundaryDir', () => {
177
+ expect(() => assertPathInsideDir(agentDir, agentDir)).not.toThrow();
178
+ });
179
+
180
+ it('throws when cwd escapes boundaryDir', () => {
181
+ const outside = path.join(agentDir, '..', 'outside');
182
+ expect(() => assertPathInsideDir(outside, agentDir)).toThrow(
183
+ /Security Error: Path resolves outside the allowed directory/
184
+ );
185
+ });
186
+
187
+ it('follows symlinks before comparing (prevents symlink escape)', async () => {
188
+ const outsideTarget = path.join(tempDir, 'outside-target');
189
+ await fs.mkdir(outsideTarget);
190
+ const linkInsideAgent = path.join(agentDir, 'escape-link');
191
+ await fs.symlink(outsideTarget, linkInsideAgent);
192
+
193
+ expect(() => assertPathInsideDir(linkInsideAgent, agentDir)).toThrow(
194
+ /Security Error: Path resolves outside the allowed directory/
195
+ );
196
+ });
197
+
198
+ it('tolerates non-existent paths (uses the path as-is)', () => {
199
+ const nonExistentInside = path.join(agentDir, 'does-not-exist', 'nested');
200
+ expect(() => assertPathInsideDir(nonExistentInside, agentDir)).not.toThrow();
201
+ });
202
+ });
138
203
  });
@@ -1,12 +1,105 @@
1
1
  import fs from 'node:fs/promises';
2
- import { constants } from 'node:fs';
2
+ import fsSync, { constants } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { randomBytes } from 'node:crypto';
5
5
  import { spawn } from 'node:child_process';
6
6
  import { pathIsInsideDir } from '../shared/utils/fs.js';
7
7
  import type { PolicyRequest, PolicyDefinition } from '../shared/policies.js';
8
+ import { resolveAgentDir } from './api/router-utils.js';
9
+ import {
10
+ getWorkspaceRoot,
11
+ getActiveEnvironmentInfo,
12
+ readEnvironment,
13
+ } from '../shared/workspace.js';
8
14
 
9
15
  export const MAX_SNAPSHOT_SIZE = 5 * 1024 * 1024;
16
+ export const MAX_INLINE_OUTPUT_LENGTH = 500;
17
+
18
+ /**
19
+ * Strips the sandbox `baseDir` from `sandboxCwd` and resolves the remainder
20
+ * against `hostTargetDir` (the host dir that mirrors baseDir inside the
21
+ * sandbox). Pure translation — no security validation; callers must validate
22
+ * the result with `assertPathInsideDir`.
23
+ */
24
+ export function translateSandboxPath(
25
+ sandboxCwd: string,
26
+ baseDir: string,
27
+ hostTargetDir: string
28
+ ): string {
29
+ let relativePath = sandboxCwd;
30
+ if (sandboxCwd.startsWith(baseDir)) {
31
+ relativePath = sandboxCwd.slice(baseDir.length);
32
+ }
33
+ if (relativePath.startsWith('/') || relativePath.startsWith('\\')) {
34
+ relativePath = relativePath.slice(1);
35
+ }
36
+ return path.resolve(hostTargetDir, relativePath);
37
+ }
38
+
39
+ /**
40
+ * Throws if `cwd` (after symlink resolution) is not inside `boundaryDir`.
41
+ *
42
+ * Security note (TOCTOU): There is an inherent race between validating the
43
+ * resolved path here and the moment `spawn` uses it as cwd. A symlink created
44
+ * on the host filesystem in that window could redirect execution outside
45
+ * boundaryDir. We accept this because the sandboxed agent cannot modify the
46
+ * host filesystem — only a local user or process with host-level access could
47
+ * exploit the gap, and that is outside our threat model.
48
+ */
49
+ export function assertPathInsideDir(cwd: string, boundaryDir: string): void {
50
+ const realCwd = tryRealpath(cwd);
51
+ const realBoundary = tryRealpath(boundaryDir);
52
+ if (!pathIsInsideDir(realCwd, realBoundary, { allowSameDir: true })) {
53
+ throw new Error(`Security Error: Path resolves outside the allowed directory: ${cwd}`);
54
+ }
55
+ }
56
+
57
+ // Realpath that tolerates missing leaves. Walks up to the nearest existing
58
+ // ancestor, realpaths that, and re-appends the missing tail. Needed so that
59
+ // symlinks in the existing prefix still resolve (e.g. macOS /var → /private/var)
60
+ // when the full path is not yet on disk.
61
+ function tryRealpath(p: string): string {
62
+ const resolved = path.resolve(p);
63
+ try {
64
+ return fsSync.realpathSync(resolved);
65
+ } catch (err: unknown) {
66
+ if (
67
+ !(err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT')
68
+ ) {
69
+ throw err;
70
+ }
71
+ }
72
+ const parent = path.dirname(resolved);
73
+ if (parent === resolved) return resolved;
74
+ return path.join(tryRealpath(parent), path.basename(resolved));
75
+ }
76
+
77
+ export async function resolveRequestCwd(
78
+ requestCwd: string | undefined,
79
+ agentId: string | undefined,
80
+ workspaceRoot: string
81
+ ): Promise<string> {
82
+ if (!requestCwd) {
83
+ // TODO throw error instead?
84
+ return workspaceRoot;
85
+ }
86
+
87
+ const agentDir = await resolveAgentDir(agentId, workspaceRoot);
88
+ const envInfo = await getActiveEnvironmentInfo(agentDir, workspaceRoot);
89
+ const envConfig = envInfo ? await readEnvironment(envInfo.name, workspaceRoot) : null;
90
+
91
+ // Translate sandbox → host only when the env declares a baseDir (VM-style
92
+ // sandbox). Otherwise requestCwd is already a host path; resolve relative
93
+ // paths against agentDir and keep absolute paths as-is.
94
+ const hostCwd =
95
+ envInfo && envConfig?.baseDir
96
+ ? translateSandboxPath(requestCwd, envConfig.baseDir, envInfo.targetPath)
97
+ : path.resolve(agentDir, requestCwd);
98
+
99
+ // Boundary: the agent dir
100
+ assertPathInsideDir(hostCwd, agentDir);
101
+ return hostCwd;
102
+ }
10
103
 
11
104
  export async function createSnapshot(
12
105
  requestedPath: string,
@@ -145,6 +238,38 @@ export async function executeRequest(
145
238
  return { stdout, stderr, exitCode, commandStr };
146
239
  }
147
240
 
241
+ /**
242
+ * Saves large stdout/stderr to files in the agent's tmp/ directory and returns
243
+ * placeholder strings pointing to those files. Small outputs are returned as-is.
244
+ */
245
+ export async function truncateLargeOutput(
246
+ stdout: string,
247
+ stderr: string,
248
+ requestId: string,
249
+ agentId: string | undefined
250
+ ): Promise<{ stdout: string; stderr: string }> {
251
+ const agentDir = await resolveAgentDir(agentId, getWorkspaceRoot());
252
+ const tmpDir = path.join(agentDir, 'tmp');
253
+ const needsTmpDir =
254
+ stdout.length >= MAX_INLINE_OUTPUT_LENGTH || stderr.length >= MAX_INLINE_OUTPUT_LENGTH;
255
+
256
+ if (needsTmpDir) {
257
+ await fs.mkdir(tmpDir, { recursive: true });
258
+ }
259
+
260
+ if (stdout.length >= MAX_INLINE_OUTPUT_LENGTH) {
261
+ await fs.writeFile(path.join(tmpDir, `stdout-${requestId}.txt`), stdout, 'utf-8');
262
+ stdout = `stdout is ${stdout.length} characters, saved to ./tmp/stdout-${requestId}.txt\n`;
263
+ }
264
+
265
+ if (stderr.length >= MAX_INLINE_OUTPUT_LENGTH) {
266
+ await fs.writeFile(path.join(tmpDir, `stderr-${requestId}.txt`), stderr, 'utf-8');
267
+ stderr = `stderr is ${stderr.length} characters, saved to ./tmp/stderr-${requestId}.txt\n`;
268
+ }
269
+
270
+ return { stdout, stderr };
271
+ }
272
+
148
273
  export async function generateRequestPreview(request: PolicyRequest): Promise<string> {
149
274
  let previewContent = `Sandbox Policy Request: ${request.commandName}\n`;
150
275
  previewContent += `ID: ${request.id}\n`;
@@ -15,6 +15,8 @@ const PolicyRequestSchema = z.object({
15
15
  rejectionReason: z.string().optional(),
16
16
  chatId: z.string(),
17
17
  agentId: z.string(),
18
+ subagentId: z.string().optional(),
19
+ cwd: z.string().optional(),
18
20
  });
19
21
 
20
22
  function isENOENT(err: unknown): boolean {
@@ -46,6 +48,35 @@ export class RequestStore {
46
48
  await fs.writeFile(filePath, JSON.stringify(request, null, 2), 'utf8');
47
49
  }
48
50
 
51
+ async delete(id: string): Promise<void> {
52
+ const normalizedId = normalizePolicyId(id);
53
+ const filePath = this.getFilePath(normalizedId);
54
+ try {
55
+ await fs.unlink(filePath);
56
+ } catch (err: unknown) {
57
+ if (!isENOENT(err)) throw err;
58
+ }
59
+ }
60
+
61
+ async cleanupCompleted(): Promise<number> {
62
+ let removed = 0;
63
+ try {
64
+ const files = await fs.readdir(this.baseDir);
65
+ for (const file of files) {
66
+ if (!file.endsWith('.json')) continue;
67
+ const id = path.basename(file, '.json');
68
+ const req = await this.load(id);
69
+ if (req && req.state !== 'Pending') {
70
+ await this.delete(id);
71
+ removed++;
72
+ }
73
+ }
74
+ } catch (err: unknown) {
75
+ if (!isENOENT(err)) throw err;
76
+ }
77
+ return removed;
78
+ }
79
+
49
80
  async load(id: string): Promise<PolicyRequest | null> {
50
81
  const normalizedId = normalizePolicyId(id);
51
82
  const filePath = this.getFilePath(normalizedId);
@@ -35,6 +35,10 @@ export function createSessionTimeoutRouter(config: SessionTimeoutConfig = {}) {
35
35
  return state;
36
36
  }
37
37
 
38
+ if (state.subagentId) {
39
+ return state;
40
+ }
41
+
38
42
  const sessionId = state.sessionId || crypto.randomUUID();
39
43
  const jobId = `__session_timeout__${sessionId}`;
40
44