failproofai 1.0.0-beta.2 → 1.0.0-beta.4

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 (234) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +6 -6
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  15. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
  17. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  18. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  22. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  23. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  24. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  25. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  26. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  27. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/run/route.js +2 -2
  30. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
  37. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  39. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  40. package/.next/standalone/.next/server/app/audit/page.js +1 -1
  41. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/.next/server/app/index.html +1 -1
  44. package/.next/standalone/.next/server/app/index.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  46. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  47. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  48. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  49. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  50. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  51. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  55. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
  56. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  57. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  60. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/page.js +1 -1
  62. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  63. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  66. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  67. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +1 -1
  68. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  70. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  71. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  72. package/.next/standalone/.next/server/app/projects/page.js +1 -1
  73. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  74. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  75. package/.next/standalone/.next/server/chunks/[externals]__0h9_83k._.js +3 -0
  76. package/.next/standalone/.next/server/chunks/[externals]__1ifpkx-._.js +3 -0
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-dh5jy._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0f8nu8d._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0h3lfmp._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0l4vuqm._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0longh-._.js → [root-of-the-server]__0szqzl8._.js} +2 -2
  82. package/.next/standalone/.next/server/chunks/[root-of-the-server]__10oa-v9._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/[root-of-the-server]__18d-9oi._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__16le-kd._.js → [root-of-the-server]__1p7s8-d._.js} +2 -2
  88. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  89. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -1
  90. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -1
  91. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/[externals]__1aq139v._.js +3 -0
  93. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
  94. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__0cmdyn_._.js} +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
  98. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0uno39i._.js → [root-of-the-server]__0n_uvv0._.js} +2 -2
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  100. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01od0m6._.js → [root-of-the-server]__0rag5dg._.js} +2 -2
  101. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apb7k_._.js → [root-of-the-server]__0rtdfof._.js} +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__0sdrz-j._.js} +2 -2
  103. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
  104. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  105. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
  106. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qapotl._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
  108. package/.next/standalone/.next/server/chunks/ssr/_1e19atl._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  112. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_1q9xxca.js} +1 -1
  113. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -1
  114. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  115. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  116. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  117. package/.next/standalone/.next/server/pages/404.html +1 -1
  118. package/.next/standalone/.next/server/pages/500.html +1 -1
  119. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  120. package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
  121. package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 06jakt_xhoolk.js} +1 -1
  122. package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
  123. package/.next/standalone/.next/static/chunks/0oe2-hp0_2tzs.js +6 -0
  124. package/.next/standalone/.next/static/chunks/{1hdhteui932bq.js → 0qmo2t50kpb30.js} +1 -1
  125. package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 0ue9ak_xexiqh.js} +1 -1
  126. package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
  127. package/.next/standalone/.next/static/chunks/1fw3pxobk_otl.js +1 -0
  128. package/.next/standalone/.next/static/chunks/23kev26by1e4f.js +1 -0
  129. package/.next/standalone/.next/static/chunks/2c90h1g6x8z8y.js +1 -0
  130. package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 2zb3m1tzq6h98.js} +1 -1
  131. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  132. package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 31cqiv_cpgj9m.js} +1 -1
  133. package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 3h5_3u46gu9jx.js} +1 -1
  134. package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
  135. package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 43a1ywqsg9bq9.js} +1 -1
  136. package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
  137. package/.next/standalone/Cargo.lock +1992 -198
  138. package/.next/standalone/Cargo.toml +1 -1
  139. package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
  140. package/.next/standalone/app/components/pause-notices.tsx +111 -0
  141. package/.next/standalone/app/policies/hooks-client.tsx +81 -6
  142. package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
  143. package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
  144. package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
  145. package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
  146. package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
  147. package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
  148. package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
  149. package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
  150. package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
  151. package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
  152. package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
  153. package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
  154. package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
  155. package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
  156. package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
  157. package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
  158. package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
  159. package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
  160. package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
  161. package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
  162. package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
  163. package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
  164. package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
  165. package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
  166. package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
  167. package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
  168. package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
  169. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
  170. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
  171. package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
  172. package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
  173. package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
  174. package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
  175. package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
  176. package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
  177. package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
  178. package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
  179. package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
  180. package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
  181. package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
  182. package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
  183. package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
  184. package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
  185. package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
  186. package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
  187. package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
  188. package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
  189. package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
  190. package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
  191. package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
  192. package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
  193. package/.next/standalone/package.json +15 -9
  194. package/.next/standalone/server.js +1 -1
  195. package/bin/failproofai.mjs +129 -0
  196. package/dist/cli.mjs +2255 -779
  197. package/dist/worker.mjs +365 -52
  198. package/package.json +15 -9
  199. package/scripts/build-daemon-packages.mjs +210 -0
  200. package/scripts/daemon-platforms.mjs +42 -0
  201. package/scripts/prune-standalone.mjs +6 -0
  202. package/scripts/publish-aliases.mjs +62 -41
  203. package/src/hooks/builtin-policies.ts +139 -0
  204. package/src/hooks/cloud-connection.ts +205 -0
  205. package/src/hooks/cloud-enrollment-cli.ts +227 -0
  206. package/src/hooks/cloud-enrollment.ts +173 -0
  207. package/src/hooks/cloud-managed-policies.ts +138 -0
  208. package/src/hooks/collector-config.ts +226 -0
  209. package/src/hooks/configure-wizard.ts +221 -5
  210. package/src/hooks/custom-hooks-loader.ts +69 -25
  211. package/src/hooks/daemon-client.ts +18 -0
  212. package/src/hooks/daemon-download.ts +137 -27
  213. package/src/hooks/daemon-service.ts +23 -8
  214. package/src/hooks/handler.ts +144 -6
  215. package/src/hooks/hook-activity-store.ts +50 -0
  216. package/src/hooks/loader-utils.ts +6 -4
  217. package/src/hooks/policy-types.ts +35 -0
  218. package/src/hooks/session-pause-cli.ts +157 -0
  219. package/src/hooks/session-pause.ts +236 -0
  220. package/src/hooks/tui.ts +115 -0
  221. package/.next/standalone/.next/server/chunks/[externals]__14odj07._.js +0 -3
  222. package/.next/standalone/.next/server/chunks/[externals]__1s61mel._.js +0 -3
  223. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +0 -3
  224. package/.next/standalone/.next/server/chunks/ssr/[externals]__1_g_b3t._.js +0 -3
  225. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  226. package/.next/standalone/.next/static/chunks/2mazwb1qmur1p.js +0 -1
  227. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  228. package/.next/standalone/.next/static/chunks/31feojxhjyjpl.js +0 -1
  229. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  230. package/.next/standalone/.next/static/chunks/3s7myk_co0gbh.js +0 -1
  231. package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
  232. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_buildManifest.js +0 -0
  233. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_clientMiddlewareManifest.js +0 -0
  234. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_ssgManifest.js +0 -0
