failproofai 1.0.0-beta.12 → 1.0.0-beta.13

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 (173) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  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/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js +2 -3
  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/server-reference-manifest.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page.js +2 -3
  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 +2 -2
  29. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/audit/run/route.js +2 -2
  31. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +3 -2
  34. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +2 -2
  36. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/auth/logout/route.js +2 -2
  38. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/auth/reminder/route.js +2 -2
  40. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/api/auth/status/route.js +2 -2
  42. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  45. package/.next/standalone/.next/server/app/audit/page.js +2 -3
  46. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  47. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  48. package/.next/standalone/.next/server/app/index.html +1 -1
  49. package/.next/standalone/.next/server/app/index.rsc +15 -15
  50. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  51. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  52. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  53. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  54. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  55. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  56. package/.next/standalone/.next/server/app/page.js +2 -3
  57. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +10 -10
  60. package/.next/standalone/.next/server/app/policies/page.js +2 -3
  61. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  62. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  63. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  64. package/.next/standalone/.next/server/app/project/[name]/page.js +3 -4
  65. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  68. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  69. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +3 -4
  70. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  71. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  72. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  73. package/.next/standalone/.next/server/app/projects/page.js +3 -4
  74. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  75. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  76. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  77. package/.next/standalone/.next/server/app/settings/page.js +2 -3
  78. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  79. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  80. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__1a7i2q4._.js → [root-of-the-server]__00ni9za._.js} +1 -1
  81. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08y94xv._.js +3 -0
  82. package/.next/standalone/.next/server/chunks/[root-of-the-server]__094p1f1._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_0xu5z._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ed01_d._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__1_ps0jw._.js → [root-of-the-server]__0effm9m._.js} +1 -1
  86. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0lk_935._.js → [root-of-the-server]__0ix8gm7._.js} +1 -1
  87. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0ky_n0r._.js → [root-of-the-server]__0ji11a-._.js} +1 -1
  88. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0v2fpxe._.js → [root-of-the-server]__0ojwjxl._.js} +1 -1
  89. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vb5fl2._.js +3 -0
  90. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ibjy5x._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tvn4wf._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1uyc-z4._.js +3 -0
  93. package/.next/standalone/.next/server/chunks/[root-of-the-server]__20d06rh._.js +3 -0
  94. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -3
  95. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -3
  96. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1rdruw4._.js → [root-of-the-server]__0_1tiya._.js} +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0ha3gul._.js → [root-of-the-server]__0eeipso._.js} +2 -2
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0spkm68._.js +3 -3
  100. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1tyf0tc._.js → [root-of-the-server]__17itmx-._.js} +2 -2
  101. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1io8n-9._.js → [root-of-the-server]__1dx-a-c._.js} +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1mmefsr._.js +5 -0
  103. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1yw368c._.js → [root-of-the-server]__1n2vcv4._.js} +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1rv-sc9._.js +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1a2hcb_._.js → [root-of-the-server]__1tlvv9a._.js} +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/_0rjgfln._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/{_1fahg9e._.js → _0vo2qyt._.js} +2 -2
  108. package/.next/standalone/.next/server/chunks/ssr/{_0uz7o9y._.js → _0xej-0e._.js} +2 -2
  109. package/.next/standalone/.next/server/chunks/ssr/{_04mekdy._.js → _10399ge._.js} +2 -2
  110. package/.next/standalone/.next/server/chunks/ssr/{_0se484m._.js → _149k9oy._.js} +2 -2
  111. package/.next/standalone/.next/server/chunks/ssr/{_1vsgpqt._.js → _166jvu8._.js} +1 -1
  112. package/.next/standalone/.next/server/chunks/ssr/{_0r-dsre._.js → _1_hwp06._.js} +2 -2
  113. package/.next/standalone/.next/server/chunks/ssr/{_1w8sola._.js → _1mnhi_r._.js} +2 -2
  114. package/.next/standalone/.next/server/chunks/ssr/{_1w_6p9g._.js → _1ror7k9._.js} +2 -2
  115. package/.next/standalone/.next/server/chunks/ssr/{_1gmzkwj._.js → _1sdgprl._.js} +1 -1
  116. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  117. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  118. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  119. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  120. package/.next/standalone/.next/server/pages/404.html +1 -1
  121. package/.next/standalone/.next/server/pages/500.html +1 -1
  122. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  123. package/.next/standalone/.next/server/server-reference-manifest.json +15 -15
  124. package/.next/standalone/.next/static/chunks/{2_h--2ame3r83.js → 01s3vpzky_qew.js} +1 -1
  125. package/.next/standalone/.next/static/chunks/0km-jn9o5xyqs.css +2 -0
  126. package/.next/standalone/.next/static/chunks/{23hp7hekegpe5.js → 0zbcd_n8ku2ov.js} +1 -1
  127. package/.next/standalone/.next/static/chunks/{0cakk77ppmlo7.js → 1erji0ratofla.js} +1 -1
  128. package/.next/standalone/.next/static/chunks/{40-zk44qsg5y-.js → 23hnlzqx44vbe.js} +1 -1
  129. package/.next/standalone/.next/static/chunks/{1wb8cnmzx69rz.js → 2cwsiyhc7d2cw.js} +1 -1
  130. package/.next/standalone/.next/static/chunks/{0_rdbv_k5j51p.js → 2y42ebzynyxdv.js} +1 -1
  131. package/.next/standalone/.next/static/chunks/{1spk1bub0vd-h.js → 315lncj7dsp9c.js} +1 -1
  132. package/.next/standalone/.next/static/chunks/{0-zh1umoq6ppc.js → 31_px0dfgdg9j.js} +2 -2
  133. package/.next/standalone/.next/static/chunks/{2bie7ajv70p9b.js → 3l4-fu3xt75j8.js} +1 -1
  134. package/.next/standalone/.next/static/chunks/{298v78ypf8edl.js → 3poscoxn69x9g.js} +1 -1
  135. package/.next/standalone/app/policies/hooks-client.tsx +4 -4
  136. package/.next/standalone/ino.ujh3/.failproofai/VERSION +4 -0
  137. package/.next/standalone/ino.ujh3/.failproofai/state/last-version +1 -0
  138. package/.next/standalone/ino.ujh3/.failproofai/state/telemetry-id +1 -0
  139. package/.next/standalone/package.json +10 -10
  140. package/.next/standalone/server.js +1 -1
  141. package/bin/failproofai.mjs +186 -5
  142. package/dist/cli.mjs +1130 -1352
  143. package/dist/worker.mjs +107 -1000
  144. package/package.json +10 -10
  145. package/src/hooks/backfill-cli.ts +1 -1
  146. package/src/hooks/cloud-connection.ts +4 -4
  147. package/src/hooks/cloud-enrollment-cli.ts +1 -1
  148. package/src/hooks/cloud-enrollment.ts +3 -3
  149. package/src/hooks/cloud-managed-policies.ts +60 -22
  150. package/src/hooks/configure-wizard.ts +124 -53
  151. package/src/hooks/custom-hooks-loader.ts +57 -8
  152. package/src/hooks/daemon-service.ts +66 -11
  153. package/src/hooks/flush-cli.ts +180 -0
  154. package/src/hooks/fp-config.ts +242 -118
  155. package/src/hooks/fp-home.ts +138 -22
  156. package/src/hooks/fp-reset.ts +357 -43
  157. package/src/hooks/handler.ts +11 -11
  158. package/src/hooks/harness-cli.ts +309 -0
  159. package/src/hooks/hook-activity-store.ts +5 -5
  160. package/src/hooks/manager.ts +35 -0
  161. package/src/hooks/onboarding-attempt.ts +22 -6
  162. package/src/hooks/tui.ts +64 -7
  163. package/src/hooks/uninstall-cli.ts +1 -1
  164. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06hexd0._.js +0 -3
  165. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1iny24d._.js +0 -3
  166. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tyedjt._.js +0 -3
  167. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +0 -5
  168. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1u-wx42._.js +0 -4
  169. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +0 -5
  170. package/.next/standalone/.next/static/chunks/13vrxdsdihqd_.css +0 -2
  171. /package/.next/standalone/.next/static/{-3gDrXYxNRTVwrFnqXAWd → ziAsXfNE0cH_S7LHK-Py3}/_buildManifest.js +0 -0
  172. /package/.next/standalone/.next/static/{-3gDrXYxNRTVwrFnqXAWd → ziAsXfNE0cH_S7LHK-Py3}/_clientMiddlewareManifest.js +0 -0
  173. /package/.next/standalone/.next/static/{-3gDrXYxNRTVwrFnqXAWd → ziAsXfNE0cH_S7LHK-Py3}/_ssgManifest.js +0 -0
