sealkeep 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +223 -1
  3. package/CONTROL_PLANE.md +2 -2
  4. package/LICENSE +1 -1
  5. package/README.md +108 -22
  6. package/THIRD_PARTY.md +2 -2
  7. package/THREAT_MODEL.md +23 -4
  8. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
  9. package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
  10. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
  11. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
  12. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
  13. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
  14. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
  15. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +4 -4
  16. package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
  17. package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
  18. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
  19. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
  20. package/dist/site/index.html +1808 -1904
  21. package/dist/site/llms.txt +67 -0
  22. package/dist/site/trust/architecture-data-flow.html +53 -0
  23. package/dist/site/trust/audit-roadmap.html +37 -0
  24. package/dist/site/trust/deployment-responsibility.html +11 -0
  25. package/dist/site/trust/dpa-sample.html +30 -0
  26. package/dist/site/trust/release-provenance.html +21 -0
  27. package/dist/site/trust/subprocessors.html +15 -0
  28. package/dist/site/trust/threat-model.html +34 -0
  29. package/dist/site/trust/toms.html +41 -0
  30. package/dist/site/trust-document.css +32 -0
  31. package/dist/site/trust.html +73 -0
  32. package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
  33. package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
  34. package/dist/site/visual/index.html +18 -0
  35. package/dist/site.zip +0 -0
  36. package/dist/src/activity.d.ts +9 -0
  37. package/dist/src/activity.js +90 -1
  38. package/dist/src/adapters.d.ts +175 -5
  39. package/dist/src/adapters.js +961 -73
  40. package/dist/src/agent-context.d.ts +135 -0
  41. package/dist/src/agent-context.js +1059 -0
  42. package/dist/src/archive-copies.d.ts +47 -0
  43. package/dist/src/archive-copies.js +179 -0
  44. package/dist/src/audit.d.ts +1 -1
  45. package/dist/src/audit.js +29 -4
  46. package/dist/src/autopilot.d.ts +52 -7
  47. package/dist/src/autopilot.js +143 -25
  48. package/dist/src/background-bandwidth.d.ts +46 -0
  49. package/dist/src/background-bandwidth.js +301 -0
  50. package/dist/src/background-cpu.d.ts +82 -0
  51. package/dist/src/background-cpu.js +212 -0
  52. package/dist/src/background-worker-error.d.ts +12 -0
  53. package/dist/src/background-worker-error.js +18 -0
  54. package/dist/src/branding.d.ts +24 -1
  55. package/dist/src/branding.js +31 -1
  56. package/dist/src/bridge.d.ts +233 -0
  57. package/dist/src/bridge.js +604 -0
  58. package/dist/src/byte-stream.d.ts +91 -0
  59. package/dist/src/byte-stream.js +385 -0
  60. package/dist/src/chunk-store.d.ts +41 -8
  61. package/dist/src/chunk-store.js +161 -65
  62. package/dist/src/cli.js +1698 -163
  63. package/dist/src/cloud.d.ts +841 -31
  64. package/dist/src/cloud.js +3196 -277
  65. package/dist/src/context-background.d.ts +37 -0
  66. package/dist/src/context-background.js +309 -0
  67. package/dist/src/context-drain-child.d.ts +1 -0
  68. package/dist/src/context-drain-child.js +98 -0
  69. package/dist/src/context-reader.d.ts +118 -0
  70. package/dist/src/context-reader.js +447 -0
  71. package/dist/src/control-plane/auth.d.ts +32 -4
  72. package/dist/src/control-plane/auth.js +85 -24
  73. package/dist/src/control-plane/server.js +19 -6
  74. package/dist/src/control-plane.d.ts +17 -1
  75. package/dist/src/control-plane.js +32 -6
  76. package/dist/src/crypto.d.ts +1 -1
  77. package/dist/src/crypto.js +5 -5
  78. package/dist/src/daemon-lease.d.ts +70 -0
  79. package/dist/src/daemon-lease.js +420 -0
  80. package/dist/src/daemon.d.ts +94 -1
  81. package/dist/src/daemon.js +1082 -105
  82. package/dist/src/darwin-service-policy.d.ts +41 -0
  83. package/dist/src/darwin-service-policy.js +60 -0
  84. package/dist/src/dashboard-cli.js +15 -15
  85. package/dist/src/device-authorization.d.ts +37 -0
  86. package/dist/src/device-authorization.js +199 -0
  87. package/dist/src/device-enrollment.d.ts +91 -0
  88. package/dist/src/device-enrollment.js +349 -0
  89. package/dist/src/disk.d.ts +17 -12
  90. package/dist/src/disk.js +43 -17
  91. package/dist/src/doctor.d.ts +35 -1
  92. package/dist/src/doctor.js +316 -41
  93. package/dist/src/durable-ticket-lock.d.ts +24 -0
  94. package/dist/src/durable-ticket-lock.js +232 -0
  95. package/dist/src/enroll.d.ts +1 -1
  96. package/dist/src/enroll.js +13 -7
  97. package/dist/src/env.d.ts +10 -1
  98. package/dist/src/env.js +11 -3
  99. package/dist/src/errors.d.ts +8 -8
  100. package/dist/src/errors.js +6 -6
  101. package/dist/src/flush.d.ts +12 -0
  102. package/dist/src/flush.js +37 -0
  103. package/dist/src/heartbeat.d.ts +86 -12
  104. package/dist/src/heartbeat.js +415 -29
  105. package/dist/src/index-background-watchdog.d.ts +1 -0
  106. package/dist/src/index-background-watchdog.js +94 -0
  107. package/dist/src/index-background-work.d.ts +21 -0
  108. package/dist/src/index-background-work.js +25 -0
  109. package/dist/src/index-background.d.ts +64 -0
  110. package/dist/src/index-background.js +394 -0
  111. package/dist/src/index-build-child.d.ts +1 -0
  112. package/dist/src/index-build-child.js +109 -0
  113. package/dist/src/index-manifest.d.ts +52 -0
  114. package/dist/src/index-manifest.js +444 -0
  115. package/dist/src/index-publication-proof.d.ts +84 -0
  116. package/dist/src/index-publication-proof.js +380 -0
  117. package/dist/src/index-publication-state.d.ts +149 -0
  118. package/dist/src/index-publication-state.js +696 -0
  119. package/dist/src/index-publication-verifier.d.ts +89 -0
  120. package/dist/src/index-publication-verifier.js +341 -0
  121. package/dist/src/index-publish.d.ts +62 -0
  122. package/dist/src/index-publish.js +540 -0
  123. package/dist/src/index-scratch-cleanup.d.ts +19 -0
  124. package/dist/src/index-scratch-cleanup.js +166 -0
  125. package/dist/src/index-segment-types.d.ts +132 -0
  126. package/dist/src/index-segment-types.js +21 -0
  127. package/dist/src/index-segments.d.ts +9 -0
  128. package/dist/src/index-segments.js +516 -0
  129. package/dist/src/index-store.d.ts +123 -0
  130. package/dist/src/index-store.js +495 -0
  131. package/dist/src/index-sync.d.ts +91 -15
  132. package/dist/src/index-sync.js +286 -53
  133. package/dist/src/index-upgrade-publication.d.ts +30 -0
  134. package/dist/src/index-upgrade-publication.js +179 -0
  135. package/dist/src/integration-manager.d.ts +32 -0
  136. package/dist/src/integration-manager.js +394 -0
  137. package/dist/src/leakscan.js +1 -1
  138. package/dist/src/local-api.d.ts +56 -15
  139. package/dist/src/local-api.js +4979 -541
  140. package/dist/src/machine-settings.d.ts +51 -0
  141. package/dist/src/machine-settings.js +166 -0
  142. package/dist/src/managed-chunks.d.ts +5 -2
  143. package/dist/src/managed-chunks.js +14 -14
  144. package/dist/src/mcp-install.d.ts +11 -9
  145. package/dist/src/mcp-install.js +73 -28
  146. package/dist/src/mcp-workspace.d.ts +18 -0
  147. package/dist/src/mcp-workspace.js +50 -0
  148. package/dist/src/mcp.js +294 -25
  149. package/dist/src/migrate.js +27 -21
  150. package/dist/src/notify.d.ts +1 -1
  151. package/dist/src/notify.js +5 -5
  152. package/dist/src/offload.d.ts +201 -14
  153. package/dist/src/offload.js +1848 -140
  154. package/dist/src/onboarding.d.ts +8 -1
  155. package/dist/src/onboarding.js +4 -4
  156. package/dist/src/packages.d.ts +2 -2
  157. package/dist/src/packages.js +10 -2
  158. package/dist/src/passkey.d.ts +0 -1
  159. package/dist/src/passkey.js +2 -7
  160. package/dist/src/password-lock.d.ts +2 -2
  161. package/dist/src/password-lock.js +6 -6
  162. package/dist/src/paths.d.ts +2 -0
  163. package/dist/src/paths.js +2 -0
  164. package/dist/src/presence.d.ts +86 -0
  165. package/dist/src/presence.js +240 -0
  166. package/dist/src/progress-deadline.d.ts +21 -0
  167. package/dist/src/progress-deadline.js +91 -0
  168. package/dist/src/project-repair.d.ts +55 -0
  169. package/dist/src/project-repair.js +131 -0
  170. package/dist/src/providers/gcs.d.ts +28 -7
  171. package/dist/src/providers/gcs.js +35 -24
  172. package/dist/src/providers/gdrive.d.ts +71 -8
  173. package/dist/src/providers/gdrive.js +223 -50
  174. package/dist/src/providers/index.d.ts +11 -3
  175. package/dist/src/providers/index.js +30 -10
  176. package/dist/src/providers/s3.d.ts +30 -8
  177. package/dist/src/providers/s3.js +41 -30
  178. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  179. package/dist/src/providers/safe-storage-fetch.js +72 -0
  180. package/dist/src/queue.d.ts +136 -19
  181. package/dist/src/queue.js +862 -96
  182. package/dist/src/reclaim-transaction.d.ts +156 -0
  183. package/dist/src/reclaim-transaction.js +1027 -0
  184. package/dist/src/recovery-codes.d.ts +32 -0
  185. package/dist/src/recovery-codes.js +338 -0
  186. package/dist/src/recovery.js +12 -9
  187. package/dist/src/rehydrate.d.ts +25 -22
  188. package/dist/src/rehydrate.js +319 -23
  189. package/dist/src/restore.d.ts +57 -4
  190. package/dist/src/restore.js +272 -36
  191. package/dist/src/resume-stub.d.ts +92 -0
  192. package/dist/src/resume-stub.js +417 -0
  193. package/dist/src/retention.d.ts +98 -7
  194. package/dist/src/retention.js +1066 -62
  195. package/dist/src/rotate.js +3 -3
  196. package/dist/src/search.d.ts +566 -8
  197. package/dist/src/search.js +5897 -290
  198. package/dist/src/secrets.d.ts +51 -7
  199. package/dist/src/secrets.js +316 -24
  200. package/dist/src/service.d.ts +49 -11
  201. package/dist/src/service.js +776 -35
  202. package/dist/src/share.js +3 -3
  203. package/dist/src/shared-spaces.d.ts +98 -0
  204. package/dist/src/shared-spaces.js +214 -0
  205. package/dist/src/source-reader.d.ts +73 -0
  206. package/dist/src/source-reader.js +715 -0
  207. package/dist/src/spool.d.ts +1 -1
  208. package/dist/src/spool.js +1 -1
  209. package/dist/src/start-tui.js +2 -1
  210. package/dist/src/start.js +2 -2
  211. package/dist/src/storage-endpoint.d.ts +21 -0
  212. package/dist/src/storage-endpoint.js +122 -0
  213. package/dist/src/storage-setup.js +12 -12
  214. package/dist/src/storage-targets.d.ts +109 -6
  215. package/dist/src/storage-targets.js +975 -67
  216. package/dist/src/stream-to-cloud.d.ts +5 -1
  217. package/dist/src/stream-to-cloud.js +34 -14
  218. package/dist/src/sync-rules.d.ts +31 -6
  219. package/dist/src/sync-rules.js +153 -14
  220. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  221. package/dist/src/team-backfill-scheduling.js +33 -0
  222. package/dist/src/team-backfill.d.ts +116 -0
  223. package/dist/src/team-backfill.js +1429 -0
  224. package/dist/src/team-index-cache.d.ts +16 -0
  225. package/dist/src/team-index-cache.js +152 -0
  226. package/dist/src/team-offboarding.d.ts +38 -0
  227. package/dist/src/team-offboarding.js +1043 -0
  228. package/dist/src/team-presence.d.ts +127 -0
  229. package/dist/src/team-presence.js +904 -0
  230. package/dist/src/team-publication-policy.d.ts +20 -0
  231. package/dist/src/team-publication-policy.js +140 -0
  232. package/dist/src/team-realtime.d.ts +68 -0
  233. package/dist/src/team-realtime.js +816 -0
  234. package/dist/src/team-source-facts-cache.d.ts +23 -0
  235. package/dist/src/team-source-facts-cache.js +255 -0
  236. package/dist/src/trash.d.ts +1 -1
  237. package/dist/src/trash.js +2 -2
  238. package/dist/src/tui.js +11 -12
  239. package/dist/src/types.d.ts +173 -7
  240. package/dist/src/types.js +20 -0
  241. package/dist/src/ui-server.d.ts +163 -35
  242. package/dist/src/ui-server.js +712 -72
  243. package/dist/src/ui.d.ts +1 -2
  244. package/dist/src/ui.js +1 -2
  245. package/dist/src/upload.d.ts +27 -0
  246. package/dist/src/upload.js +383 -43
  247. package/dist/src/vault.d.ts +226 -30
  248. package/dist/src/vault.js +1776 -192
  249. package/dist/src/watcher.d.ts +7 -1
  250. package/dist/src/watcher.js +198 -55
  251. package/dist/src/worker.d.ts +27 -3
  252. package/dist/src/worker.js +274 -55
  253. package/package.json +33 -12
  254. package/scripts/native-reboot-rehearsal.mjs +90 -0
  255. package/web/app.js +6032 -343
  256. package/web/bootstrap.js +17 -0
  257. package/web/index.html +255 -57
  258. package/web/rail.js +317 -40
  259. package/web/retention.html +2 -2
  260. package/web/rules-view.js +188 -16
  261. package/web/sessions-view.js +485 -62
  262. package/web/sessions.html +2 -2
  263. package/web/setup-api.js +152 -29
  264. package/web/setup-logic.js +68 -9
  265. package/web/setup.html +113 -44
  266. package/web/setup.js +604 -71
  267. package/web/style.css +513 -98
  268. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  269. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
  273. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