@@ -0,0 +1,484 @@
1
+ use crate::cloud_policies::{DesiredPolicy, DesiredState, PolicyStore};
2
+ use reqwest::Url;
3
+ use reqwest::blocking::Client;
4
+ use std::sync::Arc;
5
+ use std::sync::atomic::{AtomicBool, Ordering};
6
+ use std::thread::JoinHandle;
7
+ use std::time::{Duration, Instant};
8
+
9
+ const DEFAULT_POLL_MS: u64 = 30_000;
10
+ const MINIMUM_POLL_MS: u64 = 100;
11
+
12
+ #[derive(Clone)]
13
+ pub struct CloudClient {
14
+ base_url: Url,
15
+ token: String,
16
+ machine_id: String,
17
+ client: Client,
18
+ }
19
+
20
+ /// On-disk enrolment, written by `failproofai config --connect`.
21
+ ///
22
+ /// The credential deliberately does NOT live in the service unit: that file is
23
+ /// installed world-readable (0644, `/etc/systemd/system`), so a token there
24
+ /// would be readable by every local user and echoed by `systemctl show`.
25
+ #[derive(serde::Deserialize)]
26
+ struct StoredCredentials {
27
+ #[serde(rename = "schemaVersion")]
28
+ schema_version: u32,
29
+ url: String,
30
+ #[serde(rename = "machineId")]
31
+ machine_id: String,
32
+ token: String,
33
+ }
34
+
35
+ /// `~/.failproofai/cloud.json`, or `FAILPROOFAI_CLOUD_CREDENTIALS`.
36
+ /// The same two rules the TS side applies, so the pair cannot drift.
37
+ pub fn credentials_path() -> Option<std::path::PathBuf> {
38
+ if let Some(path) = std::env::var_os("FAILPROOFAI_CLOUD_CREDENTIALS") {
39
+ return Some(std::path::PathBuf::from(path));
40
+ }
41
+ let home = std::env::var_os("HOME")?;
42
+ Some(
43
+ std::path::PathBuf::from(home)
44
+ .join(".failproofai")
45
+ .join("cloud.json"),
46
+ )
47
+ }
48
+
49
+ impl CloudClient {
50
+ /// Environment first, then the credential file.
51
+ ///
52
+ /// Env wins so CI, containers and tests keep working unchanged, and so an
53
+ /// operator who prefers env-only configuration loses nothing.
54
+ pub fn from_env_or_file() -> Result<Option<Self>, String> {
55
+ if let Some(client) = Self::from_env()? {
56
+ return Ok(Some(client));
57
+ }
58
+ Self::from_file()
59
+ }
60
+
61
+ pub fn from_env() -> Result<Option<Self>, String> {
62
+ let Some(base_url) = env_value("FAILPROOFAI_CLOUD_URL") else {
63
+ return Ok(None);
64
+ };
65
+ let token = env_value("FAILPROOFAI_CLOUD_TOKEN")
66
+ .ok_or("FAILPROOFAI_CLOUD_TOKEN is required when FAILPROOFAI_CLOUD_URL is set")?;
67
+ let machine_id = env_value("FAILPROOFAI_MACHINE_ID")
68
+ .ok_or("FAILPROOFAI_MACHINE_ID is required when FAILPROOFAI_CLOUD_URL is set")?;
69
+ Self::new(&base_url, token, machine_id).map(Some)
70
+ }
71
+
72
+ /// A missing file means "not enrolled" — not an error. A malformed one IS
73
+ /// an error: it was written by us, so bad content means something is wrong
74
+ /// that the operator should see rather than a silently unenrolled machine.
75
+ pub fn from_file() -> Result<Option<Self>, String> {
76
+ let Some(path) = credentials_path() else {
77
+ return Ok(None);
78
+ };
79
+ let bytes = match std::fs::read(&path) {
80
+ Ok(bytes) => bytes,
81
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
82
+ Err(err) => return Err(format!("failed to read {}: {err}", path.display())),
83
+ };
84
+ let stored: StoredCredentials = serde_json::from_slice(&bytes)
85
+ .map_err(|err| format!("invalid credentials in {}: {err}", path.display()))?;
86
+ if stored.schema_version != 1 {
87
+ return Err(format!(
88
+ "unsupported credentials schema {} in {}",
89
+ stored.schema_version,
90
+ path.display()
91
+ ));
92
+ }
93
+ if stored.token.is_empty() {
94
+ return Err(format!("empty token in {}", path.display()));
95
+ }
96
+ Self::new(&stored.url, stored.token, stored.machine_id).map(Some)
97
+ }
98
+
99
+ fn new(base_url: &str, token: String, machine_id: String) -> Result<Self, String> {
100
+ let mut base_url =
101
+ Url::parse(base_url).map_err(|err| format!("invalid FAILPROOFAI_CLOUD_URL: {err}"))?;
102
+ if !matches!(base_url.scheme(), "http" | "https") {
103
+ return Err("FAILPROOFAI_CLOUD_URL must use http or https".to_string());
104
+ }
105
+ if !base_url.path().ends_with('/') {
106
+ base_url.set_path(&format!("{}/", base_url.path()));
107
+ }
108
+ if machine_id.is_empty() {
109
+ return Err("FAILPROOFAI_MACHINE_ID cannot be empty".to_string());
110
+ }
111
+ let client = Client::builder()
112
+ .connect_timeout(Duration::from_secs(5))
113
+ .timeout(Duration::from_secs(15))
114
+ .build()
115
+ .map_err(|err| format!("failed to build cloud HTTP client: {err}"))?;
116
+ Ok(Self {
117
+ base_url,
118
+ token,
119
+ machine_id,
120
+ client,
121
+ })
122
+ }
123
+
124
+ pub fn desired_state(&self) -> Result<DesiredState, String> {
125
+ let mut url = self
126
+ .base_url
127
+ .join("enforcement/v1/desired-state")
128
+ .map_err(|err| format!("failed to build desired-state URL: {err}"))?;
129
+ url.query_pairs_mut()
130
+ .append_pair("machineId", &self.machine_id);
131
+ self.client
132
+ .get(url)
133
+ .bearer_auth(&self.token)
134
+ .send()
135
+ .and_then(|response| response.error_for_status())
136
+ .map_err(|err| format!("desired-state request failed: {err}"))?
137
+ .json()
138
+ .map_err(|err| format!("invalid desired-state response: {err}"))
139
+ }
140
+
141
+ fn artifact(&self, policy: &DesiredPolicy) -> Result<Vec<u8>, String> {
142
+ let url = self
143
+ .base_url
144
+ .join(&policy.artifact_url)
145
+ .map_err(|err| format!("invalid artifact URL: {err}"))?;
146
+ if url.origin() != self.base_url.origin() {
147
+ return Err("artifact URL points outside the configured cloud origin".to_string());
148
+ }
149
+ self.client
150
+ .get(url)
151
+ .bearer_auth(&self.token)
152
+ .send()
153
+ .and_then(|response| response.error_for_status())
154
+ .map_err(|err| format!("artifact request failed: {err}"))?
155
+ .bytes()
156
+ .map(|bytes| bytes.to_vec())
157
+ .map_err(|err| format!("failed to read artifact response: {err}"))
158
+ }
159
+ }
160
+
161
+ /// One maintenance lane that re-resolves enrolment on every tick.
162
+ ///
163
+ /// Enrolment is deliberately NOT read once at startup. `failproofai config
164
+ /// --connect` writes a credential file without root, and the service is a
165
+ /// SYSTEM unit — so requiring a restart to notice it would put `sudo systemctl
166
+ /// restart` back into the flow and undo the reason the credential lives in a
167
+ /// file at all. Re-resolving per tick also makes token rotation and
168
+ /// `--disconnect` take effect within one interval, with nothing to restart.
169
+ ///
170
+ /// Resolution failures degrade to integrity-only rather than killing the lane:
171
+ /// a machine that was pulling policy keeps its last known-good generation and
172
+ /// keeps repairing tampering while its credentials are broken.
173
+ ///
174
+ /// Two intervals, chosen per tick, so both documented knobs keep their meaning
175
+ /// now that one lane serves both cases: `FAILPROOFAI_CLOUD_POLICY_POLL_MS` when
176
+ /// enrolled, `FAILPROOFAI_CLOUD_POLICY_RECONCILE_MS` when not.
177
+ pub fn spawn_maintenance(
178
+ store: PolicyStore,
179
+ shutdown: Arc<AtomicBool>,
180
+ poll_interval: Duration,
181
+ idle_interval: Duration,
182
+ ) -> JoinHandle<()> {
183
+ std::thread::spawn(move || {
184
+ let mut last_state: Option<bool> = None;
185
+ while !shutdown.load(Ordering::Relaxed) {
186
+ let cloud = match CloudClient::from_env_or_file() {
187
+ Ok(client) => client,
188
+ Err(err) => {
189
+ eprintln!("[failproofaid] cloud enrolment error: {err}");
190
+ None
191
+ }
192
+ };
193
+
194
+ // Log only on transition, so a disconnected machine does not print
195
+ // a line every 30 seconds forever.
196
+ let enrolled = cloud.is_some();
197
+ if last_state != Some(enrolled) {
198
+ eprintln!(
199
+ "[failproofaid] cloud-managed policy polling {}",
200
+ if enrolled { "enabled" } else { "disabled" }
201
+ );
202
+ last_state = Some(enrolled);
203
+ }
204
+
205
+ if let Some(cloud) = cloud.as_ref() {
206
+ poll_once(&store, cloud);
207
+ }
208
+
209
+ // Runs whether or not cloud is reachable: poll failures never
210
+ // discard the last known-good generation, and local tampering is
211
+ // still repaired while the cloud is offline or unconfigured.
212
+ if let Err(err) = store.repair_active_from_cache() {
213
+ eprintln!("[failproofaid] cloud policy integrity error: {err}");
214
+ }
215
+ wait_until_shutdown(
216
+ &shutdown,
217
+ if enrolled {
218
+ poll_interval
219
+ } else {
220
+ idle_interval
221
+ },
222
+ );
223
+ }
224
+ })
225
+ }
226
+
227
+ fn poll_once(store: &PolicyStore, cloud: &CloudClient) {
228
+ match cloud.desired_state() {
229
+ Ok(desired) => {
230
+ match store.reconcile(&desired, &|policy: &DesiredPolicy| cloud.artifact(policy)) {
231
+ Ok(outcome)
232
+ if outcome.activated || outcome.downloaded > 0 || outcome.repaired > 0 =>
233
+ {
234
+ eprintln!(
235
+ "[failproofaid] cloud policy generation {} active (downloaded {}, repaired {})",
236
+ outcome.generation, outcome.downloaded, outcome.repaired
237
+ );
238
+ }
239
+ Ok(_) => {}
240
+ Err(err) => eprintln!("[failproofaid] cloud policy reconcile error: {err}"),
241
+ }
242
+ }
243
+ Err(err) => eprintln!("[failproofaid] cloud policy poll error: {err}"),
244
+ }
245
+ }
246
+
247
+ pub fn poll_interval_from_env() -> Duration {
248
+ let milliseconds = env_value("FAILPROOFAI_CLOUD_POLICY_POLL_MS")
249
+ .and_then(|value| value.parse::<u64>().ok())
250
+ .unwrap_or(DEFAULT_POLL_MS)
251
+ .max(MINIMUM_POLL_MS);
252
+ Duration::from_millis(milliseconds)
253
+ }
254
+
255
+ fn env_value(name: &str) -> Option<String> {
256
+ std::env::var(name)
257
+ .ok()
258
+ .map(|value| value.trim().to_string())
259
+ .filter(|value| !value.is_empty())
260
+ }
261
+
262
+ fn wait_until_shutdown(shutdown: &AtomicBool, interval: Duration) {
263
+ let deadline = Instant::now() + interval;
264
+ while !shutdown.load(Ordering::Relaxed) && Instant::now() < deadline {
265
+ let remaining = deadline.saturating_duration_since(Instant::now());
266
+ std::thread::sleep(remaining.min(Duration::from_millis(50)));
267
+ }
268
+ }
269
+
270
+ #[cfg(test)]
271
+ mod tests {
272
+ use super::*;
273
+ // Only the fixtures construct an effect explicitly.
274
+ use crate::cloud_policies::PolicyEffect;
275
+
276
+ // std::env::set_var is process-global, so these must not interleave with
277
+ // each other or with anything else reading the same variables.
278
+ static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
279
+
280
+ /// Clears the vars it set and removes its scratch directory. Kept as a
281
+ /// guard so a failing assertion cannot leak process-global env into the
282
+ /// next test.
283
+ struct EnvGuard(std::path::PathBuf);
284
+ impl Drop for EnvGuard {
285
+ fn drop(&mut self) {
286
+ unsafe {
287
+ std::env::remove_var("FAILPROOFAI_CLOUD_CREDENTIALS");
288
+ std::env::remove_var("FAILPROOFAI_CLOUD_URL");
289
+ std::env::remove_var("FAILPROOFAI_CLOUD_TOKEN");
290
+ std::env::remove_var("FAILPROOFAI_MACHINE_ID");
291
+ }
292
+ let _ = fs::remove_dir_all(&self.0);
293
+ }
294
+ }
295
+
296
+ static SCRATCH_SEQ: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
297
+
298
+ fn with_credentials_file(contents: Option<&str>) -> EnvGuard {
299
+ let seq = SCRATCH_SEQ.fetch_add(1, Ordering::Relaxed);
300
+ let dir =
301
+ std::env::temp_dir().join(format!("failproofaid-creds-{}-{seq}", std::process::id()));
302
+ fs::create_dir_all(&dir).unwrap();
303
+ let path = dir.join("cloud.json");
304
+ if let Some(contents) = contents {
305
+ fs::write(&path, contents).unwrap();
306
+ }
307
+ unsafe {
308
+ std::env::set_var("FAILPROOFAI_CLOUD_CREDENTIALS", &path);
309
+ std::env::remove_var("FAILPROOFAI_CLOUD_URL");
310
+ std::env::remove_var("FAILPROOFAI_CLOUD_TOKEN");
311
+ std::env::remove_var("FAILPROOFAI_MACHINE_ID");
312
+ }
313
+ EnvGuard(dir)
314
+ }
315
+
316
+ const GOOD: &str =
317
+ r#"{"schemaVersion":1,"url":"https://cloud.example","machineId":"m-1","token":"secret"}"#;
318
+
319
+ #[test]
320
+ fn no_credentials_file_means_not_enrolled_rather_than_an_error() {
321
+ let _lock = ENV_LOCK.lock().unwrap();
322
+ let _guard = with_credentials_file(None);
323
+ assert!(CloudClient::from_file().unwrap().is_none());
324
+ }
325
+
326
+ #[test]
327
+ fn reads_a_valid_credentials_file() {
328
+ let _lock = ENV_LOCK.lock().unwrap();
329
+ let _guard = with_credentials_file(Some(GOOD));
330
+ let client = CloudClient::from_file().unwrap().expect("enrolled");
331
+ assert_eq!(client.machine_id, "m-1");
332
+ assert_eq!(client.token, "secret");
333
+ assert_eq!(client.base_url.host_str(), Some("cloud.example"));
334
+ }
335
+
336
+ #[test]
337
+ fn a_malformed_credentials_file_is_an_error_not_a_silent_disconnect() {
338
+ // We wrote this file. Bad content means something is wrong that the
339
+ // operator needs to see — reporting "not enrolled" would leave a
340
+ // machine quietly unmanaged while looking healthy.
341
+ let _lock = ENV_LOCK.lock().unwrap();
342
+ let _guard = with_credentials_file(Some("{ not json"));
343
+ let err = CloudClient::from_file()
344
+ .err()
345
+ .expect("malformed file must error");
346
+ assert!(err.contains("invalid credentials"), "{err}");
347
+ }
348
+
349
+ #[test]
350
+ fn rejects_an_unknown_schema_version() {
351
+ let _lock = ENV_LOCK.lock().unwrap();
352
+ let _guard = with_credentials_file(Some(
353
+ r#"{"schemaVersion":99,"url":"https://c.example","machineId":"m","token":"t"}"#,
354
+ ));
355
+ let err = CloudClient::from_file()
356
+ .err()
357
+ .expect("unknown schema must error");
358
+ assert!(err.contains("unsupported credentials schema"), "{err}");
359
+ }
360
+
361
+ #[test]
362
+ fn rejects_an_empty_token_and_an_empty_machine_id() {
363
+ let _lock = ENV_LOCK.lock().unwrap();
364
+ {
365
+ let _guard = with_credentials_file(Some(
366
+ r#"{"schemaVersion":1,"url":"https://c.example","machineId":"m","token":""}"#,
367
+ ));
368
+ let err = CloudClient::from_file()
369
+ .err()
370
+ .expect("empty token must error");
371
+ assert!(err.contains("empty token"), "{err}");
372
+ }
373
+ let _guard = with_credentials_file(Some(
374
+ r#"{"schemaVersion":1,"url":"https://c.example","machineId":"","token":"t"}"#,
375
+ ));
376
+ assert!(CloudClient::from_file().is_err());
377
+ }
378
+
379
+ #[test]
380
+ fn environment_wins_over_the_credentials_file() {
381
+ // CI, containers and the existing tests configure by env; enrolment
382
+ // must not silently override them.
383
+ let _lock = ENV_LOCK.lock().unwrap();
384
+ let _guard = with_credentials_file(Some(GOOD));
385
+ unsafe {
386
+ std::env::set_var("FAILPROOFAI_CLOUD_URL", "https://env.example");
387
+ std::env::set_var("FAILPROOFAI_CLOUD_TOKEN", "env-token");
388
+ std::env::set_var("FAILPROOFAI_MACHINE_ID", "env-machine");
389
+ }
390
+ let client = CloudClient::from_env_or_file().unwrap().expect("enrolled");
391
+ assert_eq!(client.machine_id, "env-machine");
392
+ assert_eq!(client.token, "env-token");
393
+ }
394
+
395
+ #[test]
396
+ fn falls_back_to_the_file_when_only_some_env_vars_are_set() {
397
+ // FAILPROOFAI_CLOUD_URL is the switch: without it, from_env returns
398
+ // None and the file is consulted rather than erroring.
399
+ let _lock = ENV_LOCK.lock().unwrap();
400
+ let _guard = with_credentials_file(Some(GOOD));
401
+ unsafe {
402
+ std::env::set_var("FAILPROOFAI_CLOUD_TOKEN", "stray");
403
+ }
404
+ let client = CloudClient::from_env_or_file().unwrap().expect("enrolled");
405
+ assert_eq!(client.machine_id, "m-1");
406
+ }
407
+ use sha2::{Digest, Sha256};
408
+ use std::fs;
409
+ use std::io::{Read, Write};
410
+ use std::net::TcpListener;
411
+
412
+ #[test]
413
+ fn fetches_desired_state_and_artifact_into_the_store() {
414
+ let artifact = b"export default 'managed';\n".to_vec();
415
+ let sha = format!("{:x}", Sha256::digest(&artifact));
416
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
417
+ let address = listener.local_addr().unwrap();
418
+ let expected_sha = sha.clone();
419
+ let expected_artifact = artifact.clone();
420
+ let server = std::thread::spawn(move || {
421
+ for _ in 0..2 {
422
+ let (mut stream, _) = listener.accept().unwrap();
423
+ let mut request = [0_u8; 4096];
424
+ let read = stream.read(&mut request).unwrap();
425
+ let request = String::from_utf8_lossy(&request[..read]);
426
+ assert!(
427
+ request.contains("Authorization: Bearer test-token")
428
+ || request.contains("authorization: Bearer test-token")
429
+ );
430
+ let body = if request
431
+ .starts_with("GET /enforcement/v1/desired-state?machineId=machine-1")
432
+ {
433
+ format!(r#"{{"schemaVersion":1,"generation":7,"policies":[{{"id":"guard","revision":2,"sha256":"{expected_sha}","artifactUrl":"/enforcement/v1/artifacts/{expected_sha}"}}]}}"#).into_bytes()
434
+ } else {
435
+ expected_artifact.clone()
436
+ };
437
+ let content_type = if request.contains("desired-state") {
438
+ "application/json"
439
+ } else {
440
+ "text/javascript"
441
+ };
442
+ write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: {}\r\nConnection: close\r\n\r\n", body.len(), content_type).unwrap();
443
+ stream.write_all(&body).unwrap();
444
+ }
445
+ });
446
+
447
+ let cloud = CloudClient::new(
448
+ &format!("http://{address}"),
449
+ "test-token".into(),
450
+ "machine-1".into(),
451
+ )
452
+ .unwrap();
453
+ let desired = cloud.desired_state().unwrap();
454
+ let root =
455
+ std::env::temp_dir().join(format!("failproofaid-http-test-{}", std::process::id()));
456
+ let _ = fs::remove_dir_all(&root);
457
+ let store = PolicyStore::new(root.clone());
458
+ let outcome = store
459
+ .reconcile(&desired, &|policy: &DesiredPolicy| cloud.artifact(policy))
460
+ .unwrap();
461
+ assert_eq!(outcome.generation, 7);
462
+ assert_eq!(outcome.downloaded, 1);
463
+ assert_eq!(
464
+ fs::read(root.join("generations/7/guard.mjs")).unwrap(),
465
+ artifact
466
+ );
467
+ server.join().unwrap();
468
+ fs::remove_dir_all(root).unwrap();
469
+ }
470
+
471
+ #[test]
472
+ fn rejects_cross_origin_artifacts_before_sending_the_token() {
473
+ let cloud =
474
+ CloudClient::new("https://cloud.example", "secret".into(), "machine".into()).unwrap();
475
+ let policy = DesiredPolicy {
476
+ id: "guard".into(),
477
+ revision: 1,
478
+ sha256: "0".repeat(64),
479
+ artifact_url: "https://evil.example/artifact".into(),
480
+ effect: PolicyEffect::Enforce,
481
+ };
482
+ assert!(cloud.artifact(&policy).unwrap_err().contains("outside"));
483
+ }
484
+ }