@@ -1,28 +1,29 @@
1
1
  /**
2
- * `VERSION`, `config.toml` and `credentials.toml` — the three files a human
2
+ * `VERSION`, `config.json` and `credentials.json` — the three files a human
3
3
  * might actually open in `~/.failproofai`.
4
4
  *
5
5
  * ## Why the split
6
6
  *
7
- * `config.toml` inherits the umask and lands at 0664 on a normal machine.
8
- * `credentials.toml` is written 0600 and holds every token. That boundary is
7
+ * `config.json` inherits the umask and lands at 0664 on a normal machine.
8
+ * `credentials.json` is written 0600 and holds every token. That boundary is
9
9
  * not tidiness: it is why `ingest.json` and `cloud.json` were separate files
10
- * before this, and it means CI, tooling and a screen-shared `cat config.toml`
10
+ * before this, and it means CI, tooling and a screen-shared `cat config.json`
11
11
  * can read a machine's configuration without ever seeing a live credential.
12
12
  *
13
- * ## Why TOML here and JSON elsewhere
13
+ * ## One format, not two
14
14
  *
15
- * These three files are the ones a person edits by hand, so they get comments
16
- * and a forgiving syntax. Everything the machine writes on a hot path — the
17
- * builtin policy set, cursors, collector health, the spool — stays JSON,
18
- * parsed by the runtime's own native parser with no dependency and no import
19
- * cost. A TOML parser on the per-tool-call path would be paying a
20
- * human-ergonomics tax on a latency budget that exists precisely because
21
- * per-call work was too expensive.
15
+ * These were TOML through layout 2, for the comments — the file explained what
16
+ * `oss` meant and why `daemon.configured` must never be hand-set. Layout 3
17
+ * moved them to JSON so the home speaks one format end to end: one parser, one
18
+ * escaping rule, and no `toml` dependency in either the CLI or the daemon for
19
+ * files that were always flat key/value.
20
+ *
21
+ * The comments are the cost, and they were not decoration — this is the file
22
+ * someone opens when something is wrong. That guidance now lives in
23
+ * `failproofai config --status` and the docs, which is a worse place for it.
22
24
  */