@@ -6,8 +6,49 @@ import { dirname, join } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { fail } from "./errors.js";
8
8
  import { envVar } from "./env.js";
9
+ import { INTEGRATION_MANAGER_SOURCE } from "./integration-manager.js";
9
10
  const run = promisify(execFile);
10
- export const SERVICE_LABEL = "ai.vaultline.agent";
11
+ export const SERVICE_LABEL = "ai.sealkeep.agent";
12
+ /**
13
+ * What this service was called before the rename, and why that is not just
14
+ * trivia.
15
+ *
16
+ * A launchd agent is not owned by the file that wrote it — it is owned by the
17
+ * OS, under a label, until something tells the OS to let go. Rename the label
18
+ * and install, and the old agent does not disappear: it keeps loading at every
19
+ * login, running an executable that may no longer exist, next to the new one.
20
+ * So every install and every uninstall tears the old label down first.
21
+ */
22
+ export const LEGACY_SERVICE_LABEL = "ai.vaultline.agent";
23
+ export const LEGACY_SYSTEMD_UNIT = "vaultline.service";
24
+ /**
25
+ * The user config root a service definition belongs in.
26
+ *
27
+ * `XDG_CONFIG_HOME` names the CURRENT user's config root, so it may only
28
+ * answer for the machine's own home. A caller that names a different home —
29
+ * a test fixture, tooling that manages another account — must get that home's
30
+ * `.config`, or every such caller collides on one path: on a CI runner, which
31
+ * exports XDG_CONFIG_HOME, each install found the previous vault's unit file
32
+ * already there and refused as "owned by a different Sealkeep vault".
33
+ */
34
+ function userConfigRoot(home) {
35
+ const real = homedir();
36
+ return home === real ? (process.env.XDG_CONFIG_HOME ?? join(home, ".config")) : join(home, ".config");
37
+ }
38
+ /** The commands that retire whatever the previous name left running. */
39
+ function legacyTeardown(kind, home) {
40
+ if (kind === "launchd") {
41
+ const path = join(home, "Library", "LaunchAgents", `${LEGACY_SERVICE_LABEL}.plist`);
42
+ return { commands: [["launchctl", ["unload", "-w", path]]], paths: [path] };
43
+ }
44
+ if (kind === "systemd") {
45
+ const path = join(userConfigRoot(home), "systemd", "user", LEGACY_SYSTEMD_UNIT);
46
+ return { commands: [["systemctl", ["--user", "disable", "--now", LEGACY_SYSTEMD_UNIT]]], paths: [path] };
47
+ }
48
+ if (kind === "schtasks")
49
+ return { commands: [["schtasks", ["/delete", "/f", "/tn", "Vaultline"]]], paths: [] };
50
+ return { commands: [], paths: [] };
51
+ }
11
52
  function kindFor(target) {
12
53
  if (target === "darwin")
13
54
  return "launchd";
@@ -32,6 +73,13 @@ export function servicePlan(options) {
32
73
  const home = options.home ?? homedir();
33
74
  const executable = options.executable ?? process.execPath;
34
75
  const args = options.args ?? [];
76
+ const managerPath = join(options.dataDir, "integrations", "manager.mjs");
77
+ const receiptPath = join(options.dataDir, "integrations", "ownership.json");
78
+ // The stable manager is the service target. The original invocation remains
79
+ // in argv as visible evidence and in the receipt as its machine-readable
80
+ // form; the manager starts it while installed and self-cleans when absent.
81
+ const serviceExecutable = process.execPath;
82
+ const serviceArgs = [managerPath, receiptPath, "--", executable, ...args];
35
83
  const logs = join(options.dataDir, "logs");
36
84
  const environment = options.environment ?? {};
37
85
  const environmentEntries = Object.entries(environment);
@@ -44,7 +92,7 @@ export function servicePlan(options) {
44
92
  <key>Label</key><string>${SERVICE_LABEL}</string>
45
93
  <key>ProgramArguments</key>
46
94
  <array>
47
- ${[executable, ...args].map((value) => ` <string>${escapeXml(value)}</string>`).join("\n")}
95
+ ${[serviceExecutable, ...serviceArgs].map((value) => ` <string>${escapeXml(value)}</string>`).join("\n")}
48
96
  </array>
49
97
  ${environmentEntries.length === 0 ? "" : ` <key>EnvironmentVariables</key>
50
98
  <dict>
@@ -53,7 +101,11 @@ ${environmentEntries.map(([key, value]) => ` <key>${escapeXml(key)}</key><str
53
101
  `} <key>RunAtLoad</key><true/>
54
102
  <key>KeepAlive</key>
55
103
  <dict><key>SuccessfulExit</key><false/></dict>
56
- <key>ProcessType</key><string>Background</string>
104
+ <!-- The service owns the loopback UI as well as archive work. Marking the
105
+ whole process Background lets macOS starve even /health and Settings
106
+ while the machine is busy. Keep disk I/O and nice priority deferential,
107
+ but leave CPU/process QoS at launchd's normal class so user-facing local
108
+ requests remain schedulable. -->
57
109
  <key>LowPriorityIO</key><true/>
58
110
  <key>Nice</key><integer>5</integer>
59
111
  <key>StandardOutPath</key><string>${escapeXml(join(logs, "daemon.log"))}</string>
@@ -62,21 +114,21 @@ ${environmentEntries.map(([key, value]) => ` <key>${escapeXml(key)}</key><str
62
114
  </plist>
63
115
  `;
64
116
  return {
65
- kind, label: SERVICE_LABEL, path, contents,
117
+ kind, label: SERVICE_LABEL, path, contents, managerPath, receiptPath,
66
118
  enable: [["launchctl", ["load", "-w", path]]],
67
119
  disable: [["launchctl", ["unload", "-w", path]]]
68
120
  };
69
121
  }
70
122
  if (kind === "systemd") {
71
- const path = join(process.env.XDG_CONFIG_HOME ?? join(home, ".config"), "systemd", "user", "vaultline.service");
123
+ const path = join(userConfigRoot(home), "systemd", "user", "sealkeep.service");
72
124
  const contents = `[Unit]
73
125
  Description=Sealkeep — seals and archives AI coding-agent sessions
74
- Documentation=https://github.com/vaultline/vaultline
126
+ Documentation=https://sealkeep.spala.ai
75
127
  After=default.target
76
128
 
77
129
  [Service]
78
130
  Type=simple
79
- ExecStart=${[executable, ...args].map((value) => JSON.stringify(value)).join(" ")}
131
+ ExecStart=${[serviceExecutable, ...serviceArgs].map((value) => JSON.stringify(value)).join(" ")}
80
132
  ${environmentEntries.map(([key, value]) => `Environment=${key}=${value}\n`).join("")}Restart=on-failure
81
133
  RestartSec=20
82
134
  Nice=5
@@ -86,26 +138,556 @@ IOSchedulingClass=idle
86
138
  WantedBy=default.target
87
139
  `;
88
140
  return {
89
- kind, label: "vaultline.service", path, contents,
90
- enable: [["systemctl", ["--user", "daemon-reload"]], ["systemctl", ["--user", "enable", "--now", "vaultline.service"]]],
91
- disable: [["systemctl", ["--user", "disable", "--now", "vaultline.service"]]]
141
+ kind, label: "sealkeep.service", path, contents, managerPath, receiptPath,
142
+ enable: [["systemctl", ["--user", "daemon-reload"]], ["systemctl", ["--user", "enable", "--now", "sealkeep.service"]]],
143
+ disable: [["systemctl", ["--user", "disable", "--now", "sealkeep.service"]]]
92
144
  };
93
145
  }
94
146
  if (kind === "schtasks") {
95
- const path = join(options.dataDir, "vaultline-task.xml");
96
- const invocation = [executable, ...args].map((value) => `"${value}"`).join(" ");
147
+ const path = join(options.dataDir, "sealkeep-task.xml");
148
+ const invocation = [serviceExecutable, ...serviceArgs].map((value) => `"${value}"`).join(" ");
97
149
  // schtasks has no environment block, so anything the daemon needs has to be
98
150
  // set inside the command it runs.
99
151
  const commandLine = environmentEntries.length === 0
100
152
  ? invocation
101
153
  : `cmd /c "${environmentEntries.map(([key, value]) => `set ${key}=${value}&& `).join("")}${invocation}"`;
102
154
  return {
103
- kind, label: "Sealkeep", path, contents: commandLine,
155
+ kind, label: "Sealkeep", path, contents: commandLine, managerPath, receiptPath,
104
156
  enable: [["schtasks", ["/create", "/f", "/tn", "Sealkeep", "/sc", "onlogon", "/rl", "limited", "/tr", commandLine]]],
105
157
  disable: [["schtasks", ["/delete", "/f", "/tn", "Sealkeep"]]]
106
158
  };
107
159
  }
108
- return { kind, label: "", path: "", contents: "", enable: [], disable: [] };
160
+ return { kind, label: "", path: "", contents: "", enable: [], disable: [], managerPath, receiptPath };
161
+ }
162
+ /**
163
+ * Tears down whatever the previous name left behind, and only then.
164
+ *
165
+ * The guard matters: without it a first-ever install still issues an unload
166
+ * for a unit file that never existed, which is a command run for nothing and
167
+ * a line of noise in every install transcript.
168
+ */
169
+ async function retireLegacy(kind, home, exec) {
170
+ const { commands, paths } = legacyTeardown(kind, home);
171
+ // Unload unconditionally. A loaded job outlives its unit file — delete the
172
+ // plist by hand and launchd keeps running the agent — so "the file is gone"
173
+ // is not "there is nothing to retire", and skipping the unload on that basis
174
+ // is how a machine ends up running BOTH agents, which is what this exists
175
+ // to prevent.
176
+ for (const [file, args] of commands) {
177
+ try {
178
+ await exec(file, args);
179
+ }
180
+ catch { /* was never loaded */ }
181
+ }
182
+ // And never let cleanup sink the install. `force: true` swallows a missing
183
+ // file but not a locked or read-only one, and this runs BEFORE the new unit
184
+ // is written: an uncaught throw here stopped the old agent and then aborted
185
+ // before starting the new one, leaving the machine with no agent at all and
186
+ // no message saying so.
187
+ for (const stale of paths) {
188
+ try {
189
+ await rm(stale, { force: true });
190
+ }
191
+ catch { /* a leftover file is not worth failing an install over */ }
192
+ }
193
+ }
194
+ function launchdTarget(label) {
195
+ return typeof process.getuid === "function" ? `gui/${process.getuid()}/${label}` : null;
196
+ }
197
+ /** True only for the daemon child of this exact package-independent manager. */
198
+ function runningInsideServicePlan(plan) {
199
+ return process.env.SEALKEEP_SERVICE_MANAGER_PATH === plan.managerPath
200
+ && process.env.SEALKEEP_SERVICE_RECEIPT_PATH === plan.receiptPath;
201
+ }
202
+ /**
203
+ * Ask the service manager, rather than the filesystem, whether this service is
204
+ * loaded. A plist/unit can survive a failed load (or a later crash), and a
205
+ * launchd job can remain loaded after somebody deletes its plist by hand.
206
+ */
207
+ async function registrationIsLive(plan, exec) {
208
+ if (plan.kind === "launchd") {
209
+ const target = launchdTarget(plan.label);
210
+ if (target) {
211
+ const printed = await exec("launchctl", ["print", target]).then(() => true).catch(() => false);
212
+ if (printed)
213
+ return true;
214
+ }
215
+ // `list <label>` is the compatibility probe for older launchctl versions
216
+ // and for environments where a numeric GUI domain is unavailable.
217
+ return exec("launchctl", ["list", plan.label]).then(() => true).catch(() => false);
218
+ }
219
+ if (plan.kind === "systemd") {
220
+ return exec("systemctl", ["--user", "is-active", "--quiet", plan.label]).then(() => true).catch(() => false);
221
+ }
222
+ if (plan.kind === "schtasks") {
223
+ // There is no durable unit file on Windows; the scheduler is the source of
224
+ // truth. `/query` also fails when the task registration is unusable.
225
+ return exec("schtasks", ["/query", "/tn", plan.label]).then(() => true).catch(() => false);
226
+ }
227
+ return false;
228
+ }
229
+ function commandOutput(result) {
230
+ if (typeof result === "string")
231
+ return result;
232
+ if (result && typeof result === "object" && "stdout" in result) {
233
+ const stdout = result.stdout;
234
+ if (typeof stdout === "string")
235
+ return stdout;
236
+ if (stdout instanceof Uint8Array)
237
+ return Buffer.from(stdout).toString("utf8");
238
+ }
239
+ return null;
240
+ }
241
+ function currentUid(options) {
242
+ const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : undefined);
243
+ return Number.isInteger(uid) && (uid ?? -1) >= 0 ? uid : null;
244
+ }
245
+ function serviceExec(options) {
246
+ if (options.exec)
247
+ return options.exec;
248
+ const target = options.platform ?? platform();
249
+ const uid = currentUid(options);
250
+ return (file, args) => {
251
+ // A launchd registration from an older release can keep its Background
252
+ // process policy cached until logout even after its plist is corrected.
253
+ // Reconciliation is the escape path, so its launchctl probes must not be
254
+ // starved by the very policy they are repairing. taskpolicy's application
255
+ // profile applies before launchctl starts, and the timeout keeps a broken
256
+ // service manager from delaying the local UI indefinitely.
257
+ if (target === "darwin") {
258
+ return run("/usr/sbin/taskpolicy", ["-a", file, ...args], {
259
+ timeout: 5_000,
260
+ killSignal: "SIGTERM",
261
+ });
262
+ }
263
+ if (target !== "linux")
264
+ return run(file, args);
265
+ // Minimal/root SSH sessions do not always export XDG_RUNTIME_DIR even
266
+ // after logind has created the current user's manager. Point systemctl at
267
+ // that same current-user runtime directory; never manufacture one or
268
+ // target a different uid. loginctl calls are bounded so a missing D-Bus or
269
+ // polkit agent cannot strand the rest of Autopilot setup.
270
+ const standardRuntime = uid === null ? null : `/run/user/${uid}`;
271
+ const runtime = standardRuntime && existsSync(standardRuntime) ? standardRuntime : null;
272
+ // XDG_RUNTIME_DIR selects the user bus. Never inherit a value that may
273
+ // belong to the invoking shell's/sudo caller's different account: use only
274
+ // the standard directory for the exact current UID, or omit it entirely.
275
+ const { XDG_RUNTIME_DIR: _untrustedRuntime, ...baseEnvironment } = process.env;
276
+ return run(file, args, {
277
+ env: { ...baseEnvironment, ...(runtime ? { XDG_RUNTIME_DIR: runtime } : {}) },
278
+ ...(file === "loginctl" ? { timeout: 5_000, killSignal: "SIGTERM" } : {}),
279
+ });
280
+ };
281
+ }
282
+ function linuxLingerValue(result) {
283
+ const value = commandOutput(result)?.trim().toLowerCase();
284
+ if (value === "yes" || value === "linger=yes")
285
+ return true;
286
+ if (value === "no" || value === "linger=no")
287
+ return false;
288
+ return null;
289
+ }
290
+ async function linuxLingerEnabled(uid, exec) {
291
+ const result = await exec("loginctl", ["show-user", String(uid), "--property=Linger", "--value"]).catch(() => null);
292
+ return linuxLingerValue(result);
293
+ }
294
+ async function readLinuxLingerOwnership(plan, uid) {
295
+ if (uid === null)
296
+ return null;
297
+ const receipt = await readFile(plan.receiptPath, "utf8")
298
+ .then((raw) => JSON.parse(raw))
299
+ .catch(() => null);
300
+ const linger = receipt?.linuxLinger;
301
+ return linger?.enabledBySealkeep === true
302
+ && linger.uid === uid
303
+ && typeof linger.enabledAt === "string"
304
+ ? linger
305
+ : null;
306
+ }
307
+ function oneLine(error) {
308
+ return error instanceof Error ? error.message.split("\n")[0] : "not permitted";
309
+ }
310
+ /**
311
+ * A systemd user unit normally follows login sessions. Linger makes the
312
+ * current user's manager start at boot and survive the last SSH logout. It is
313
+ * a user-wide switch, so Sealkeep records and later reverses it only when the
314
+ * switch was proven off before this install. No sudo or other user is ever
315
+ * attempted, and inability to enable it never blocks preservation right now.
316
+ */
317
+ async function ensureLinuxBootPersistence(plan, options, exec) {
318
+ const uid = currentUid(options);
319
+ if (uid === null) {
320
+ return {
321
+ persistence: "login", ownership: null, previousOwnership: null, newlyEnabled: false,
322
+ note: "Automatic preservation is active, but this Linux runtime did not expose the current user id, so persistence after logout or reboot could not be verified. It will resume at the next login."
323
+ };
324
+ }
325
+ const previousOwnership = await readLinuxLingerOwnership(plan, uid);
326
+ const before = await linuxLingerEnabled(uid, exec);
327
+ if (before === true) {
328
+ return { persistence: "boot", ownership: previousOwnership, previousOwnership, newlyEnabled: false };
329
+ }
330
+ if (before === null) {
331
+ return {
332
+ persistence: "login", ownership: previousOwnership, previousOwnership, newlyEnabled: false,
333
+ note: "Automatic preservation is active, but this host could not inspect systemd user lingering, so persistence after the last logout or reboot could not be verified. It will resume at the next login."
334
+ };
335
+ }
336
+ try {
337
+ await exec("loginctl", ["enable-linger", String(uid)]);
338
+ }
339
+ catch (error) {
340
+ return {
341
+ persistence: "login", ownership: previousOwnership, previousOwnership, newlyEnabled: false,
342
+ note: `Automatic preservation is active, but this host did not allow boot persistence (${oneLine(error)}). It will resume at the next login.`
343
+ };
344
+ }
345
+ if (await linuxLingerEnabled(uid, exec) !== true) {
346
+ // The mutating command returned success without proving the state. Restore
347
+ // the known-off state instead of claiming a reboot promise we cannot test.
348
+ const rollbackError = await exec("loginctl", ["disable-linger", String(uid)])
349
+ .then(() => null)
350
+ .catch((error) => error);
351
+ const ownership = previousOwnership ?? { uid, enabledBySealkeep: true, enabledAt: new Date().toISOString() };
352
+ return {
353
+ persistence: "login",
354
+ // A failed reversal leaves state uncertain but still ours: it was proven
355
+ // off before our enable attempt. Keep the receipt so uninstall can retry.
356
+ ownership: rollbackError ? ownership : previousOwnership,
357
+ previousOwnership,
358
+ newlyEnabled: rollbackError !== null,
359
+ note: rollbackError
360
+ ? `Automatic preservation is active, but systemd neither confirmed boot persistence nor restored its previous setting (${oneLine(rollbackError)}). Sealkeep kept an ownership receipt so cleanup can retry.`
361
+ : "Automatic preservation is active, but systemd did not confirm boot persistence. It will resume at the next login."
362
+ };
363
+ }
364
+ const ownership = previousOwnership ?? { uid, enabledBySealkeep: true, enabledAt: new Date().toISOString() };
365
+ return { persistence: "boot", ownership, previousOwnership, newlyEnabled: true };
366
+ }
367
+ async function rollbackNewLinuxLinger(setup, exec, ranCommands) {
368
+ if (!setup?.newlyEnabled || !setup.ownership)
369
+ return null;
370
+ try {
371
+ await exec("loginctl", ["disable-linger", String(setup.ownership.uid)]);
372
+ ranCommands?.push(`loginctl disable-linger ${setup.ownership.uid}`);
373
+ return null;
374
+ }
375
+ catch (error) {
376
+ return `loginctl could not roll back Sealkeep's linger setting: ${oneLine(error)}`;
377
+ }
378
+ }
379
+ /** Whether the manager's loaded command is the package-independent target. */
380
+ async function registrationUsesPlan(plan, exec) {
381
+ let result;
382
+ if (plan.kind === "launchd") {
383
+ const target = launchdTarget(plan.label);
384
+ if (!target)
385
+ return true;
386
+ result = await exec("launchctl", ["print", target]).catch(() => null);
387
+ }
388
+ else if (plan.kind === "systemd") {
389
+ result = await exec("systemctl", ["--user", "show", plan.label, "--property=ExecStart", "--value"]).catch(() => null);
390
+ }
391
+ else {
392
+ return true;
393
+ }
394
+ const output = commandOutput(result);
395
+ // Injected test executors and older managers may expose only success/failure.
396
+ // In that case file drift still drives repair; never declare drift from an
397
+ // observation the platform did not provide.
398
+ return output === null || (output.includes(plan.managerPath) && output.includes(plan.receiptPath));
399
+ }
400
+ function definitionUsesPlan(contents, plan) {
401
+ return contents !== null && contents.includes(plan.managerPath) && contents.includes(plan.receiptPath);
402
+ }
403
+ function decodeServiceXml(value) {
404
+ return value.replace(/&(?:lt|gt|amp|quot|apos|#\d+|#x[0-9a-f]+);/gi, (entity) => {
405
+ const named = { "&lt;": "<", "&gt;": ">", "&amp;": "&", "&quot;": '"', "&apos;": "'" };
406
+ if (named[entity])
407
+ return named[entity];
408
+ const numeric = entity.startsWith("&#x") ? Number.parseInt(entity.slice(3, -1), 16) : Number(entity.slice(2, -1));
409
+ return Number.isInteger(numeric) && numeric >= 0 && numeric <= 0x10ffff ? String.fromCodePoint(numeric) : entity;
410
+ });
411
+ }
412
+ /** Parse only the direct argv forms emitted by our service definitions. Shell
413
+ * programs, expansion, and malformed quoting are not ownership evidence. */
414
+ function serviceCommandWords(command, windows = false) {
415
+ const words = [];
416
+ const token = /\s*("(?:[^"\\]|\\.)*"|'[^']*'|[^\s"']+)/gy;
417
+ let offset = 0;
418
+ while (offset < command.length) {
419
+ if (!command.slice(offset).trim())
420
+ break;
421
+ token.lastIndex = offset;
422
+ const match = token.exec(command);
423
+ if (!match)
424
+ return null;
425
+ const raw = match[1];
426
+ try {
427
+ words.push(raw.startsWith('"') ? (windows ? raw.slice(1, -1) : JSON.parse(raw))
428
+ : raw.startsWith("'") ? raw.slice(1, -1) : raw);
429
+ }
430
+ catch {
431
+ return null;
432
+ }
433
+ offset = token.lastIndex;
434
+ }
435
+ return words;
436
+ }
437
+ function serviceArgvOwnsVault(argv, plan, dataDir) {
438
+ if (!argv)
439
+ return false;
440
+ const executable = argv[0]?.replace(/\\/g, "/").split("/").at(-1) ?? "";
441
+ if (!/^(?:node(?:\.exe)?|sealkeep(?:\.cmd|\.exe)?|vaultline(?:\.cmd|\.exe)?)$/i.test(executable))
442
+ return false;
443
+ const manager = argv.findIndex((argument) => /(?:^|[/\\])manager\.mjs$/.test(argument));
444
+ if (manager >= 0) {
445
+ // A foreign manager/receipt remains foreign even when a trailing argument
446
+ // or comment happens to mention the requested vault.
447
+ if (manager !== 1 || argv[manager] !== plan.managerPath || argv[manager + 1] !== plan.receiptPath || argv[manager + 2] !== "--")
448
+ return false;
449
+ }
450
+ const roots = [];
451
+ for (let index = 0; index < argv.length; index += 1) {
452
+ if (argv[index] === "--data-dir")
453
+ roots.push(argv[++index] ?? "");
454
+ else if (argv[index].startsWith("--data-dir="))
455
+ roots.push(argv[index].slice("--data-dir=".length));
456
+ }
457
+ if (roots.some((root) => root !== dataDir))
458
+ return false;
459
+ return manager >= 0 || (argv.includes("daemon") && roots.length > 0);
460
+ }
461
+ async function reconciliationReceiptAgrees(plan, dataDir) {
462
+ let raw;
463
+ try {
464
+ raw = await readFile(plan.receiptPath, "utf8");
465
+ }
466
+ catch (error) {
467
+ return error.code === "ENOENT";
468
+ }
469
+ try {
470
+ const receipt = JSON.parse(raw);
471
+ return receipt.dataDir === dataDir && receipt.managerPath === plan.managerPath && receipt.receiptPath === plan.receiptPath;
472
+ }
473
+ catch {
474
+ return false;
475
+ }
476
+ }
477
+ function serviceDefinitionOwnsVault(contents, plan, dataDir) {
478
+ if (plan.kind === "launchd") {
479
+ const array = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(contents)?.[1];
480
+ if (array !== undefined) {
481
+ const strings = [...array.matchAll(/<string>([^<]*)<\/string>/g)];
482
+ if (array.replace(/<string>[^<]*<\/string>/g, "").trim())
483
+ return false;
484
+ return serviceArgvOwnsVault(strings.map((match) => decodeServiceXml(match[1])), plan, dataDir);
485
+ }
486
+ // Older malformed plists stored a command string rather than an argv
487
+ // array. Repair only when that command explicitly names this same vault.
488
+ const legacy = /<key>ProgramArguments<\/key>\s*<string>([^<]*)<\/string>/.exec(contents)?.[1];
489
+ return legacy !== undefined && serviceArgvOwnsVault(serviceCommandWords(decodeServiceXml(legacy)), plan, dataDir);
490
+ }
491
+ if (plan.kind === "systemd") {
492
+ const commands = [...contents.matchAll(/^ExecStart=(.*)$/gm)];
493
+ return commands.length === 1 && serviceArgvOwnsVault(serviceCommandWords(commands[0][1]), plan, dataDir);
494
+ }
495
+ return false;
496
+ }
497
+ function scheduledTaskOwnsVault(xml, plan, dataDir) {
498
+ const actions = [...xml.matchAll(/<Exec(?:\s[^>]*)?>([\s\S]*?)<\/Exec>/g)];
499
+ if (actions.length !== 1)
500
+ return false;
501
+ const command = /<Command>([^<]*)<\/Command>/.exec(actions[0][1])?.[1];
502
+ const argumentsText = /<Arguments>([^<]*)<\/Arguments>/.exec(actions[0][1])?.[1] ?? "";
503
+ if (!command)
504
+ return false;
505
+ const executable = decodeServiceXml(command).replace(/^"|"$/g, "");
506
+ let args = decodeServiceXml(argumentsText);
507
+ if (/(?:^|[/\\])cmd(?:\.exe)?$/i.test(executable)) {
508
+ const wrapper = /^\/c\s+"((?:set [A-Za-z_][A-Za-z0-9_]*=[^&"\r\n]*&& )+)([\s\S]*)"$/i.exec(args);
509
+ if (!wrapper)
510
+ return false;
511
+ return serviceArgvOwnsVault(serviceCommandWords(wrapper[2], true), plan, dataDir);
512
+ }
513
+ return serviceArgvOwnsVault([executable, ...(serviceCommandWords(args, true) ?? [])], plan, dataDir);
514
+ }
515
+ async function receiptUsesPlan(plan, dataDir) {
516
+ try {
517
+ const receipt = JSON.parse(await readFile(plan.receiptPath, "utf8"));
518
+ return receipt.dataDir === dataDir && receipt.managerPath === plan.managerPath && receipt.receiptPath === plan.receiptPath;
519
+ }
520
+ catch {
521
+ return false;
522
+ }
523
+ }
524
+ /**
525
+ * Remove the current launchd registration by label before loading a corrected
526
+ * plist. `bootout` deliberately comes first: old Sealkeep builds wrote
527
+ * ProgramArguments in a malformed shape, so asking launchctl to parse that
528
+ * stale file again is not a reliable way to unload it.
529
+ */
530
+ async function unloadLaunchdRegistration(plan, exec) {
531
+ const target = launchdTarget(plan.label);
532
+ if (target) {
533
+ const command = ["launchctl", ["bootout", target]];
534
+ if (await exec(command[0], command[1]).then(() => true).catch(() => false))
535
+ return command;
536
+ }
537
+ const fallback = ["launchctl", ["unload", "-w", plan.path]];
538
+ if (await exec(fallback[0], fallback[1]).then(() => true).catch(() => false))
539
+ return fallback;
540
+ return null;
541
+ }
542
+ function packageTargetOf(executable, args) {
543
+ const command = executable.replace(/\\/g, "/").split("/").pop()?.toLowerCase() ?? "";
544
+ return command.startsWith("node") && args[0] ? args[0] : executable;
545
+ }
546
+ async function writeIntegrationManager(plan, options, linuxLingerOverride) {
547
+ const home = options.home ?? homedir();
548
+ const executable = options.executable ?? process.execPath;
549
+ const args = options.args ?? [];
550
+ const previousLinger = await readLinuxLingerOwnership(plan, currentUid(options));
551
+ // `undefined` preserves an ownership record while reconciling an upgrade;
552
+ // `null` deliberately clears it after a rollback or when another tool
553
+ // already owned the user-wide setting.
554
+ const linuxLinger = linuxLingerOverride === undefined ? previousLinger : linuxLingerOverride;
555
+ const receipt = {
556
+ version: 1,
557
+ disclosed: true,
558
+ installedAt: new Date().toISOString(),
559
+ dataDir: options.dataDir,
560
+ home,
561
+ kind: plan.kind,
562
+ label: plan.label,
563
+ unitPath: plan.path,
564
+ managerPath: plan.managerPath,
565
+ receiptPath: plan.receiptPath,
566
+ executable,
567
+ args,
568
+ packageTarget: packageTargetOf(executable, args),
569
+ cleanupGraceMs: options.cleanupGraceMs ?? 5 * 60_000,
570
+ monitorIntervalMs: options.cleanupMonitorMs ?? 30_000,
571
+ ...(linuxLinger ? { linuxLinger } : {}),
572
+ hookFiles: [join(home, ".codex", "hooks.json"), join(home, ".claude", "settings.json")],
573
+ ownedFragments: [join(options.dataDir, "integrations", "codex-hooks.json"), join(options.dataDir, "integrations", "claude-hooks.json")],
574
+ // Runtime evidence owned by the supervised child. Normal service shutdown
575
+ // removes this itself; the surviving manager also names it explicitly so
576
+ // an interrupted package uninstall cannot leave a convincing stale owner.
577
+ // ui-server.json is deliberately absent: an interactive UI for this vault
578
+ // may own that listener and must not be torn down by service cleanup.
579
+ ownedRuntimeFiles: [join(options.dataDir, "ui-supervisor.json")],
580
+ cleanupPolicy: {
581
+ removes: [
582
+ "Sealkeep-owned agent lifecycle entries",
583
+ "background service/task",
584
+ "Autopilot local UI supervision record",
585
+ "Sealkeep-owned hook fragments",
586
+ ...(linuxLinger ? ["systemd user lingering enabled by Sealkeep"] : []),
587
+ ],
588
+ preserves: ["encrypted archives", "unsynced outbox", "native-keychain recovery material", "remote objects"],
589
+ },
590
+ };
591
+ await mkdir(dirname(plan.managerPath), { recursive: true, mode: 0o700 });
592
+ await writeFile(plan.managerPath, INTEGRATION_MANAGER_SOURCE, { mode: 0o700 });
593
+ await writeFile(plan.receiptPath, JSON.stringify(receipt, null, 2) + "\n", { mode: 0o600 });
594
+ }
595
+ /**
596
+ * Upgrade path for units installed before the package-independent manager
597
+ * existed. No new service is installed on a machine that did not already
598
+ * consent to one; an existing stale or inactive registration is repaired and
599
+ * activated immediately instead of waiting for the next login/reboot.
600
+ */
601
+ export async function reconcileInstalledService(options) {
602
+ const plan = servicePlan(options);
603
+ if (plan.kind === "unsupported")
604
+ return false;
605
+ const exec = serviceExec(options);
606
+ if (plan.kind !== "schtasks") {
607
+ const current = await readFile(plan.path, "utf8").catch(() => null);
608
+ if (current === null)
609
+ return false;
610
+ // The service label is user-wide, not vault-wide. Automatic reconciliation
611
+ // is an upgrade of an already-owned integration, never a vault switch.
612
+ // Check before even querying/altering service or linger state.
613
+ if (!serviceDefinitionOwnsVault(current, plan, options.dataDir))
614
+ return false;
615
+ if (!(await reconciliationReceiptAgrees(plan, options.dataDir)))
616
+ return false;
617
+ const live = await registrationIsLive(plan, exec);
618
+ const changed = current !== plan.contents;
619
+ const staleRegistration = live && !(await registrationUsesPlan(plan, exec));
620
+ if (await readFile(plan.path, "utf8").catch(() => null) !== current)
621
+ return false;
622
+ const linger = plan.kind === "systemd" ? await ensureLinuxBootPersistence(plan, options, exec) : null;
623
+ try {
624
+ await writeIntegrationManager(plan, options, linger?.ownership);
625
+ }
626
+ catch (error) {
627
+ const rollbackNote = await rollbackNewLinuxLinger(linger, exec);
628
+ if (rollbackNote && linger?.ownership) {
629
+ await writeIntegrationManager(plan, options, linger.ownership).catch(() => undefined);
630
+ }
631
+ throw error;
632
+ }
633
+ if (changed)
634
+ await writeFile(plan.path, plan.contents, { mode: 0o600 });
635
+ const failed = async () => {
636
+ const rollbackNote = await rollbackNewLinuxLinger(linger, exec);
637
+ if (linger?.newlyEnabled) {
638
+ await writeIntegrationManager(plan, options, rollbackNote ? linger.ownership : linger.previousOwnership).catch(() => undefined);
639
+ }
640
+ return false;
641
+ };
642
+ if (plan.kind === "launchd" && (changed || staleRegistration || !live)) {
643
+ // A daemon also runs this reconciliation after package upgrades. It must
644
+ // never boot out the launchd job it is currently a child of: launchd
645
+ // terminates the manager and this process before either can execute the
646
+ // matching load, leaving the login agent unregistered. The corrected
647
+ // files are already on disk; an ordinary setup/settings call (outside
648
+ // the job) can reload them safely, and the next login uses them anyway.
649
+ if (process.env.XPC_SERVICE_NAME === plan.label && live)
650
+ return true;
651
+ if (live && !(await unloadLaunchdRegistration(plan, exec)))
652
+ return failed();
653
+ for (const [file, args] of plan.enable) {
654
+ if (!(await exec(file, args).then(() => true).catch(() => false)))
655
+ return failed();
656
+ }
657
+ }
658
+ else if (plan.kind === "systemd" && (changed || staleRegistration || !live)) {
659
+ for (const [file, args] of plan.enable) {
660
+ if (!(await exec(file, args).then(() => true).catch(() => false)))
661
+ return failed();
662
+ }
663
+ // `enable --now` starts an inactive unit but does not replace the process
664
+ // of an already-active unit after ExecStart changed.
665
+ if ((changed || staleRegistration) && live) {
666
+ // The daemon itself runs reconciliation after an npm upgrade. Asking
667
+ // systemd to synchronously restart the service from that child creates
668
+ // a stop cycle: systemd waits for the child, while daemon shutdown waits
669
+ // for this very `restart` call. The corrected unit/manager are already
670
+ // durable and will be used at the next safe native restart. Foreground
671
+ // setup/settings callers still restart an active drifted service now.
672
+ if (!runningInsideServicePlan(plan)
673
+ && !(await exec("systemctl", ["--user", "restart", plan.label]).then(() => true).catch(() => false)))
674
+ return failed();
675
+ }
676
+ }
677
+ return true;
678
+ }
679
+ const present = await registrationIsLive(plan, exec);
680
+ if (!present)
681
+ return false;
682
+ const registered = commandOutput(await exec("schtasks", ["/query", "/tn", plan.label, "/xml"]).catch(() => null));
683
+ if (registered === null || !scheduledTaskOwnsVault(registered, plan, options.dataDir))
684
+ return false;
685
+ if (!(await reconciliationReceiptAgrees(plan, options.dataDir)))
686
+ return false;
687
+ await writeIntegrationManager(plan, options);
688
+ for (const [file, args] of plan.enable)
689
+ await exec(file, args).catch(() => undefined);
690
+ return true;
109
691
  }