23
25
  import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
24
26
  import { dirname } from "node:path";
25
- import { parse as parseToml } from "smol-toml";
26
27
  import { version as cliVersion } from "../../package.json";
27
28
  import {
28
29
  LAYOUT_VERSION,
@@ -47,8 +48,10 @@ export interface VersionFile {
47
48
  export type LayoutState =
48
49
  /** No home yet, or an empty one. A fresh install. */
49
50
  | { kind: "absent" }
50
- /** A home written by this layout. */
51
- | { kind: "current"; version: VersionFile }
51
+ /** A home written by this layout. `inferred` means the layout was recovered
52
+ * from a landmark because `VERSION` was missing or unreadable — the caller
53
+ * should write the marker back. */
54
+ | { kind: "current"; version: VersionFile; inferred?: boolean }
52
55
  /** A home from an older layout — must be reset, never half-read. */
53
56
  | { kind: "stale"; found: number }
54
57
  /** A home from a NEWER layout: a downgrade. Refuse rather than corrupt it. */
@@ -75,8 +78,44 @@ export function detectLayout(): LayoutState {
75
78
  : { kind: "stale", found: raw.layout };
76
79
  }
77
80
 
78
- // No VERSION file. Layout 1 if any of its landmarks are present, otherwise
79
- // this is simply a home that has not been set up yet.
81
+ // No VERSION file — fall back to landmarks.
82
+ //
83
+ // ORDER MATTERS, because layout 3 collides with layout 1 on one of them.
84
+ // Layout 3 put `policies-config.json` back at the home root, which is exactly
85
+ // where layout 1 kept it. A layout-3 home that lost its VERSION would
86
+ // therefore match a layout-1 landmark and be reported as layout 1 — and
87
+ // `resetHome(1)` runs layout-1 MIGRATIONS, which move `policies/*.mjs` on the
88
+ // assumption they are in layout 1's positions. In layout 3 those are the
89
+ // user's own convention policies, sitting where they belong.
90
+ //
91
+ // So check for a marker only the newer layouts have first — and DISTINGUISH
92
+ // them, because the two answers are not equally safe.
93
+ //
94
+ // `config.json` is layout 3's OWN file. Layout 2 wrote `config.toml`, layout 1
95
+ // had neither, and nothing but setup writes either one. So its presence does
96
+ // not merely prove "newer than layout 1" — it identifies THIS layout, and the
97
+ // only thing actually missing is the marker. Reporting it as stale instead ran
98
+ // a destructive reset over a healthy machine: `resettablePaths()` lists
99
+ // `configFile()` and `credentialsFile()`, so the home lost its live cloud
100
+ // token AND `daemon.configured` — which is what makes a machine fail closed.
101
+ // A machine that dropped out of fail-closed enforcement because a marker file
102
+ // went missing is the exact failure this module exists to prevent, and it
103
+ // announced itself as a routine "reorganised your home" message.
104
+ if (existsSync(configFile())) {
105
+ // `inferred`: the layout is right but the MARKER is missing, and nothing
106
+ // else rewrites it — so every later command re-derives it from a landmark,
107
+ // and the daemon version recorded in that file is gone for good
108
+ // (`daemonVersionSkew()` reads it, and stops nudging about a stale daemon).
109
+ // The caller re-stamps, exactly as it already does for a fresh home.
110
+ return { kind: "current", version: { layout: LAYOUT_VERSION, cli: "" }, inferred: true };
111
+ }
112
+
113
+ // `config.toml` and no `config.json` is genuinely layout 2, and a reset is
114
+ // right: its files are the ones being replaced.
115
+ if (existsSync(legacy.configToml())) return { kind: "stale", found: LAYOUT_VERSION - 1 };
116
+
117
+ // Layout 1 if any of its landmarks are present, otherwise this is simply a
118
+ // home that has not been set up yet.
80
119
  const layoutOneMarkers = [
81
120
  legacy.policyConfig(),
82
121
  legacy.cacheDir(),
@@ -89,9 +128,50 @@ export function detectLayout(): LayoutState {
89
128
  return layoutOneMarkers.some((p) => existsSync(p)) ? { kind: "stale", found: 1 } : { kind: "absent" };
90
129
  }
91
130
 
131
+ /**
132
+ * Read a layout-2 `VERSION`, which is TOML.
133
+ *
134
+ * Layout 3 made this file JSON, so `JSON.parse` throws on every layout-2 home —
135
+ * and "unreadable" is NOT a harmless answer here. It sends `detectLayout()` to
136
+ * the landmarks, and a layout-2 home whose owner never completed setup has no
137
+ * `config.toml` to match: it falls through every layout-1 marker too (layout 2
138
+ * nested `policies-config.json`, so the root file layout 1 is recognised by is
139
+ * absent) and reads as `absent` — a FRESH INSTALL. The CLI then stamps it
140
+ * layout 3 with no reset and no migration, and
141
+ * `policies/local-policies/policies-config.json` plus
142
+ * `policies/custom-policies/*.mjs` are orphaned permanently: the user's enabled
143
+ * policies revert to the defaults, their own policy files stop loading, and
144
+ * nothing says so. Reproduced exactly that way on a seeded home.
145
+ *
146
+ * A regex rather than a TOML parser because removing that dependency is half
147
+ * the point of layout 3, and this file is three flat `key = value` lines that
148
+ * this codebase itself wrote.
149
+ *
150
+ * ALL THREE fields are read, including `daemon`. It is tempting to take only the
151
+ * layout number on the grounds that the home is about to be reset — but
152
+ * `writeVersionFile()` carries the daemon version forward from `existing?.daemon`,
153
+ * so a field missing here is not re-derived, it is ERASED. The machine then
154
+ * reports "daemon not installed" with the binary sitting in `bin/`, and
155
+ * `daemonVersionSkew()` stops nudging about a stale daemon precisely when an
156
+ * upgrade has just happened.
157
+ */
158
+ function readLegacyTomlVersion(text: string): VersionFile | null {
159
+ const layout = Number(/^\s*layout\s*=\s*(\d+)\s*$/m.exec(text)?.[1]);
160
+ if (!Number.isFinite(layout)) return null;
161
+ const str = (key: string) => new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, "m").exec(text)?.[1];
162
+ return { layout, cli: str("cli") ?? "", daemon: str("daemon") };
163
+ }
164
+
92
165
  export function readVersionFile(): VersionFile | null {
166
+ let text: string;
93
167
  try {
94
- const parsed = parseToml(readFileSync(versionFile(), "utf8")) as Record<string, unknown>;
168
+ text = readFileSync(versionFile(), "utf8");
169
+ } catch {
170
+ // Absent or unreadable. The landmark check decides what that implies.
171
+ return null;
172
+ }
173
+ try {
174
+ const parsed = JSON.parse(text) as Record<string, unknown>;
95
175
  const layout = Number(parsed.layout);
96
176
  if (!Number.isFinite(layout)) return null;
97
177
  return {
@@ -100,9 +180,10 @@ export function readVersionFile(): VersionFile | null {
100
180
  daemon: typeof parsed.daemon === "string" ? parsed.daemon : undefined,
101
181
  };
102
182
  } catch {
103
- // Absent, unreadable, or malformed. All three mean "cannot prove a
104
- // layout", and the landmark check above decides what that implies.
105
- return null;
183
+ // Not JSON. Before concluding "no layout", try the one other format this
184
+ // file has ever had — see `readLegacyTomlVersion` for what guessing wrong
185
+ // costs. Genuinely corrupt content still yields null from there.
186
+ return readLegacyTomlVersion(text);
106
187
  }
107
188
  }
108
189
 
@@ -118,17 +199,17 @@ export function writeVersionFile(
118
199
  // explicit act, used on uninstall.
119
200
  daemon: v.clearDaemon ? undefined : (v.daemon ?? existing?.daemon),
120
201
  };
121
- const lines = [
122
- "# Written by failproofai. `layout` is what tells a newer CLI whether this",
123
- "# directory can be read as-is; do not edit it by hand.",
124
- `layout = ${next.layout}`,
125
- `cli = ${JSON.stringify(next.cli)}`,
126
- ];
127
- if (next.daemon) lines.push(`daemon = ${JSON.stringify(next.daemon)}`);
128
- writeFileAt(versionFile(), lines.join("\n") + "\n");
202
+ writeFileAt(
203
+ versionFile(),
204
+ `${JSON.stringify(
205
+ { layout: next.layout, cli: next.cli, ...(next.daemon ? { daemon: next.daemon } : {}) },
206
+ null,
207
+ 2,
208
+ )}\n`,
209
+ );
129
210
  }
130
211
 
131
- // ── config.toml ──────────────────────────────────────────────────────────────
212
+ // ── config.json ──────────────────────────────────────────────────────────────
132
213
 
133
214
  /**
134
215
  * How this machine relates to Failproof Cloud.
@@ -159,6 +240,25 @@ export interface FpConfig {
159
240
  redact: "minimal" | "off";
160
241
  environment: string;
161
242
  machineId?: string;
243
+ /**
244
+ * Extra locations to capture per harness, beyond the one each source ships
245
+ * with — `[collector.sources.<harness>] extra_paths` in `config.toml`.
246
+ *
247
+ * Each entry is `label=path` or a bare `path`; the label becomes an agent-id
248
+ * namespace (`<label>-<agentId>`), which is what keeps two copies of one
249
+ * project from merging into a single agent. The daemon is the authority on
250
+ * the grammar and on which harness names are real — see
251
+ * `crates/fpai-collect/src/extra_paths.rs` and `HARNESS_KEYS` in
252
+ * `crates/failproofaid/src/main.rs`. This side stores and edits the strings;
253
+ * it deliberately does not re-implement the parser, because two parsers is
254
+ * how the CLI comes to accept a path the daemon then silently drops.
255
+ *
256
+ * Absent for every machine that has not configured one, which is almost all
257
+ * of them — so `writeConfig` emits nothing at all in that case and a config
258
+ * file is byte-identical to what this version wrote before the field
259
+ * existed.
260
+ */
261
+ sources?: Record<string, { extraPaths: string[] }>;
162
262
  };
163
263
  telemetry: {
164
264
  /**
@@ -234,17 +334,50 @@ export const DEFAULT_CONFIG: FpConfig = {
234
334
  audit: { auto: false, intervalDays: DEFAULT_AUDIT_INTERVAL_DAYS },
235
335
  };
236
336
 
337
+ /**
338
+ * `[collector.sources.*]` → the in-memory shape, dropping anything unusable.
339
+ *
340
+ * Entries are kept as written rather than normalised. The daemon parses them
341
+ * (label derivation, overlap rejection, `~` expansion) and reports what it
342
+ * rejected at startup; normalising here would give the two sides two answers
343
+ * and hide the daemon's rejection behind a value the CLI had already rewritten.
344
+ * The only things dropped are shapes that could not survive a round trip at
345
+ * all — a non-array, or a non-string element.
346
+ *
347
+ * `undefined` rather than `{}` when nothing is configured, so `writeConfig`
348
+ * emits no table and an untouched config file keeps its exact previous bytes.
349
+ */
350
+ function readSources(raw: unknown): FpConfig["collector"]["sources"] {
351
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
352
+ const out: Record<string, { extraPaths: string[] }> = {};
353
+ for (const [name, table] of Object.entries(raw as Record<string, unknown>)) {
354
+ if (!table || typeof table !== "object" || Array.isArray(table)) continue;
355
+ const entries = (table as Record<string, unknown>).extra_paths;
356
+ if (!Array.isArray(entries)) continue;
357
+ const paths = entries.filter((e): e is string => typeof e === "string" && e.trim() !== "");
358
+ if (paths.length > 0) out[name] = { extraPaths: paths };
359
+ }
360
+ return Object.keys(out).length > 0 ? out : undefined;
361
+ }
362
+
237
363
  export function readConfig(): FpConfig {
238
364
  try {
239
- const parsed = parseToml(readFileSync(configFile(), "utf8")) as Record<string, unknown>;
365
+ const parsed = JSON.parse(readFileSync(configFile(), "utf8")) as Record<string, unknown>;
240
366
  const mode = (parsed.mode as Record<string, unknown> | undefined)?.kind;
241
367
  const daemon = (parsed.daemon ?? {}) as Record<string, unknown>;
242
368
  const collector = (parsed.collector ?? {}) as Record<string, unknown>;
243
369
  const telemetry = (parsed.telemetry ?? {}) as Record<string, unknown>;
244
370
  const audit = (parsed.audit ?? {}) as Record<string, unknown>;
245
371
  return {
246
- // Anything unrecognised reads as `oss`. The failure direction matters:
247
- // a corrupt config must not be able to turn cloud reporting ON.
372
+ // Anything unrecognised reads as `oss`. The failure direction matters: a
373
+ // corrupt config must not be able to turn cloud reporting ON.
374
+ //
375
+ // This is a real check rather than `?? "oss"`, which only caught a MISSING
376
+ // mode and passed every present-but-unknown value straight through — so
377
+ // `"enterprise"`, or a typo like `"clod"`, became the machine's mode. The
378
+ // test named for this branch was writing a TOML body into a `.json` file,
379
+ // so the parse failed, the whole function fell to its defaults, and the
380
+ // assertion passed without the branch ever running.
248
381
  mode: mode === "cloud" ? "cloud" : "oss",
249
382
  daemon: { configured: daemon.configured === true },
250
383
  collector: {
@@ -258,6 +391,7 @@ export function readConfig(): FpConfig {
258
391
  environment:
259
392
  typeof collector.environment === "string" ? collector.environment : "local",
260
393
  machineId: typeof collector.machine_id === "string" ? collector.machine_id : undefined,
394
+ sources: readSources(collector.sources),
261
395
  },
262
396
  // Only an explicit `false` switches it off. Absent, or any other value,
263
397
  // reads as on — the shipped default, and what a config with no
@@ -278,62 +412,61 @@ export function readConfig(): FpConfig {
278
412
  }
279
413
  }
280
414
 
415
+ /**
416
+ * Regenerates `config.json` wholesale.
417
+ *
418
+ * The layout-2 file was hand-written TOML carrying its own documentation — what
419
+ * `oss` meant, why `daemon.configured` must never be hand-set. JSON cannot hold
420
+ * a comment, so that guidance now lives in `failproofai config --status` and the
421
+ * docs. It is a real loss on the one file a user opens when something is wrong;
422
+ * the trade is one serialisation format across the whole home instead of two.
423
+ */
281
424
  export function writeConfig(config: FpConfig): void {
282
425
  const c = config.collector;
283
- const lines = [
284
- "# failproofai configuration. Safe to edit by hand.",
285
- "# Credentials are NOT here — see credentials.toml (owner-only).",
286
- "",
287
- "[mode]",
288
- "# \"oss\" — fully local. No transcripts, hook activity or policy leave",
289
- "# this machine.",
290
- "# \"cloud\" — reports to, and receives policy from, Failproof Cloud.",
291
- `kind = ${JSON.stringify(config.mode)}`,
292
- "",
293
- "[daemon]",
294
- "# When true, hooks route through failproofaid AND FAIL CLOSED if it is",
295
- "# unreachable. Never set this by hand: `failproofai config` sets it only",
296
- "# after verifying the service is genuinely running.",
297
- `configured = ${config.daemon.configured}`,
298
- ];
299
- lines.push(
300
- "",
301
- "[collector]",
302
- "# Session transcripts carry prompts, file contents and command output.",
303
- `sessions = ${c.sessions}`,
304
- "# Hook decisions: which policy fired and what it decided. No file contents.",
305
- `hooks = ${c.hooks}`,
306
- `hooks_verbosity = ${JSON.stringify(c.hooksVerbosity)}`,
307
- `redact = ${JSON.stringify(c.redact)}`,
308
- `environment = ${JSON.stringify(c.environment)}`,
309
- );
310
- if (c.machineId) lines.push(`machine_id = ${JSON.stringify(c.machineId)}`);
311
- // Written ONLY when switched off. A default install therefore carries no
312
- // [telemetry] block at all, but an operator who added one by hand keeps it:
313
- // writeConfig regenerates this file wholesale, so emitting the key only when
314
- // it is set is what stops a later rewrite from silently switching telemetry
315
- // back on underneath them.
316
- if (!config.telemetry.enabled) {
317
- lines.push("", "[telemetry]", "enabled = false");
318
- }
319
- // Written ALWAYS, unlike [telemetry] directly above — the two are opposites
320
- // on purpose. Telemetry ships on and is deliberately not advertised in the
321
- // file; the scheduled audit ships off and is meant to be FOUND, and a switch
322
- // nobody can see is the same as a switch that does not exist. Emitting both
323
- // keys unconditionally also makes "a user's setting survives a rewrite" total
324
- // rather than conditional: every field the struct carries is on disk, so
325
- // there is no value of this block that a later regeneration can drop.
326
- lines.push(
327
- "",
328
- "[audit]",
329
- "# Scan this machine's agent history on a schedule and refresh the audit",
330
- "# dashboard. OFF by default: the scan reads the CONTENTS of every session",
331
- "# transcript on disk, so it waits to be asked. It runs as a separate",
332
- "# short-lived process — never on the hook path.",
333
- `auto = ${config.audit.auto}`,
334
- `interval_days = ${config.audit.intervalDays}`,
335
- );
336
- writeFileAt(configFile(), lines.join("\n") + "\n");
426
+ const out: Record<string, unknown> = {
427
+ mode: { kind: config.mode },
428
+ daemon: { configured: config.daemon.configured },
429
+ collector: {
430
+ sessions: c.sessions,
431
+ hooks: c.hooks,
432
+ hooks_verbosity: c.hooksVerbosity,
433
+ redact: c.redact,
434
+ environment: c.environment,
435
+ // Omitted when unset rather than written null: the Rust side treats an
436
+ // absent machine_id as "derive one", and an explicit null is not that.
437
+ ...(c.machineId ? { machine_id: c.machineId } : {}),
438
+ // Extra capture paths per harness. Emitted ONLY when non-empty, so a
439
+ // machine that never configured one gets a file byte-identical to what
440
+ // this function produced before the field existed — the same reasoning as
441
+ // `telemetry` below. (In TOML this also had to come after every scalar of
442
+ // `[collector]`, or the scalars after it read as belonging to the
443
+ // sub-table. JSON has no such ordering hazard, which is one of the things
444
+ // the format change buys.)
445
+ ...(Object.entries(c.sources ?? {}).some(([, s]) => s.extraPaths?.length)
446
+ ? {
447
+ sources: Object.fromEntries(
448
+ Object.entries(c.sources ?? {})
449
+ .filter(([, s]) => s.extraPaths?.length)
450
+ .map(([name, s]) => [name, { extra_paths: s.extraPaths }]),
451
+ ),
452
+ }
453
+ : {}),
454
+ },
455
+ // Written ONLY when switched off. A default install therefore carries no
456
+ // telemetry block at all, but an operator who switched it off keeps it:
457
+ // this function regenerates the file wholesale, so emitting the key only
458
+ // when it is set is what stops a later rewrite from silently switching
459
+ // telemetry back on underneath them.
460
+ ...(config.telemetry.enabled ? {} : { telemetry: { enabled: false } }),
461
+ // Written ALWAYS, unlike telemetry directly above — the two are opposites
462
+ // on purpose. Telemetry ships on and is deliberately not advertised;
463
+ // the scheduled audit ships off and is meant to be FOUND, and a switch
464
+ // nobody can see is the same as a switch that does not exist. Emitting both
465
+ // keys unconditionally also makes "a user's setting survives a rewrite"
466
+ // total rather than conditional.
467
+ audit: { auto: config.audit.auto, interval_days: config.audit.intervalDays },
468
+ };
469
+ writeFileAt(configFile(), `${JSON.stringify(out, null, 2)}\n`);
337
470
  }
338
471
 
339
472
  /** Merge a partial update into the config on disk. */
@@ -356,7 +489,7 @@ export function updateConfig(patch: {
356
489
  return next;
357
490
  }
358
491
 
359
- // ── credentials.toml ─────────────────────────────────────────────────────────
492
+ // ── credentials.json ─────────────────────────────────────────────────────────
360
493
 
361
494
  export interface FpCredentials {
362
495
  cloud?: { url: string; machineId: string; token: string; machineLabel?: string };
@@ -382,7 +515,7 @@ export interface FpCredentials {
382
515
 
383
516
  export function readCredentials(): FpCredentials {
384
517
  try {
385
- const parsed = parseToml(readFileSync(credentialsFile(), "utf8")) as Record<string, unknown>;
518
+ const parsed = JSON.parse(readFileSync(credentialsFile(), "utf8")) as Record<string, unknown>;
386
519
  const cloud = parsed.cloud as Record<string, unknown> | undefined;
387
520
  const ingest = parsed.ingest as Record<string, unknown> | undefined;
388
521
  const auth = parsed.auth as Record<string, unknown> | undefined;
@@ -433,41 +566,32 @@ export function readCredentials(): FpCredentials {
433
566
  * without the explicit chmod.
434
567
  */
435
568
  export function writeCredentials(creds: FpCredentials): void {
436
- const lines: string[] = [
437
- "# failproofai credentials — owner-only (0600). Do not commit.",
438
- ];
569
+ const out: Record<string, unknown> = {};
439
570
  if (creds.cloud) {
440
- lines.push(
441
- "",
442
- "[cloud]",
443
- `url = ${JSON.stringify(creds.cloud.url)}`,
444
- `machine_id = ${JSON.stringify(creds.cloud.machineId)}`,
445
- ...(creds.cloud.machineLabel
446
- ? [`machine_label = ${JSON.stringify(creds.cloud.machineLabel)}`]
447
- : []),
448
- `token = ${JSON.stringify(creds.cloud.token)}`,
449
- );
571
+ out.cloud = {
572
+ url: creds.cloud.url,
573
+ machine_id: creds.cloud.machineId,
574
+ ...(creds.cloud.machineLabel ? { machine_label: creds.cloud.machineLabel } : {}),
575
+ token: creds.cloud.token,
576
+ };
450
577
  }
451
578
  if (creds.ingest) {
452
- lines.push(
453
- "",
454
- "[ingest]",
455
- `url = ${JSON.stringify(creds.ingest.url)}`,
456
- `key = ${JSON.stringify(creds.ingest.key)}`,
457
- );
579
+ out.ingest = { url: creds.ingest.url, key: creds.ingest.key };
458
580
  }
459
581
  if (creds.org && (creds.org.id || creds.org.slug || creds.org.name)) {
460
- lines.push("", "[org]");
461
- if (creds.org.id) lines.push(`id = ${JSON.stringify(creds.org.id)}`);
462
- if (creds.org.slug) lines.push(`slug = ${JSON.stringify(creds.org.slug)}`);
463
- if (creds.org.name) lines.push(`name = ${JSON.stringify(creds.org.name)}`);
582
+ out.org = {
583
+ ...(creds.org.id ? { id: creds.org.id } : {}),
584
+ ...(creds.org.slug ? { slug: creds.org.slug } : {}),
585
+ ...(creds.org.name ? { name: creds.org.name } : {}),
586
+ };
464
587
  }
465
588
  if (creds.auth && (creds.auth.sessionToken || creds.auth.email)) {
466
- lines.push("", "[auth]");
467
- if (creds.auth.baseUrl) lines.push(`base_url = ${JSON.stringify(creds.auth.baseUrl)}`);
468
- if (creds.auth.sessionToken) lines.push(`session_token = ${JSON.stringify(creds.auth.sessionToken)}`);
469
- if (creds.auth.expiresAt) lines.push(`expires_at = ${creds.auth.expiresAt}`);
470
- if (creds.auth.email) lines.push(`email = ${JSON.stringify(creds.auth.email)}`);
589
+ out.auth = {
590
+ ...(creds.auth.baseUrl ? { base_url: creds.auth.baseUrl } : {}),
591
+ ...(creds.auth.sessionToken ? { session_token: creds.auth.sessionToken } : {}),
592
+ ...(creds.auth.expiresAt ? { expires_at: creds.auth.expiresAt } : {}),
593
+ ...(creds.auth.email ? { email: creds.auth.email } : {}),
594
+ };
471
595
  }
472
596
 
473
597
  const home = failproofaiHome();
@@ -477,7 +601,7 @@ export function writeCredentials(creds: FpCredentials): void {
477
601
  } catch {
478
602
  // Not fatal: the file's own 0600 is the primary protection.
479
603
  }
480
- writeFileSync(credentialsFile(), lines.join("\n") + "\n", { mode: 0o600 });
604
+ writeFileSync(credentialsFile(), `${JSON.stringify(out, null, 2)}\n`, { mode: 0o600 });
481
605
  try {
482
606
  chmodSync(credentialsFile(), 0o600);
483
607
  } catch {