110
692
  /** Writes the unit file and asks the platform's service manager to start it at login. */
111
693
  export async function installService(options) {
@@ -113,54 +695,199 @@ export async function installService(options) {
113
695
  if (plan.kind === "unsupported") {
114
696
  return { kind: plan.kind, installed: false, path: "", ranCommands: [], note: `No background service is available on ${options.platform ?? platform()}. Run \`sealkeep daemon\` yourself.` };
115
697
  }
116
- const exec = options.exec ?? ((file, args) => run(file, args));
698
+ const exec = serviceExec(options);
699
+ const previous = plan.kind === "schtasks" ? null : await readFile(plan.path, "utf8").catch(() => null);
700
+ const live = await registrationIsLive(plan, exec);
701
+ if (plan.kind !== "schtasks" && previous !== null && !definitionUsesPlan(previous, plan)) {
702
+ return {
703
+ kind: plan.kind,
704
+ installed: false,
705
+ path: plan.path,
706
+ ranCommands: [],
707
+ note: "Autopilot is already owned by a different Sealkeep vault for this user. Stop it from that vault before choosing another; nothing was changed.",
708
+ };
709
+ }
710
+ const changed = previous !== plan.contents;
711
+ const staleRegistration = live && !(await registrationUsesPlan(plan, exec));
117
712
  await mkdir(join(options.dataDir, "logs"), { recursive: true, mode: 0o700 });
713
+ await writeIntegrationManager(plan, options);
714
+ // Retire the previous name first, or both agents load at the next login.
715
+ // Only when there is something to retire: asking launchctl to unload a unit
716
+ // file that was never written is a command that does nothing, and a fresh
717
+ // install should not run it.
718
+ await retireLegacy(plan.kind, options.home ?? homedir(), exec);
118
719
  if (plan.kind !== "schtasks") {
119
720
  await mkdir(dirname(plan.path), { recursive: true });
120
721
  await writeFile(plan.path, plan.contents, { mode: 0o600 });
121
722
  }
122
723
  const ranCommands = [];
724
+ let linger = null;
725
+ if (plan.kind === "systemd") {
726
+ linger = await ensureLinuxBootPersistence(plan, options, exec);
727
+ if (linger.newlyEnabled && linger.ownership) {
728
+ ranCommands.push(`loginctl enable-linger ${linger.ownership.uid}`);
729
+ }
730
+ try {
731
+ await writeIntegrationManager(plan, options, linger.ownership);
732
+ }
733
+ catch (error) {
734
+ await rollbackNewLinuxLinger(linger, exec, ranCommands);
735
+ throw error;
736
+ }
737
+ }
738
+ const installFailure = async (message) => {
739
+ const rollbackNote = await rollbackNewLinuxLinger(linger, exec, ranCommands);
740
+ if (linger?.newlyEnabled) {
741
+ await writeIntegrationManager(plan, options, rollbackNote ? linger.ownership : linger.previousOwnership).catch(() => undefined);
742
+ }
743
+ return {
744
+ kind: plan.kind, installed: false, path: plan.path, ranCommands,
745
+ persistence: linger?.persistence,
746
+ note: rollbackNote ? `${message}. ${rollbackNote}` : message,
747
+ };
748
+ };
749
+ if (plan.kind === "launchd" && live && (changed || staleRegistration)) {
750
+ const unloaded = await unloadLaunchdRegistration(plan, exec);
751
+ if (!unloaded) {
752
+ return installFailure("launchctl failed to unload the stale service registration");
753
+ }
754
+ ranCommands.push([unloaded[0], ...unloaded[1]].join(" "));
755
+ }
756
+ // A byte-identical launchd job that is already loaded needs no second `load`
757
+ // (which launchctl rejects as a duplicate registration).
758
+ if (plan.kind === "launchd" && live && !changed && !staleRegistration) {
759
+ return { kind: plan.kind, installed: true, path: plan.path, managerPath: plan.managerPath, receiptPath: plan.receiptPath, ranCommands, persistence: "login" };
760
+ }
123
761
  for (const [file, args] of plan.enable) {
124
762
  try {
125
763
  await exec(file, args);
126
764
  ranCommands.push([file, ...args].join(" "));
127
765
  }
128
766
  catch (error) {
129
- return { kind: plan.kind, installed: false, path: plan.path, ranCommands, note: `${file} failed: ${error instanceof Error ? error.message.split("\n")[0] : "unknown error"}` };
767
+ return installFailure(`${file} failed: ${error instanceof Error ? error.message.split("\n")[0] : "unknown error"}`);
130
768
  }
131
769
  }
132
- return { kind: plan.kind, installed: true, path: plan.path, ranCommands };
770
+ if (plan.kind === "systemd" && live && (changed || staleRegistration)) {
771
+ // `installService` is normally foreground, but retain the same exact
772
+ // self-restart guard as reconciliation for library callers inside the
773
+ // managed daemon.
774
+ if (!runningInsideServicePlan(plan)) {
775
+ const file = "systemctl";
776
+ const args = ["--user", "restart", plan.label];
777
+ try {
778
+ await exec(file, args);
779
+ ranCommands.push([file, ...args].join(" "));
780
+ }
781
+ catch (error) {
782
+ return installFailure(`${file} failed: ${error instanceof Error ? error.message.split("\n")[0] : "unknown error"}`);
783
+ }
784
+ }
785
+ }
786
+ return {
787
+ kind: plan.kind, installed: true, path: plan.path,
788
+ managerPath: plan.managerPath, receiptPath: plan.receiptPath, ranCommands,
789
+ persistence: linger?.persistence ?? "login",
790
+ note: linger?.note,
791
+ };
133
792
  }
134
793
  export async function uninstallService(options) {
135
794
  const plan = servicePlan(options);
136
795
  if (plan.kind === "unsupported")
137
796
  return { kind: plan.kind, installed: false, path: "", ranCommands: [] };
138
- const exec = options.exec ?? ((file, args) => run(file, args));
797
+ const exec = serviceExec(options);
139
798
  const ranCommands = [];
799
+ const uid = currentUid(options);
800
+ const definition = plan.kind === "schtasks" ? null : await readFile(plan.path, "utf8").catch(() => null);
801
+ const receiptOwned = await receiptUsesPlan(plan, options.dataDir);
802
+ const liveBefore = await registrationIsLive(plan, exec);
803
+ if (plan.kind !== "schtasks" && ((definition !== null && !definitionUsesPlan(definition, plan)) || (definition === null && liveBefore && !receiptOwned))) {
804
+ return {
805
+ kind: plan.kind,
806
+ installed: false,
807
+ path: plan.path,
808
+ ranCommands,
809
+ note: "The active Autopilot service belongs to a different Sealkeep vault. Nothing was stopped or removed.",
810
+ };
811
+ }
812
+ const ownedLinger = plan.kind === "systemd" ? await readLinuxLingerOwnership(plan, uid) : null;
813
+ const stopErrors = [];
140
814
  for (const [file, args] of plan.disable) {
141
815
  try {
142
816
  await exec(file, args);
143
817
  ranCommands.push([file, ...args].join(" "));
144
818
  }
145
- catch { /* already stopped */ }
819
+ catch (error) {
820
+ stopErrors.push(`${file}: ${oneLine(error)}`);
821
+ }
822
+ }
823
+ const stillLive = await registrationIsLive(plan, exec);
824
+ // A service that was demonstrably live before a failed stop is not proven
825
+ // gone merely because the follow-up query cannot see it. Preserve every
826
+ // ownership artifact in that ambiguous state so a retry can finish safely.
827
+ if (stillLive || (liveBefore && stopErrors.length > 0)) {
828
+ return {
829
+ kind: plan.kind,
830
+ installed: true,
831
+ path: plan.path,
832
+ managerPath: plan.managerPath,
833
+ receiptPath: plan.receiptPath,
834
+ ranCommands,
835
+ note: `Autopilot is still running, so its files, phrase, hooks, and ownership receipt were kept. Retry after resolving the service-manager error${stopErrors.length ? ` (${stopErrors.join("; ")})` : ""}.`,
836
+ };
146
837
  }
838
+ await retireLegacy(plan.kind, options.home ?? homedir(), exec);
147
839
  if (plan.kind !== "schtasks")
148
840
  await rm(plan.path, { force: true });
149
- return { kind: plan.kind, installed: false, path: plan.path, ranCommands };
841
+ // The managed child normally removes this on SIGTERM together with its
842
+ // loopback listener. Remove the owner record here as well for service
843
+ // managers (notably Task Scheduler) that may terminate a child without
844
+ // delivering Node a graceful signal. Never remove ui-server.json here: a
845
+ // separately opened interactive UI may legitimately own it.
846
+ await rm(join(options.dataDir, "ui-supervisor.json"), { force: true });
847
+ let note;
848
+ let lingerCleanupPending = false;
849
+ // Never trust a receipt to target another account. The only shared systemd
850
+ // setting Sealkeep reverses is the current numeric user's setting, and only
851
+ // when the owner-only receipt proves Sealkeep enabled it from an off state.
852
+ if (ownedLinger && uid === ownedLinger.uid) {
853
+ try {
854
+ await exec("loginctl", ["disable-linger", String(uid)]);
855
+ ranCommands.push(`loginctl disable-linger ${uid}`);
856
+ }
857
+ catch (error) {
858
+ lingerCleanupPending = true;
859
+ note = `The Sealkeep service is removed, but systemd user lingering could not be restored (${oneLine(error)}). No Sealkeep process will run.`;
860
+ }
861
+ }
862
+ await rm(plan.managerPath, { force: true });
863
+ // The receipt is the proof that a later retry may disable this user-wide
864
+ // setting. Erasing it after a transient loginctl failure makes safe cleanup
865
+ // impossible, so retain it until restoration succeeds.
866
+ if (!lingerCleanupPending)
867
+ await rm(plan.receiptPath, { force: true });
868
+ return { kind: plan.kind, installed: false, path: plan.path, ranCommands, note };
150
869
  }
151
- /** Reports whether the unit file this build would write is currently present. */
870
+ /** Reports durable installation separately from whether the manager has it live now. */
152
871
  export async function serviceStatus(options) {
153
872
  const plan = servicePlan(options);
154
873
  if (plan.kind === "unsupported")
155
- return { kind: plan.kind, present: false, path: "" };
156
- if (plan.kind === "schtasks") {
157
- const exec = options.exec ?? ((file, args) => run(file, args));
158
- const present = await exec("schtasks", ["/query", "/tn", "Sealkeep"]).then(() => true).catch(() => false);
159
- return { kind: plan.kind, present, path: plan.path };
160
- }
161
- const contents = await readFile(plan.path, "utf8").catch(() => null);
874
+ return { kind: plan.kind, installed: false, present: false, path: "" };
875
+ const exec = serviceExec(options);
876
+ const contents = plan.kind === "schtasks" ? null : await readFile(plan.path, "utf8").catch(() => null);
162
877
  const runs = contents?.match(/ExecStart=(.*)/)?.[1] ?? contents?.match(/<string>([^<]*daemon[^<]*)<\/string>/)?.[1];
163
- return { kind: plan.kind, present: contents !== null, path: plan.path, runs: runs ?? undefined };
878
+ // A durable POSIX service is healthy only when both halves agree: its unit
879
+ // remains installed for the next login and the service manager has it live
880
+ // now. Windows keeps both facts in Task Scheduler itself.
881
+ const owned = plan.kind === "schtasks" ? true : definitionUsesPlan(contents, plan);
882
+ const present = owned && await registrationIsLive(plan, exec) && await registrationUsesPlan(plan, exec);
883
+ // POSIX keeps the durable registration in its unit file. Task Scheduler is
884
+ // itself both the stored definition and the queryable registration.
885
+ const installed = plan.kind === "schtasks" ? present : owned;
886
+ const uid = currentUid(options);
887
+ const persistence = plan.kind === "systemd" && contents !== null
888
+ ? (uid !== null && await linuxLingerEnabled(uid, exec) === true ? "boot" : "login")
889
+ : plan.kind === "launchd" || plan.kind === "schtasks" ? "login" : undefined;
890
+ return { kind: plan.kind, installed, present, path: plan.path, runs: runs ?? undefined, persistence };
164
891
  }
165
892
  /**
166
893
  * The environment a service unit needs beyond its flags. A service manager
@@ -168,14 +895,21 @@ export async function serviceStatus(options) {
168
895
  * where secrets live has to ride along in the unit — a vault set up with
169
896
  * SEALKEEP_SECRET_BACKEND=file kept its phrase in a keystore the daemon then
170
897
  * never looked in, and every tick died on "No recovery phrase available".
898
+ * Upload capability is included by default because storage may be connected
899
+ * after installation; target configuration and credentials remain mandatory.
900
+ * Callers representing an explicit `--no-upload` omit it with `uploads:false`.
171
901
  */
172
- export function serviceUnitEnvironment(base = {}, env = process.env) {
902
+ export function serviceUnitEnvironment(base = {}, env = process.env, options = {}) {
173
903
  const backend = envVar("SECRET_BACKEND", env);
174
- return { ...(backend ? { SEALKEEP_SECRET_BACKEND: backend } : {}), ...base };
904
+ return {
905
+ ...(backend ? { SEALKEEP_SECRET_BACKEND: backend } : {}),
906
+ ...(options.uploads === false ? {} : { SEALKEEP_ENABLE_SIGNER: "1" }),
907
+ ...base,
908
+ };
175
909
  }
176
- /** The argv the service should run: this package's CLI, with the daemon subcommand. */
910
+ /** The argv the service should run: this package's daemon plus its loopback UI lane. */
177
911
  export function daemonInvocation(dataDir, options = {}) {
178
- let cli = options.cliPath ?? process.argv[1] ?? "vaultline";
912
+ let cli = options.cliPath ?? process.argv[1] ?? "sealkeep";
179
913
  // A service unit runs plain node, and plain node cannot execute TypeScript.
180
914
  // Installed packages always resolve to dist/cli.js, but a source checkout
181
915
  // running under tsx resolves to src/cli.ts — writing that path into launchd
@@ -191,9 +925,16 @@ export function daemonInvocation(dataDir, options = {}) {
191
925
  }
192
926
  cli = built;
193
927
  }
194
- const args = [cli, "daemon", "--data-dir", dataDir];
928
+ // `--serve-ui` is intentionally explicit. `sealkeep daemon` remains a
929
+ // foreground worker unless its native service owns the browserless UI lane;
930
+ // a developer command must never acquire 7477 as a hidden side effect.
931
+ const args = [cli, "daemon", "--data-dir", dataDir, "--serve-ui"];
932
+ if (Number.isFinite(options.intervalSeconds) && (options.intervalSeconds ?? 0) > 0) {
933
+ args.push("--interval", String(Math.max(1, Math.round(options.intervalSeconds))));
934
+ }
935
+ if (options.upload === false)
936
+ args.push("--no-upload");
195
937
  if (options.reclaim)
196
938
  args.push("--reclaim");
197
939
  return { executable: process.execPath, args };
198
940
  }
199
- export function serviceKind(target = platform()) { return kindFor(target); }