@phigon/observer 8.1.0 → 8.3.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 (309) hide show
  1. package/README.md +244 -18
  2. package/dist/bin/guardian-runner-darwin-amd64 +0 -0
  3. package/dist/bin/guardian-runner-darwin-arm64 +0 -0
  4. package/dist/bin/guardian-runner-linux-amd64 +0 -0
  5. package/dist/bin/guardian-runner-linux-arm64 +0 -0
  6. package/dist/bin/guardian-runner-windows-amd64.exe +0 -0
  7. package/dist/bin/guardian-runner-windows-arm64.exe +0 -0
  8. package/dist/src/abortable-delay.d.ts +10 -0
  9. package/dist/src/abortable-delay.d.ts.map +1 -0
  10. package/dist/src/abortable-delay.js +21 -0
  11. package/dist/src/abortable-delay.js.map +1 -0
  12. package/dist/src/action-policy.d.ts.map +1 -1
  13. package/dist/src/action-policy.js +4 -2
  14. package/dist/src/action-policy.js.map +1 -1
  15. package/dist/src/activity/adapters/claude.d.ts.map +1 -1
  16. package/dist/src/activity/adapters/claude.js +28 -2
  17. package/dist/src/activity/adapters/claude.js.map +1 -1
  18. package/dist/src/activity/adapters/cline-task.d.ts +7 -0
  19. package/dist/src/activity/adapters/cline-task.d.ts.map +1 -0
  20. package/dist/src/activity/adapters/cline-task.js +197 -0
  21. package/dist/src/activity/adapters/cline-task.js.map +1 -0
  22. package/dist/src/activity/adapters/cline.d.ts +3 -0
  23. package/dist/src/activity/adapters/cline.d.ts.map +1 -0
  24. package/dist/src/activity/adapters/cline.js +102 -0
  25. package/dist/src/activity/adapters/cline.js.map +1 -0
  26. package/dist/src/activity/adapters/codex.d.ts.map +1 -1
  27. package/dist/src/activity/adapters/codex.js +76 -0
  28. package/dist/src/activity/adapters/codex.js.map +1 -1
  29. package/dist/src/activity/adapters/copilot-chat.d.ts +3 -0
  30. package/dist/src/activity/adapters/copilot-chat.d.ts.map +1 -0
  31. package/dist/src/activity/adapters/copilot-chat.js +176 -0
  32. package/dist/src/activity/adapters/copilot-chat.js.map +1 -0
  33. package/dist/src/activity/adapters/copilot.d.ts +2 -1
  34. package/dist/src/activity/adapters/copilot.d.ts.map +1 -1
  35. package/dist/src/activity/adapters/copilot.js +173 -2
  36. package/dist/src/activity/adapters/copilot.js.map +1 -1
  37. package/dist/src/activity/adapters/cursor.d.ts +3 -0
  38. package/dist/src/activity/adapters/cursor.d.ts.map +1 -0
  39. package/dist/src/activity/adapters/cursor.js +86 -0
  40. package/dist/src/activity/adapters/cursor.js.map +1 -0
  41. package/dist/src/activity/adapters/factory-droid.d.ts +3 -0
  42. package/dist/src/activity/adapters/factory-droid.d.ts.map +1 -0
  43. package/dist/src/activity/adapters/factory-droid.js +126 -0
  44. package/dist/src/activity/adapters/factory-droid.js.map +1 -0
  45. package/dist/src/activity/adapters/gemini.d.ts +3 -0
  46. package/dist/src/activity/adapters/gemini.d.ts.map +1 -0
  47. package/dist/src/activity/adapters/gemini.js +177 -0
  48. package/dist/src/activity/adapters/gemini.js.map +1 -0
  49. package/dist/src/activity/adapters/goose.d.ts +3 -0
  50. package/dist/src/activity/adapters/goose.d.ts.map +1 -0
  51. package/dist/src/activity/adapters/goose.js +116 -0
  52. package/dist/src/activity/adapters/goose.js.map +1 -0
  53. package/dist/src/activity/adapters/kiro.d.ts +3 -0
  54. package/dist/src/activity/adapters/kiro.d.ts.map +1 -0
  55. package/dist/src/activity/adapters/kiro.js +47 -0
  56. package/dist/src/activity/adapters/kiro.js.map +1 -0
  57. package/dist/src/activity/adapters/openai-compatible.d.ts.map +1 -1
  58. package/dist/src/activity/adapters/openai-compatible.js +9 -1
  59. package/dist/src/activity/adapters/openai-compatible.js.map +1 -1
  60. package/dist/src/activity/adapters/opencode.d.ts.map +1 -1
  61. package/dist/src/activity/adapters/opencode.js +108 -1
  62. package/dist/src/activity/adapters/opencode.js.map +1 -1
  63. package/dist/src/activity/adapters/zed.d.ts +3 -0
  64. package/dist/src/activity/adapters/zed.d.ts.map +1 -0
  65. package/dist/src/activity/adapters/zed.js +134 -0
  66. package/dist/src/activity/adapters/zed.js.map +1 -0
  67. package/dist/src/activity/collector.d.ts +43 -1
  68. package/dist/src/activity/collector.d.ts.map +1 -1
  69. package/dist/src/activity/collector.js +490 -77
  70. package/dist/src/activity/collector.js.map +1 -1
  71. package/dist/src/activity/goose-sqlite-source.d.ts +33 -0
  72. package/dist/src/activity/goose-sqlite-source.d.ts.map +1 -0
  73. package/dist/src/activity/goose-sqlite-source.js +274 -0
  74. package/dist/src/activity/goose-sqlite-source.js.map +1 -0
  75. package/dist/src/activity/index.d.ts +3 -0
  76. package/dist/src/activity/index.d.ts.map +1 -1
  77. package/dist/src/activity/index.js +3 -0
  78. package/dist/src/activity/index.js.map +1 -1
  79. package/dist/src/activity/ingest.d.ts +9 -0
  80. package/dist/src/activity/ingest.d.ts.map +1 -1
  81. package/dist/src/activity/ingest.js +94 -4
  82. package/dist/src/activity/ingest.js.map +1 -1
  83. package/dist/src/activity/json-document-source.d.ts +151 -0
  84. package/dist/src/activity/json-document-source.d.ts.map +1 -0
  85. package/dist/src/activity/json-document-source.js +586 -0
  86. package/dist/src/activity/json-document-source.js.map +1 -0
  87. package/dist/src/activity/native-capabilities.d.ts.map +1 -1
  88. package/dist/src/activity/native-capabilities.js +8 -12
  89. package/dist/src/activity/native-capabilities.js.map +1 -1
  90. package/dist/src/activity/normalize.d.ts.map +1 -1
  91. package/dist/src/activity/normalize.js +64 -6
  92. package/dist/src/activity/normalize.js.map +1 -1
  93. package/dist/src/activity/opencode-message-tables.d.ts +87 -0
  94. package/dist/src/activity/opencode-message-tables.d.ts.map +1 -0
  95. package/dist/src/activity/opencode-message-tables.js +546 -0
  96. package/dist/src/activity/opencode-message-tables.js.map +1 -0
  97. package/dist/src/activity/opencode-sqlite-source.d.ts +7 -0
  98. package/dist/src/activity/opencode-sqlite-source.d.ts.map +1 -1
  99. package/dist/src/activity/opencode-sqlite-source.js +154 -63
  100. package/dist/src/activity/opencode-sqlite-source.js.map +1 -1
  101. package/dist/src/activity/source-reader.d.ts +60 -4
  102. package/dist/src/activity/source-reader.d.ts.map +1 -1
  103. package/dist/src/activity/source-reader.js +593 -75
  104. package/dist/src/activity/source-reader.js.map +1 -1
  105. package/dist/src/activity/sqlite-connections.d.ts +77 -0
  106. package/dist/src/activity/sqlite-connections.d.ts.map +1 -0
  107. package/dist/src/activity/sqlite-connections.js +220 -0
  108. package/dist/src/activity/sqlite-connections.js.map +1 -0
  109. package/dist/src/activity/zed-sqlite-source.d.ts +65 -0
  110. package/dist/src/activity/zed-sqlite-source.d.ts.map +1 -0
  111. package/dist/src/activity/zed-sqlite-source.js +547 -0
  112. package/dist/src/activity/zed-sqlite-source.js.map +1 -0
  113. package/dist/src/agent-action-executor.d.ts +18 -1
  114. package/dist/src/agent-action-executor.d.ts.map +1 -1
  115. package/dist/src/agent-action-executor.js +113 -58
  116. package/dist/src/agent-action-executor.js.map +1 -1
  117. package/dist/src/agentic-guardian.d.ts.map +1 -1
  118. package/dist/src/agentic-guardian.js +6 -5
  119. package/dist/src/agentic-guardian.js.map +1 -1
  120. package/dist/src/atomic-replace.d.ts +7 -0
  121. package/dist/src/atomic-replace.d.ts.map +1 -1
  122. package/dist/src/atomic-replace.js +28 -3
  123. package/dist/src/atomic-replace.js.map +1 -1
  124. package/dist/src/bridge-journal.d.ts +32 -0
  125. package/dist/src/bridge-journal.d.ts.map +1 -0
  126. package/dist/src/bridge-journal.js +113 -0
  127. package/dist/src/bridge-journal.js.map +1 -0
  128. package/dist/src/cli.d.ts +7 -1
  129. package/dist/src/cli.d.ts.map +1 -1
  130. package/dist/src/cli.js +534 -50
  131. package/dist/src/cli.js.map +1 -1
  132. package/dist/src/collection-notice.d.ts +34 -0
  133. package/dist/src/collection-notice.d.ts.map +1 -0
  134. package/dist/src/collection-notice.js +73 -0
  135. package/dist/src/collection-notice.js.map +1 -0
  136. package/dist/src/config.d.ts +39 -0
  137. package/dist/src/config.d.ts.map +1 -1
  138. package/dist/src/config.js +187 -36
  139. package/dist/src/config.js.map +1 -1
  140. package/dist/src/connection-config.d.ts.map +1 -1
  141. package/dist/src/connection-config.js +5 -0
  142. package/dist/src/connection-config.js.map +1 -1
  143. package/dist/src/content-redaction.d.ts +17 -1
  144. package/dist/src/content-redaction.d.ts.map +1 -1
  145. package/dist/src/content-redaction.js +51 -3
  146. package/dist/src/content-redaction.js.map +1 -1
  147. package/dist/src/credential-store.d.ts +20 -1
  148. package/dist/src/credential-store.d.ts.map +1 -1
  149. package/dist/src/credential-store.js +29 -4
  150. package/dist/src/credential-store.js.map +1 -1
  151. package/dist/src/custom-agent-registry.d.ts.map +1 -1
  152. package/dist/src/custom-agent-registry.js +2 -1
  153. package/dist/src/custom-agent-registry.js.map +1 -1
  154. package/dist/src/discovery/adapters/generic.d.ts.map +1 -1
  155. package/dist/src/discovery/adapters/generic.js +160 -2
  156. package/dist/src/discovery/adapters/generic.js.map +1 -1
  157. package/dist/src/discovery/engine.d.ts +7 -1
  158. package/dist/src/discovery/engine.d.ts.map +1 -1
  159. package/dist/src/discovery/engine.js +21 -16
  160. package/dist/src/discovery/engine.js.map +1 -1
  161. package/dist/src/discovery/known-agent.d.ts +7 -0
  162. package/dist/src/discovery/known-agent.d.ts.map +1 -1
  163. package/dist/src/discovery/known-agent.js +9 -4
  164. package/dist/src/discovery/known-agent.js.map +1 -1
  165. package/dist/src/discovery/system-evidence.d.ts +40 -1
  166. package/dist/src/discovery/system-evidence.d.ts.map +1 -1
  167. package/dist/src/discovery/system-evidence.js +189 -16
  168. package/dist/src/discovery/system-evidence.js.map +1 -1
  169. package/dist/src/doctor-check.d.ts +51 -0
  170. package/dist/src/doctor-check.d.ts.map +1 -0
  171. package/dist/src/doctor-check.js +174 -0
  172. package/dist/src/doctor-check.js.map +1 -0
  173. package/dist/src/enroll.d.ts +86 -0
  174. package/dist/src/enroll.d.ts.map +1 -0
  175. package/dist/src/enroll.js +245 -0
  176. package/dist/src/enroll.js.map +1 -0
  177. package/dist/src/entry.d.ts +3 -0
  178. package/dist/src/entry.d.ts.map +1 -0
  179. package/dist/src/entry.js +14 -0
  180. package/dist/src/entry.js.map +1 -0
  181. package/dist/src/event-queue.d.ts +13 -1
  182. package/dist/src/event-queue.d.ts.map +1 -1
  183. package/dist/src/event-queue.js +137 -42
  184. package/dist/src/event-queue.js.map +1 -1
  185. package/dist/src/event-spool.d.ts +61 -3
  186. package/dist/src/event-spool.d.ts.map +1 -1
  187. package/dist/src/event-spool.js +341 -44
  188. package/dist/src/event-spool.js.map +1 -1
  189. package/dist/src/generated/phigon/v1/identity_pb.d.ts +7 -0
  190. package/dist/src/generated/phigon/v1/identity_pb.d.ts.map +1 -1
  191. package/dist/src/generated/phigon/v1/identity_pb.js +1 -1
  192. package/dist/src/generated/phigon/v1/identity_pb.js.map +1 -1
  193. package/dist/src/guardian-containment.d.ts +2 -6
  194. package/dist/src/guardian-containment.d.ts.map +1 -1
  195. package/dist/src/guardian-containment.js +12 -63
  196. package/dist/src/guardian-containment.js.map +1 -1
  197. package/dist/src/guardian-decision-journal.d.ts.map +1 -1
  198. package/dist/src/guardian-decision-journal.js +4 -19
  199. package/dist/src/guardian-decision-journal.js.map +1 -1
  200. package/dist/src/guardian-managed-exec.d.ts.map +1 -1
  201. package/dist/src/guardian-managed-exec.js +6 -18
  202. package/dist/src/guardian-managed-exec.js.map +1 -1
  203. package/dist/src/guardian-model-bridge.d.ts.map +1 -1
  204. package/dist/src/guardian-model-bridge.js +2 -1
  205. package/dist/src/guardian-model-bridge.js.map +1 -1
  206. package/dist/src/guardian-native.d.ts +0 -1
  207. package/dist/src/guardian-native.d.ts.map +1 -1
  208. package/dist/src/guardian-native.js +0 -6
  209. package/dist/src/guardian-native.js.map +1 -1
  210. package/dist/src/guardian-process-monitor.d.ts +4 -0
  211. package/dist/src/guardian-process-monitor.d.ts.map +1 -1
  212. package/dist/src/guardian-process-monitor.js +82 -26
  213. package/dist/src/guardian-process-monitor.js.map +1 -1
  214. package/dist/src/health.d.ts +19 -1
  215. package/dist/src/health.d.ts.map +1 -1
  216. package/dist/src/health.js +22 -1
  217. package/dist/src/health.js.map +1 -1
  218. package/dist/src/historical-identity-store.d.ts +8 -1
  219. package/dist/src/historical-identity-store.d.ts.map +1 -1
  220. package/dist/src/historical-identity-store.js +28 -10
  221. package/dist/src/historical-identity-store.js.map +1 -1
  222. package/dist/src/index.d.ts +2 -0
  223. package/dist/src/index.d.ts.map +1 -1
  224. package/dist/src/index.js +2 -0
  225. package/dist/src/index.js.map +1 -1
  226. package/dist/src/installation.d.ts +2 -0
  227. package/dist/src/installation.d.ts.map +1 -1
  228. package/dist/src/installation.js +6 -3
  229. package/dist/src/installation.js.map +1 -1
  230. package/dist/src/instance-lock.d.ts +45 -0
  231. package/dist/src/instance-lock.d.ts.map +1 -0
  232. package/dist/src/instance-lock.js +243 -0
  233. package/dist/src/instance-lock.js.map +1 -0
  234. package/dist/src/machine-command-executor.d.ts +22 -7
  235. package/dist/src/machine-command-executor.d.ts.map +1 -1
  236. package/dist/src/machine-command-executor.js +80 -19
  237. package/dist/src/machine-command-executor.js.map +1 -1
  238. package/dist/src/node-version.d.ts +8 -0
  239. package/dist/src/node-version.d.ts.map +1 -0
  240. package/dist/src/node-version.js +17 -0
  241. package/dist/src/node-version.js.map +1 -0
  242. package/dist/src/observer-log.d.ts +50 -0
  243. package/dist/src/observer-log.d.ts.map +1 -0
  244. package/dist/src/observer-log.js +141 -0
  245. package/dist/src/observer-log.js.map +1 -0
  246. package/dist/src/pair.d.ts +68 -1
  247. package/dist/src/pair.d.ts.map +1 -1
  248. package/dist/src/pair.js +204 -65
  249. package/dist/src/pair.js.map +1 -1
  250. package/dist/src/private-directory.d.ts +10 -0
  251. package/dist/src/private-directory.d.ts.map +1 -0
  252. package/dist/src/private-directory.js +66 -0
  253. package/dist/src/private-directory.js.map +1 -0
  254. package/dist/src/process-tree.d.ts +73 -0
  255. package/dist/src/process-tree.d.ts.map +1 -0
  256. package/dist/src/process-tree.js +256 -0
  257. package/dist/src/process-tree.js.map +1 -0
  258. package/dist/src/proxy.d.ts +68 -0
  259. package/dist/src/proxy.d.ts.map +1 -0
  260. package/dist/src/proxy.js +209 -0
  261. package/dist/src/proxy.js.map +1 -0
  262. package/dist/src/purge.d.ts +17 -0
  263. package/dist/src/purge.d.ts.map +1 -0
  264. package/dist/src/purge.js +84 -0
  265. package/dist/src/purge.js.map +1 -0
  266. package/dist/src/remove-machine.d.ts +5 -1
  267. package/dist/src/remove-machine.d.ts.map +1 -1
  268. package/dist/src/remove-machine.js +28 -14
  269. package/dist/src/remove-machine.js.map +1 -1
  270. package/dist/src/server-authority.d.ts +29 -0
  271. package/dist/src/server-authority.d.ts.map +1 -0
  272. package/dist/src/server-authority.js +106 -0
  273. package/dist/src/server-authority.js.map +1 -0
  274. package/dist/src/server-clock.d.ts +32 -0
  275. package/dist/src/server-clock.d.ts.map +1 -0
  276. package/dist/src/server-clock.js +72 -0
  277. package/dist/src/server-clock.js.map +1 -0
  278. package/dist/src/service-command.d.ts +20 -0
  279. package/dist/src/service-command.d.ts.map +1 -0
  280. package/dist/src/service-command.js +136 -0
  281. package/dist/src/service-command.js.map +1 -0
  282. package/dist/src/service.d.ts +98 -0
  283. package/dist/src/service.d.ts.map +1 -0
  284. package/dist/src/service.js +472 -0
  285. package/dist/src/service.js.map +1 -0
  286. package/dist/src/stream-client.d.ts +21 -1
  287. package/dist/src/stream-client.d.ts.map +1 -1
  288. package/dist/src/stream-client.js +137 -24
  289. package/dist/src/stream-client.js.map +1 -1
  290. package/dist/src/stream-state.d.ts +2 -0
  291. package/dist/src/stream-state.d.ts.map +1 -1
  292. package/dist/src/stream-state.js +5 -2
  293. package/dist/src/stream-state.js.map +1 -1
  294. package/dist/src/system-inspection.d.ts +78 -0
  295. package/dist/src/system-inspection.d.ts.map +1 -0
  296. package/dist/src/system-inspection.js +281 -0
  297. package/dist/src/system-inspection.js.map +1 -0
  298. package/dist/src/update.d.ts.map +1 -1
  299. package/dist/src/update.js +9 -3
  300. package/dist/src/update.js.map +1 -1
  301. package/dist/src/user-notices.d.ts +64 -0
  302. package/dist/src/user-notices.d.ts.map +1 -0
  303. package/dist/src/user-notices.js +141 -0
  304. package/dist/src/user-notices.js.map +1 -0
  305. package/package.json +7 -7
  306. package/dist/src/windows-process.d.ts +0 -7
  307. package/dist/src/windows-process.d.ts.map +0 -1
  308. package/dist/src/windows-process.js +0 -18
  309. package/dist/src/windows-process.js.map +0 -1
package/dist/src/cli.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { create, toJsonString } from "@bufbuild/protobuf";
3
3
  import { timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt";
4
- import { constants, realpathSync } from "node:fs";
4
+ import { constants, readFileSync, realpathSync } from "node:fs";
5
5
  import { access, readFile } from "node:fs/promises";
6
+ import { dirname, resolve } from "node:path";
6
7
  import { createInterface } from "node:readline";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import { FileActionPolicyStore, managedActionNames, } from "./action-policy.js";
@@ -10,7 +11,7 @@ import { GuardianDecisionUnavailableError, HTTPGuardianDecisionClient, readGuard
10
11
  import { AgentActionExecutor } from "./agent-action-executor.js";
11
12
  import { ActivityCollector, historySyncQueueHeadroom, mergeInventoryWithSourceIdentities, registeredAgentActivitySources, } from "./activity/collector.js";
12
13
  import { ingestActivityJSONL } from "./activity/ingest.js";
13
- import { loadConfig, DEFAULT_CONTROL_PLANE_ORIGIN, } from "./config.js";
14
+ import { consentedCollectionPolicy, contentOutsideProfileMessage, dataDirectoryFrom, loadConfig, DEFAULT_CONTROL_PLANE_ORIGIN, } from "./config.js";
14
15
  import { FileCredentialStore } from "./credential-store.js";
15
16
  import { CustomAgentRegistry, customAgentRegistration, } from "./custom-agent-registry.js";
16
17
  import { DiscoveryEngine, runDiscoveryLoop, } from "./discovery/index.js";
@@ -18,9 +19,16 @@ import { AgentState } from "./generated/phigon/v1/common_pb.js";
18
19
  import { AgentInventorySchema } from "./generated/phigon/v1/identity_pb.js";
19
20
  import { HistorySyncResultSchema, HistorySyncResultState, } from "./generated/phigon/v1/history_sync_pb.js";
20
21
  import { HistoricalIdentityStore, mergeInventoryWithHistoricalIdentities, } from "./historical-identity-store.js";
21
- import { createHealthSnapshot, } from "./health.js";
22
+ import { consentHealth, createHealthSnapshot, describeConsent, } from "./health.js";
22
23
  import { OBSERVER_CONSENT_POLICY_VERSION, pairMachine, } from "./pair.js";
24
+ import { EnrollmentError, checkedEnrollmentCode, enrollMachine, enrollmentCodeVariable, } from "./enroll.js";
23
25
  import { removeMachine } from "./remove-machine.js";
26
+ import { forgetServerAuthority, parseAuthorityFingerprint, pinServerAuthority, trustAuthority } from "./server-authority.js";
27
+ import { readServiceSettings, startService, stopService, uninstallService } from "./service.js";
28
+ import { runServiceCommand, serviceContext } from "./service-command.js";
29
+ import { purgeDataDirectory, removeEmptyDataDirectory } from "./purge.js";
30
+ import { hasByteHeadroom } from "./event-queue.js";
31
+ import { InstanceLock, ObserverAlreadyRunningError, processIsAlive } from "./instance-lock.js";
24
32
  import { clearEventSpool } from "./event-spool.js";
25
33
  import { GuardianDecisionJournal } from "./guardian-decision-journal.js";
26
34
  import { GuardianContainmentExecutor, } from "./guardian-containment.js";
@@ -34,15 +42,29 @@ import { semanticDecision } from "./agentic-guardian.js";
34
42
  import { guardianRunnerPath } from "./guardian-native.js";
35
43
  import { GuardianProcessMonitor } from "./guardian-process-monitor.js";
36
44
  import { setTimeout as delay } from "node:timers/promises";
45
+ import { ServerClock, describeClockSkew } from "./server-clock.js";
37
46
  import { StreamStateStore } from "./stream-state.js";
38
47
  import { connectionEnvironment } from "./connection-config.js";
39
48
  import { latestObserverVersion, newerVersion, offerObserverUpdate } from "./update.js";
49
+ import { UserNotices } from "./user-notices.js";
50
+ import { announceCompanyCollection } from "./collection-notice.js";
51
+ import { defaultMaxDeliveryAgeMinutes, formatDoctorCheck, runDoctorCheck, } from "./doctor-check.js";
52
+ import { proxyForUrl, proxySettings, useProxyFromEnvironment, } from "./proxy.js";
53
+ import { RotatingLogFile, levelledOutput, logLevelFrom, } from "./observer-log.js";
40
54
  const defaultOutput = {
41
55
  stdout: (value) => process.stdout.write(value),
42
56
  stderr: (value) => process.stderr.write(value),
43
57
  };
44
- export async function run(argv, environment = process.env, output = defaultOutput, input = process.stdin) {
58
+ export async function run(argv, environment = process.env, output = defaultOutput, input = process.stdin, dependencies = {}) {
45
59
  const [command, ...flags] = argv;
60
+ let logged;
61
+ // A background service runs `run --service-settings <file>`; the file
62
+ // sits in the data directory and holds the settings it was installed with.
63
+ const serviceSettingsFile = command === "run" ? flagValue(flags, "--service-settings") : undefined;
64
+ const ownEnvironment = environment === process.env;
65
+ if (serviceSettingsFile !== undefined) {
66
+ environment = { ...environment, PHIGON_DATA_DIR: dirname(resolve(serviceSettingsFile)) };
67
+ }
46
68
  if (command === "--version" || command === "-v" || command === "version") {
47
69
  output.stdout(`${await readVersion()}\n`);
48
70
  return 0;
@@ -61,17 +83,51 @@ export async function run(argv, environment = process.env, output = defaultOutpu
61
83
  command !== "pair" &&
62
84
  command !== "remove-machine" &&
63
85
  command !== "unpair" &&
86
+ command !== "service" &&
64
87
  command !== "run") {
65
88
  output.stderr(`Unknown command: ${command}\n\n${helpText()}`);
66
89
  return 2;
67
90
  }
91
+ if (command === "run") {
92
+ // A background service throws away what it prints, so `run` also keeps
93
+ // a log file in the data directory, at PHIGON_LOG_LEVEL.
94
+ const log = new RotatingLogFile(dataDirectoryFrom(environment), {
95
+ onFailure: (message) => output.stderr(message),
96
+ });
97
+ logged = levelledOutput(output, logLevelFrom(environment["PHIGON_LOG_LEVEL"]), log);
98
+ output = logged;
99
+ }
100
+ let callerEnvironment = environment;
101
+ let proxy;
68
102
  try {
103
+ if (serviceSettingsFile !== undefined) {
104
+ const settings = await readServiceSettings(serviceSettingsFile);
105
+ environment = { ...environment, ...settings, PHIGON_DATA_DIR: dirname(resolve(serviceSettingsFile)) };
106
+ if (ownEnvironment) {
107
+ // PATH (to find agents) and the proxy variables (for grpc-js) are
108
+ // read from the process itself.
109
+ Object.assign(process.env, settings);
110
+ }
111
+ callerEnvironment = environment;
112
+ trustExtraCertificates(settings["NODE_EXTRA_CA_CERTS"], output);
113
+ }
69
114
  environment = command === "update" ? { PHIGON_DATA_DIR: environment["PHIGON_DATA_DIR"] } : await connectionEnvironment(environment, command === "pair" || command === "run" || command === "doctor" ? flags : []);
70
115
  const config = loadConfig(observabilityEnvironment(command, flags, environment));
116
+ // Every HTTPS call and the telemetry stream go through the proxy that
117
+ // HTTPS_PROXY, HTTP_PROXY and NO_PROXY name, when there is one.
118
+ proxy = useProxyFromEnvironment(callerEnvironment, { streamUrl: config.grpcUrl });
119
+ if (proxy.notice !== undefined)
120
+ output.stderr(`${proxy.notice}\n`);
71
121
  const version = await readVersion();
72
122
  const json = flags.includes("--json");
73
- const interactive = input === process.stdin && process.stdin.isTTY === true && process.stdout.isTTY === true && !json;
74
- if (command === "update" || (interactive && (command === "pair" || command === "run") && environment["PHIGON_UPDATE_CHECK"] !== "false")) {
123
+ // The company's code a device management script enrolls with. It never
124
+ // prompts, so it is never offered an update either.
125
+ const enrollmentCode = command === "pair" ? enrollmentCodeFrom(flags, callerEnvironment) : undefined;
126
+ // A background service has a console under conhost on Windows, but nobody
127
+ // reads it: it never prompts.
128
+ const interactive = input === process.stdin && process.stdin.isTTY === true && process.stdout.isTTY === true && !json &&
129
+ serviceSettingsFile === undefined;
130
+ if (command === "update" || (interactive && ((command === "pair" && enrollmentCode === undefined) || command === "run") && environment["PHIGON_UPDATE_CHECK"] !== "false")) {
75
131
  const latest = await latestObserverVersion(config.dataDirectory, { force: command === "update" });
76
132
  if (command === "update" && (json || flags.includes("--check"))) {
77
133
  output.stdout(`${JSON.stringify({ current: version, latest, updateAvailable: latest !== null && newerVersion(version, latest), status: latest === null ? "unavailable" : "checked" })}\n`);
@@ -88,6 +144,9 @@ export async function run(argv, environment = process.env, output = defaultOutpu
88
144
  }
89
145
  }
90
146
  const customAgentRegistry = new CustomAgentRegistry(config.dataDirectory);
147
+ if (command === "service") {
148
+ return await runServiceCommand(flags, config, callerEnvironment, output, json, dependencies);
149
+ }
91
150
  if (command === "guardian") {
92
151
  const subcommand = flags[0] ?? "status";
93
152
  if (subcommand === "exec") {
@@ -118,6 +177,8 @@ export async function run(argv, environment = process.env, output = defaultOutpu
118
177
  const action = parseManagedAction(flagValue(flags, "--action"));
119
178
  const resource = flagValue(flags, "--resource");
120
179
  const agentID = flagValue(flags, "--agent") ?? "";
180
+ if (agentID !== "")
181
+ validatedAgentID(agentID);
121
182
  const processID = parseOptionalProcessID(flagValue(flags, "--process-id"));
122
183
  const processStartedAt = flagValue(flags, "--process-started-at");
123
184
  if ((processID === undefined) !== (processStartedAt === undefined)) {
@@ -343,6 +404,9 @@ export async function run(argv, environment = process.env, output = defaultOutpu
343
404
  return result.records > 0 ? 0 : 1;
344
405
  }
345
406
  if (command === "remove-machine" || command === "unpair") {
407
+ if (flags.includes("--purge")) {
408
+ return await removeMachineAndPurge(config, callerEnvironment, flags, json, output, input, dependencies);
409
+ }
346
410
  const credentialStore = new FileCredentialStore(config.dataDirectory);
347
411
  const credential = await credentialStore.load();
348
412
  if (credential === null) {
@@ -351,21 +415,42 @@ export async function run(argv, environment = process.env, output = defaultOutpu
351
415
  if (json && !flags.includes("--yes")) {
352
416
  throw new Error("JSON machine removal requires --yes after reviewing the removal scope.");
353
417
  }
354
- if (!flags.includes("--yes")) {
355
- output.stdout(removeMachinePrompt(credential.machineId));
356
- const confirmed = await readConfirmation(input);
357
- if (confirmed !== "remove") {
358
- throw new Error("Machine removal was not confirmed. No request was sent.");
418
+ // A running observer would keep writing the queue this removes, then
419
+ // stop as unpaired and be restarted by its service over and over.
420
+ let removalLock;
421
+ try {
422
+ removalLock = await InstanceLock.acquire(config.dataDirectory);
423
+ }
424
+ catch (error) {
425
+ if (!(error instanceof ObserverAlreadyRunningError))
426
+ throw error;
427
+ throw new Error("A Phigon observer is running with this data directory. Stop it, " +
428
+ "or stop its service (phigon-observer service uninstall), and then " +
429
+ "run remove-machine again; remove-machine --purge stops the service " +
430
+ "itself. No request was sent.");
431
+ }
432
+ let result;
433
+ try {
434
+ if (!flags.includes("--yes")) {
435
+ output.stdout(removeMachinePrompt(credential.machineId));
436
+ const confirmed = await readConfirmation(input);
437
+ if (confirmed !== "remove") {
438
+ throw new Error("Machine removal was not confirmed. No request was sent.");
439
+ }
359
440
  }
441
+ result = await removeMachine({
442
+ config,
443
+ credential,
444
+ clearCredential: async () => {
445
+ clearEventSpool(config.dataDirectory);
446
+ await forgetServerAuthority(config.dataDirectory);
447
+ return await credentialStore.clear();
448
+ },
449
+ });
450
+ }
451
+ finally {
452
+ await removalLock.release();
360
453
  }
361
- const result = await removeMachine({
362
- config,
363
- credential,
364
- clearCredential: async () => {
365
- clearEventSpool(config.dataDirectory);
366
- return await credentialStore.clear();
367
- },
368
- });
369
454
  output.stdout(json
370
455
  ? `${JSON.stringify(result, null, 2)}\n`
371
456
  : formatRemoveMachineResult(result));
@@ -386,6 +471,22 @@ export async function run(argv, environment = process.env, output = defaultOutpu
386
471
  const stop = () => controller.abort();
387
472
  process.once("SIGINT", stop);
388
473
  process.once("SIGTERM", stop);
474
+ // On Windows the service runs the observer under conhost, and ending
475
+ // the scheduled task ends only conhost; stop with it.
476
+ // A service's console can disappear before the observer has stopped;
477
+ // writing to it must not end the process, the log keeps the message.
478
+ const ignoreConsoleError = () => undefined;
479
+ if (serviceSettingsFile !== undefined) {
480
+ process.stdout.on("error", ignoreConsoleError);
481
+ process.stderr.on("error", ignoreConsoleError);
482
+ }
483
+ const stopWatchingParent = serviceSettingsFile !== undefined && process.platform === "win32"
484
+ ? watchParentProcess(process.ppid, () => {
485
+ output.stdout("The background service stopped, so the observer stops too.\n");
486
+ controller.abort();
487
+ })
488
+ : undefined;
489
+ let instanceLock;
389
490
  try {
390
491
  const credential = await new FileCredentialStore(config.dataDirectory).load();
391
492
  if (credential === null) {
@@ -404,7 +505,15 @@ export async function run(argv, environment = process.env, output = defaultOutpu
404
505
  credential.consentProfile !== "full") {
405
506
  throw new Error("Full-content collection is outside the consented metadata profile.");
406
507
  }
407
- let effectivePolicy = intersectCollectionPolicy(config.policy);
508
+ if (config.contentSettingsOutsideProfile.length > 0) {
509
+ throw new Error(contentOutsideProfileMessage(config.contentSettingsOutsideProfile));
510
+ }
511
+ // Before any queued activity, cursor or saved state is read. A
512
+ // signal only aborts the tasks, so `finally` releases the lock.
513
+ instanceLock = await InstanceLock.acquire(config.dataDirectory);
514
+ logged?.debug(`Observer ${version} started for machine ${credential.machineId} ` +
515
+ `with the ${config.observabilityProfile} profile, using ${config.dataDirectory}.\n`);
516
+ let effectivePolicy = intersectCollectionPolicy(config.policy, credential.consentProfile);
408
517
  let collector;
409
518
  let resolveCollector;
410
519
  const collectorReady = new Promise((resolve) => {
@@ -414,16 +523,24 @@ export async function run(argv, environment = process.env, output = defaultOutpu
414
523
  const discoveryEngine = new DiscoveryEngine({
415
524
  customAgentRegistry,
416
525
  });
526
+ const userNotices = new UserNotices({ enabled: config.userNotices });
527
+ const serverClock = new ServerClock({
528
+ onLargeSkew: (offsetMs) => output.stdout(`${describeClockSkew(offsetMs)}\n`),
529
+ });
417
530
  const actionExecutor = new AgentActionExecutor({
531
+ notices: userNotices,
418
532
  enabled: config.remoteActionsEnabled,
419
533
  commandsEnabled: config.remoteCommandsEnabled,
420
534
  machineID: credential.machineId,
421
535
  dataDirectory: config.dataDirectory,
422
536
  inventory: async () => (await discoveryEngine.discover(credential.machineId)).inventory,
537
+ serverNow: () => serverClock.now(),
423
538
  });
424
539
  const client = new ObserverStreamClient({
425
540
  config,
426
541
  actionExecutor,
542
+ serverClock,
543
+ observerVersion: version,
427
544
  onHistorySync: async (syncCommand) => {
428
545
  const rejected = (detail) => create(HistorySyncResultSchema, {
429
546
  jobId: syncCommand.jobId,
@@ -445,7 +562,7 @@ export async function run(argv, environment = process.env, output = defaultOutpu
445
562
  return rejected("Workspace collection policy does not allow readable prompts, replies, and tool activity.");
446
563
  }
447
564
  const expiresAt = timestampDate(syncCommand.expiresAt).getTime();
448
- const remainingMs = expiresAt - Date.now();
565
+ const remainingMs = expiresAt - serverClock.now();
449
566
  if (!Number.isFinite(remainingMs) || remainingMs <= 0) {
450
567
  return rejected("History sync command expired before execution.");
451
568
  }
@@ -468,7 +585,7 @@ export async function run(argv, environment = process.env, output = defaultOutpu
468
585
  },
469
586
  onStatus: (message) => output.stdout(`${message}\n`),
470
587
  onPolicy: (policy) => {
471
- effectivePolicy = intersectCollectionPolicy(config.policy, policy);
588
+ effectivePolicy = intersectCollectionPolicy(config.policy, credential.consentProfile, policy);
472
589
  collector?.updatePolicy(effectivePolicy);
473
590
  },
474
591
  onCursorReset: (acknowledgedSequence, allocationSequence) => {
@@ -491,11 +608,16 @@ export async function run(argv, environment = process.env, output = defaultOutpu
491
608
  const historicalStore = new HistoricalIdentityStore(config.dataDirectory, {
492
609
  tenantId: credential.tenantId,
493
610
  machineId: credential.machineId,
611
+ }, {
612
+ onSaveError: (error) => output.stderr(`Saved agent identities could not be written (${(error instanceof Error ? error.message : String(error))
613
+ .replace(/\s+/gu, " ")
614
+ .slice(0, 200)}); retrying on the next pass.\n`),
494
615
  });
495
616
  let historicalIdentities = await historicalStore.load();
496
617
  const initialDiscovery = await discoveryEngine.discover(credential.machineId);
497
618
  historicalIdentities = await historicalStore.remember(initialDiscovery.inventory.agents);
498
619
  let latestDiscoveryInventory = initialDiscovery.inventory;
620
+ actionExecutor.observeInventory(latestDiscoveryInventory);
499
621
  const publishInventory = () => {
500
622
  const withHistory = mergeInventoryWithHistoricalIdentities(latestDiscoveryInventory, historicalIdentities);
501
623
  client.updateInventory(mergeInventoryWithSourceIdentities(withHistory, collector?.sourceIdentities() ?? []));
@@ -505,10 +627,13 @@ export async function run(argv, environment = process.env, output = defaultOutpu
505
627
  const streaming = client.run(controller.signal);
506
628
  const discovering = runDiscoveryLoop(discoveryEngine, credential.machineId, config.discoveryIntervalMs, controller.signal, async (inventory) => {
507
629
  latestDiscoveryInventory = inventory;
630
+ actionExecutor.observeInventory(inventory);
508
631
  collector?.updateInventory(inventory);
509
632
  historicalIdentities = await historicalStore.remember(inventory.agents);
510
633
  publishInventory();
511
- });
634
+ }, (error) => output.stderr(`Agent discovery failed and will retry on the next pass: ${(error instanceof Error ? error.message : String(error))
635
+ .replace(/\s+/gu, " ")
636
+ .slice(0, 200)}\n`));
512
637
  const processMonitor = new GuardianProcessMonitor({
513
638
  coverageClient: new GuardianHostedClient({ controlPlaneURL: config.controlPlaneUrl, credential: credential.credential }),
514
639
  machineId: credential.machineId,
@@ -523,10 +648,23 @@ export async function run(argv, environment = process.env, output = defaultOutpu
523
648
  inventory: async () => (await discoveryEngine.discover(credential.machineId)).inventory,
524
649
  }),
525
650
  journal: new GuardianDecisionJournal(config.dataDirectory),
651
+ notices: userNotices,
526
652
  onStatus: (message) => output.stdout(`${message}\n`),
527
653
  });
654
+ // Being stopped before the stream first connects is a normal stop.
655
+ const streamReady = async () => {
656
+ try {
657
+ return await client.waitUntilReady(controller.signal);
658
+ }
659
+ catch (error) {
660
+ if (controller.signal.aborted)
661
+ return undefined;
662
+ throw error;
663
+ }
664
+ };
528
665
  const monitoring = (async () => {
529
- await client.waitUntilReady(controller.signal);
666
+ if (await streamReady() === undefined)
667
+ return;
530
668
  while (!controller.signal.aborted) {
531
669
  try {
532
670
  await processMonitor.review(latestDiscoveryInventory, controller.signal);
@@ -543,24 +681,47 @@ export async function run(argv, environment = process.env, output = defaultOutpu
543
681
  }
544
682
  })();
545
683
  const collecting = (async () => {
546
- const allocationSequence = await client.waitUntilReady(controller.signal);
684
+ const allocationSequence = await streamReady();
685
+ if (allocationSequence === undefined)
686
+ return;
687
+ // Collection starts now. Where the company consented, tell the
688
+ // person at the computer; a notice never holds collection up.
689
+ void announceCompanyCollection({
690
+ dataDirectory: config.dataDirectory,
691
+ credential,
692
+ profile: config.observabilityProfile,
693
+ notices: userNotices,
694
+ }).then((outcome) => {
695
+ if (outcome === "shown")
696
+ output.stdout("Told the person at this computer that Phigon is recording AI agent activity.\n");
697
+ }, () => output.stderr("The start-of-collection notice could not be remembered; it is shown again on the next start.\n"));
547
698
  collector = new ActivityCollector({
548
699
  config,
549
700
  credential,
550
701
  initialSequence: allocationSequence,
551
702
  initialAcknowledgedSequence: client.metrics().lastAcknowledgedSequence,
552
703
  durableDelivery: true,
704
+ flushDelivery: () => client.flush(),
553
705
  policy: effectivePolicy,
554
706
  emit: (event) => client.enqueue(event),
707
+ // The spool also has a byte budget; reaching it fails delivery
708
+ // instead of waiting, so both stop well before it.
555
709
  canCollect: () => {
556
710
  const queue = client.metrics().queue;
557
- return (queue.capacity - queue.depth >=
558
- Math.max(32, config.activityRateLimitPerMinute));
711
+ return (hasByteHeadroom(queue) &&
712
+ queue.capacity - queue.depth >=
713
+ Math.max(32, config.activityRateLimitPerMinute));
559
714
  },
560
715
  canSyncHistory: () => {
561
716
  const queue = client.metrics().queue;
562
- return queue.capacity - queue.depth >= Math.min(queue.capacity, historySyncQueueHeadroom);
717
+ return hasByteHeadroom(queue) &&
718
+ queue.capacity - queue.depth >= Math.min(queue.capacity, historySyncQueueHeadroom);
563
719
  },
720
+ onDeliveryRefused: (error) => output.stderr(`Telemetry delivery is paused (${(error instanceof Error ? error.message : String(error))
721
+ .replace(/\s+/gu, " ")
722
+ .slice(0, 200)}). Collection resumes from the same records once queued events are acknowledged.\n`),
723
+ onSourceFailure: (failure) => output.stderr(`Activity source ${failure.adapterId} could not be read (${failure.message}). ` +
724
+ "It is retried shortly; other sources keep collecting.\n"),
564
725
  additionalSources: async () => registeredAgentActivitySources(await customAgentRegistry.list()),
565
726
  onSourceIdentity: async (identity) => {
566
727
  // Make the source target addressable immediately, then durably
@@ -623,15 +784,36 @@ export async function run(argv, environment = process.env, output = defaultOutpu
623
784
  }
624
785
  }
625
786
  })();
626
- await settleObserverTasks([streaming, discovering, collecting, monitoring, maintaining], controller, () => fatalPersistenceError);
787
+ try {
788
+ await settleObserverTasks([streaming, discovering, collecting, monitoring, maintaining], controller, () => fatalPersistenceError);
789
+ }
790
+ finally {
791
+ // Write what the spool still buffers. Nothing depends on it:
792
+ // checkpoints only moved past written events, and a lost
793
+ // acknowledgement only sends an event again.
794
+ try {
795
+ client.flush();
796
+ }
797
+ catch { /* Read again on the next start. */ }
798
+ }
627
799
  output.stdout("Observer stopped.\n");
628
800
  }
629
801
  finally {
630
802
  process.removeListener("SIGINT", stop);
631
803
  process.removeListener("SIGTERM", stop);
804
+ stopWatchingParent?.();
805
+ process.stdout.removeListener("error", ignoreConsoleError);
806
+ process.stderr.removeListener("error", ignoreConsoleError);
807
+ await instanceLock?.release();
632
808
  }
633
809
  return 0;
634
810
  }
811
+ if (command === "pair" && enrollmentCode !== undefined) {
812
+ if (config.controlPlaneUrl.origin !== DEFAULT_CONTROL_PLANE_ORIGIN && !config.grpcEndpointConfigured) {
813
+ throw new Error("Custom workspaces require --grpc-url alongside --control-plane-url (or PHIGON_GRPC_URL). Copy both endpoints from your workspace's Connect machine dialog.");
814
+ }
815
+ return await enrollWithCode(enrollmentCode, flags, config, callerEnvironment, json, version, output);
816
+ }
635
817
  if (command === "pair") {
636
818
  if (config.controlPlaneUrl.origin !== DEFAULT_CONTROL_PLANE_ORIGIN && !config.grpcEndpointConfigured) {
637
819
  throw new Error("Custom workspaces require --grpc-url alongside --control-plane-url (or PHIGON_GRPC_URL). Copy both endpoints from your workspace's Connect machine dialog.");
@@ -642,6 +824,8 @@ export async function run(argv, environment = process.env, output = defaultOutpu
642
824
  }
643
825
  const displayName = flagValue(flags, "--name");
644
826
  const consentProfile = parseConsentProfile(flagValue(flags, "--consent-profile"));
827
+ const authorityFlag = flagValue(flags, "--server-ca-sha256");
828
+ const authority = authorityFlag === undefined ? undefined : parseAuthorityFingerprint(authorityFlag);
645
829
  if (json && !flags.includes("--accept-consent")) {
646
830
  throw new Error("JSON pairing requires --accept-consent after reviewing the selected profile.");
647
831
  }
@@ -652,17 +836,41 @@ export async function run(argv, environment = process.env, output = defaultOutpu
652
836
  throw new Error("Consent was not granted. No pairing request was sent.");
653
837
  }
654
838
  }
655
- const result = await pairMachine({
656
- config,
657
- code,
658
- consentProfile,
659
- ...(displayName === undefined ? {} : { displayName }),
660
- });
839
+ if (authority !== undefined) {
840
+ await pinServerAuthority(config.controlPlaneUrl, authority, config.dataDirectory, undefined, {
841
+ proxy: proxyForUrl(config.controlPlaneUrl, proxySettings(callerEnvironment)),
842
+ });
843
+ }
844
+ let result;
845
+ try {
846
+ result = await pairMachine({
847
+ config,
848
+ code,
849
+ consentProfile,
850
+ ...(displayName === undefined ? {} : { displayName }),
851
+ });
852
+ }
853
+ catch (error) {
854
+ // An authority pinned for a pairing that failed is not kept.
855
+ if (authority !== undefined && await new FileCredentialStore(config.dataDirectory).load() === null) {
856
+ await forgetServerAuthority(config.dataDirectory);
857
+ }
858
+ throw error;
859
+ }
661
860
  output.stdout(json
662
861
  ? `${JSON.stringify(result, null, 2)}\n`
663
862
  : formatPairResult(result));
664
863
  return 0;
665
864
  }
865
+ if (flags.includes("--check")) {
866
+ const report = await runDoctorCheck({
867
+ config,
868
+ observerVersion: version,
869
+ maxDeliveryAgeMinutes: parseMaxDeliveryAge(flagValue(flags, "--max-delivery-age")),
870
+ });
871
+ output.stdout(json ? `${JSON.stringify(report, null, 2)}\n` : formatDoctorCheck(report));
872
+ return report.exitCode;
873
+ }
666
874
  const storedCredential = await new FileCredentialStore(config.dataDirectory).load();
667
875
  let streamCursor;
668
876
  let streamCursorIssue;
@@ -682,15 +890,107 @@ export async function run(argv, environment = process.env, output = defaultOutpu
682
890
  const health = createHealthSnapshot({
683
891
  ...config,
684
892
  machineTokenConfigured: config.machineTokenConfigured || storedCredential !== null,
685
- }, version, new Date(), streamCursor, streamCursorIssue);
893
+ }, version, new Date(), streamCursor, streamCursorIssue, storedCredential === null ? undefined : consentHealth(storedCredential));
686
894
  output.stdout(json ? `${JSON.stringify(health, null, 2)}\n` : formatHealth(health));
687
895
  return 0;
688
896
  }
689
897
  catch (error) {
690
898
  const message = error instanceof Error ? error.message : "Unknown error.";
691
- output.stderr(`Observer command failed: ${message}\n`);
899
+ (logged?.error ?? output.stderr)(`Observer command failed: ${message}\n`);
900
+ if (error instanceof EnrollmentError) {
901
+ if (flags.includes("--json")) {
902
+ output.stdout(`${JSON.stringify({
903
+ enrolled: false,
904
+ error: { code: error.code, message: error.message },
905
+ exitCode: error.exitCode,
906
+ }, null, 2)}\n`);
907
+ }
908
+ return error.exitCode;
909
+ }
692
910
  return 1;
693
911
  }
912
+ finally {
913
+ proxy?.restore();
914
+ }
915
+ }
916
+ /**
917
+ * The enrollment code from --enrollment-code or PHIGON_ENROLLMENT_CODE, or
918
+ * undefined for an owner's pairing. The value is never repeated in errors.
919
+ */
920
+ function enrollmentCodeFrom(flags, environment) {
921
+ const flagGiven = flags.some((flag) => flag === "--enrollment-code" || flag.startsWith("--enrollment-code="));
922
+ const fromFlag = flagValue(flags, "--enrollment-code");
923
+ if (flagGiven && (fromFlag === undefined || fromFlag.trim() === "")) {
924
+ throw new Error(`--enrollment-code needs the code. Device management scripts should set ${enrollmentCodeVariable} instead, which keeps it out of process lists.`);
925
+ }
926
+ const fromEnvironment = environment[enrollmentCodeVariable]?.trim() || undefined;
927
+ const code = fromFlag ?? fromEnvironment;
928
+ if (code === undefined)
929
+ return undefined;
930
+ if (flags.some((flag) => flag === "--code" || flag.startsWith("--code="))) {
931
+ throw new Error(`Use either --code with a one-time pairing code, or an enrollment code (--enrollment-code or ${enrollmentCodeVariable}), not both.`);
932
+ }
933
+ if (flags.some((flag) => flag === "--accept-consent" || flag === "--consent-profile" || flag.startsWith("--consent-profile="))) {
934
+ throw new Error("With an enrollment code your company has chosen the collection profile and given consent. Remove --consent-profile and --accept-consent.");
935
+ }
936
+ return code;
937
+ }
938
+ /**
939
+ * `pair` with an enrollment code: no prompt, the company's consent, and a
940
+ * distinct exit code for each failure a device management script can act on.
941
+ */
942
+ async function enrollWithCode(code, flags, config, environment, json, version, output) {
943
+ // Checked before anything is pinned or sent.
944
+ const enrollmentCode = checkedEnrollmentCode(code);
945
+ const displayName = flagValue(flags, "--name");
946
+ const authorityFlag = flagValue(flags, "--server-ca-sha256");
947
+ const authority = authorityFlag === undefined ? undefined : parseAuthorityFingerprint(authorityFlag);
948
+ if (authority !== undefined) {
949
+ await pinServerAuthority(config.controlPlaneUrl, authority, config.dataDirectory, undefined, {
950
+ proxy: proxyForUrl(config.controlPlaneUrl, proxySettings(environment)),
951
+ });
952
+ }
953
+ let result;
954
+ try {
955
+ result = await enrollMachine({
956
+ config,
957
+ code: enrollmentCode,
958
+ observerVersion: version,
959
+ ...(displayName === undefined ? {} : { displayName }),
960
+ onRetry: (waitMs) => output.stderr(`The Phigon server is busy. Trying again in ${Math.ceil(waitMs / 1_000)} seconds.\n`),
961
+ });
962
+ }
963
+ catch (error) {
964
+ // An authority pinned for an enrollment that failed is not kept.
965
+ if (authority !== undefined && await new FileCredentialStore(config.dataDirectory).load() === null) {
966
+ await forgetServerAuthority(config.dataDirectory);
967
+ }
968
+ throw error;
969
+ }
970
+ const warning = config.observabilityProfile === "full" && result.consent.profile !== "full"
971
+ ? "Your company's code covers the metadata profile only, so the observer does not start with PHIGON_OBSERVABILITY_PROFILE=full. Set it to metadata, or ask for a full-activity code."
972
+ : undefined;
973
+ if (warning !== undefined)
974
+ output.stderr(`${warning}\n`);
975
+ output.stdout(json ? `${JSON.stringify({ enrolled: true, ...result }, null, 2)}\n` : formatEnrollResult(result));
976
+ return 0;
977
+ }
978
+ function formatEnrollResult(result) {
979
+ return [
980
+ "Computer enrolled.",
981
+ `Machine: ${result.displayName} (${result.machineId})`,
982
+ `Control plane: ${result.controlPlaneOrigin}`,
983
+ `Consent: given by your company${result.consent.enrollmentCodeLabel === undefined ? "" : ` with the enrollment code "${result.consent.enrollmentCodeLabel}"`}, ${result.consent.profile} profile`,
984
+ "Machine credential: stored securely",
985
+ ...(result.replacedMachineId === undefined
986
+ ? []
987
+ : [
988
+ `Earlier machine: ${result.replacedMachineId} (the workspace no longer accepted its key)`,
989
+ "This computer was enrolled as a new machine. Activity the earlier machine had not delivered was moved to the retired-pairings folder in the data directory.",
990
+ ]),
991
+ "Next step: phigon-observer service install",
992
+ "",
993
+ ].join("\n");
694
994
  }
695
995
  function guardianAgentMessage(action, allowed, approvalRequired, reason, adminContract, approvalState) {
696
996
  const operation = action.replace(".", " ");
@@ -745,6 +1045,7 @@ async function runGuardianLaunch(flags, environment, output, input, dataDirector
745
1045
  if (agentID === undefined) {
746
1046
  throw new Error("guardian launch requires --agent <agent-id>.");
747
1047
  }
1048
+ validatedAgentID(agentID);
748
1049
  const model = validatedModelResource(flagValue(gatewayFlags, "--model"));
749
1050
  const executable = flags[separator + 1];
750
1051
  const commandArguments = flags.slice(separator + 2);
@@ -754,6 +1055,14 @@ async function runGuardianLaunch(flags, environment, output, input, dataDirector
754
1055
  }
755
1056
  return await runGuardedCommand(executable, commandArguments, gatewayFlags, model, environment, output, dataDirectory);
756
1057
  }
1058
+ // The control plane refuses any other agent ID with a generic manifest error,
1059
+ // so check here and say what is wrong.
1060
+ function validatedAgentID(value) {
1061
+ if (!/^[A-Za-z0-9._:-]{1,128}$/u.test(value)) {
1062
+ throw new Error("--agent must be 1-128 letters, numbers, dots, underscores, colons or hyphens.");
1063
+ }
1064
+ return value;
1065
+ }
757
1066
  function validateGuardianExecFlags(flags) {
758
1067
  const valued = new Set([
759
1068
  "--agent",
@@ -806,7 +1115,7 @@ async function runGuardedCommand(executable, arguments_, flags, model, environme
806
1115
  output.stdout("Phigon Guardian blocked execution: administrator policy is unavailable.\n");
807
1116
  return 77;
808
1117
  }
809
- const agentId = flagValue(flags, "--agent") ?? "managed-agent";
1118
+ const agentId = validatedAgentID(flagValue(flags, "--agent") ?? "managed-agent");
810
1119
  const context = await readGuardianActionContext(flagValue(flags, "--context-file"));
811
1120
  const hosted = new GuardianHostedClient({ controlPlaneURL: config.controlPlaneUrl, credential: credential.credential });
812
1121
  const childEnvironment = executionEnvironment({ ...process.env, ...environment });
@@ -910,6 +1219,37 @@ async function runGuardedCommand(executable, arguments_, flags, model, environme
910
1219
  await bridge?.close();
911
1220
  }
912
1221
  }
1222
+ /**
1223
+ * Calls `onGone` once the process `pid` has exited, checking every few
1224
+ * seconds. Returns a function that stops watching.
1225
+ */
1226
+ export function watchParentProcess(pid, onGone, intervalMs = 5_000, isAlive = processIsAlive) {
1227
+ const timer = setInterval(() => {
1228
+ if (pid > 0 && isAlive(pid))
1229
+ return;
1230
+ clearInterval(timer);
1231
+ onGone();
1232
+ }, intervalMs);
1233
+ timer.unref();
1234
+ return () => clearInterval(timer);
1235
+ }
1236
+ /**
1237
+ * NODE_EXTRA_CA_CERTS from the service settings. Node.js reads the variable
1238
+ * only when it starts, so the service adds those authorities itself.
1239
+ */
1240
+ function trustExtraCertificates(file, output) {
1241
+ if (file === undefined || file === "")
1242
+ return;
1243
+ try {
1244
+ const certificates = readFileSync(file, "utf8")
1245
+ .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/gu) ?? [];
1246
+ for (const certificate of certificates)
1247
+ trustAuthority(`${certificate}\n`);
1248
+ }
1249
+ catch {
1250
+ output.stderr(`The certificates in NODE_EXTRA_CA_CERTS (${file}) could not be read; HTTPS uses the system's authorities only.\n`);
1251
+ }
1252
+ }
913
1253
  export async function settleObserverTasks(tasks, controller, fatalError) {
914
1254
  try {
915
1255
  await Promise.all(tasks);
@@ -922,7 +1262,8 @@ export async function settleObserverTasks(tasks, controller, fatalError) {
922
1262
  await Promise.allSettled(tasks);
923
1263
  }
924
1264
  }
925
- function intersectCollectionPolicy(local, server) {
1265
+ function intersectCollectionPolicy(configured, consentProfile, server) {
1266
+ const local = consentedCollectionPolicy(configured, consentProfile);
926
1267
  return Object.freeze({
927
1268
  promptContent: local.promptContent &&
928
1269
  server?.promptContent === true,
@@ -989,16 +1330,22 @@ function helpText() {
989
1330
  " phigon-observer agent list [--json]",
990
1331
  " phigon-observer agent remove --id <id> [--yes] [--json]",
991
1332
  " phigon-observer doctor [--profile <metadata|full>] [--json]",
1333
+ " phigon-observer doctor --check [--max-delivery-age <minutes>] [--json]",
992
1334
  " phigon-observer update [--check] [--yes] [--json]",
993
1335
  " phigon-observer --version",
994
1336
  " phigon-observer discover [--json]",
995
1337
  " phigon-observer ingest --agent <agent-id|custom> [--name <agent-name>] [--quiet] [--json]",
996
- " phigon-observer pair --code <one-time-code> --consent-profile <metadata|full> [--control-plane-url <url> --grpc-url <url>] [--name <machine-name>] [--accept-consent] [--json]",
997
- " phigon-observer remove-machine [--yes] [--json]",
1338
+ " phigon-observer pair --code <one-time-code> --consent-profile <metadata|full> [--control-plane-url <url> --grpc-url <url> [--server-ca-sha256 <fingerprint>]] [--name <machine-name>] [--accept-consent] [--json]",
1339
+ " PHIGON_ENROLLMENT_CODE=<code> phigon-observer pair [--control-plane-url <url> --grpc-url <url> [--server-ca-sha256 <fingerprint>]] [--name <machine-name>] [--json]",
1340
+ " phigon-observer pair --enrollment-code <code> [same options]",
1341
+ " phigon-observer remove-machine [--purge] [--yes] [--json]",
998
1342
  " phigon-observer run [--profile <metadata|full>]",
1343
+ " phigon-observer service install [--profile <metadata|full>] [--json]",
1344
+ " phigon-observer service uninstall [--json]",
1345
+ " phigon-observer service status [--json]",
999
1346
  " phigon-observer help",
1000
1347
  "",
1001
- "The doctor command validates local configuration without transmitting data.",
1348
+ "The doctor command validates local configuration without transmitting data. With --check it also probes the control plane and exits non-zero when something needs attention: 3 not paired, 4 consent missing or outdated, 5 observer not running, 6 nothing delivered within --max-delivery-age minutes (default 15), 7 control plane unreachable.",
1002
1349
  "The agent commands manage dynamic local monitoring definitions for custom agents without changing observer code.",
1003
1350
  "The authorize-action command is a fail-closed decision point for managed gateways; it does not intercept actions performed outside that boundary. Stop/force-stop requires an administrator policy and exact process identity.",
1004
1351
  "The optional Guardian context file supplies bounded task_id, user_prompt, agent_plan, justification, and recent_activity fields for semantic review.",
@@ -1008,8 +1355,10 @@ function helpText() {
1008
1355
  "The discover command reports a sanitized one-time local inventory.",
1009
1356
  "The ingest command accepts NDJSON on stdin for every supported bridge; use --agent custom --name <agent-name> for any other agent.",
1010
1357
  "The pair command shows the selected collection scope, requires machine-owner acceptance, and stores that evidence with the mode-0600 credential.",
1011
- "The remove-machine command unregisters only this paired machine, revokes its credential, and requires local confirmation.",
1358
+ "With a company's enrollment code (phe_...), pair asks nothing: the company has given consent and chosen the profile, which the observer stores and keeps collection within. Prefer PHIGON_ENROLLMENT_CODE to --enrollment-code, which shows in process lists. The code is never printed or stored. A busy server is retried a few times. Exit codes: 20 try again later, 21 the code expired, was revoked, is used up or outdated, 22 not a valid code, 23 this computer already used this code, 24 already connected to this workspace. Then run phigon-observer service install.",
1359
+ "The remove-machine command stops collection on this paired machine and revokes its credential; activity already collected stays until an administrator deletes the machine or it expires. It requires local confirmation. With --purge it also removes the background service and every file the observer keeps in the data directory.",
1012
1360
  "The run command maintains an authenticated, resumable telemetry stream; --profile applies the same portable local collection profile on Windows, macOS, and Linux.",
1361
+ "The service commands run the observer in the background for the signed-in user (a Task Scheduler task, a LaunchAgent or a systemd user unit) with this data directory and these settings, starting it at sign-in and again if it stops. Install needs a paired computer with the owner's consent.",
1013
1362
  "",
1014
1363
  ].join("\n");
1015
1364
  }
@@ -1059,25 +1408,149 @@ function formatPairResult(result) {
1059
1408
  `Machine: ${result.displayName} (${result.machineId})`,
1060
1409
  `Control plane: ${result.controlPlaneOrigin}`,
1061
1410
  "Machine credential: stored securely",
1411
+ ...(result.replacedMachineId === undefined
1412
+ ? []
1413
+ : [
1414
+ `Earlier machine: ${result.replacedMachineId} (the workspace no longer accepted its key)`,
1415
+ "This computer was paired as a new machine. Activity the earlier machine had not delivered was moved to the retired-pairings folder in the data directory.",
1416
+ ]),
1062
1417
  "",
1063
1418
  ].join("\n");
1064
1419
  }
1420
+ /**
1421
+ * `remove-machine --purge`: the usual removal, then the background service
1422
+ * and every file the observer keeps on this computer. The company's copy of
1423
+ * the activity is not touched; only an administrator or retention removes it.
1424
+ */
1425
+ async function removeMachineAndPurge(config, environment, flags, json, output, input, dependencies) {
1426
+ const credentialStore = new FileCredentialStore(config.dataDirectory);
1427
+ const credential = await credentialStore.load();
1428
+ if (json && !flags.includes("--yes")) {
1429
+ throw new Error("JSON machine removal requires --yes after reviewing the removal scope.");
1430
+ }
1431
+ if (!flags.includes("--yes")) {
1432
+ output.stdout(purgePrompt(credential?.machineId, config.dataDirectory));
1433
+ if (await readConfirmation(input) !== "remove") {
1434
+ throw new Error("Machine removal was not confirmed. Nothing was changed.");
1435
+ }
1436
+ }
1437
+ // The service would restart the observer this removes, so stop it first,
1438
+ // and start it again if the removal fails.
1439
+ let service;
1440
+ try {
1441
+ service = serviceContext(config, environment, dependencies);
1442
+ }
1443
+ catch {
1444
+ // No background service on this system; there is nothing to stop.
1445
+ }
1446
+ const serviceStopped = service !== undefined && await stopService(service);
1447
+ const restartService = async () => {
1448
+ if (serviceStopped && service !== undefined)
1449
+ await startService(service).catch(() => undefined);
1450
+ };
1451
+ if (serviceStopped)
1452
+ await waitForObserverToStop(config.dataDirectory, dependencies.stopTimeoutMs ?? 15_000);
1453
+ let removalLock;
1454
+ try {
1455
+ removalLock = await InstanceLock.acquire(config.dataDirectory);
1456
+ }
1457
+ catch (error) {
1458
+ await restartService();
1459
+ if (!(error instanceof ObserverAlreadyRunningError))
1460
+ throw error;
1461
+ throw new Error("A Phigon observer is running with this data directory. Stop it, or stop its service, " +
1462
+ "and then run remove-machine again. No request was sent.");
1463
+ }
1464
+ let result;
1465
+ let purged;
1466
+ try {
1467
+ if (credential !== null) {
1468
+ try {
1469
+ result = await removeMachine({
1470
+ config,
1471
+ credential,
1472
+ clearCredential: async () => {
1473
+ clearEventSpool(config.dataDirectory);
1474
+ await forgetServerAuthority(config.dataDirectory);
1475
+ return await credentialStore.clear();
1476
+ },
1477
+ });
1478
+ }
1479
+ catch (error) {
1480
+ await removalLock.release();
1481
+ await restartService();
1482
+ throw error;
1483
+ }
1484
+ }
1485
+ if (service !== undefined)
1486
+ await uninstallService(service);
1487
+ purged = await purgeDataDirectory(config.dataDirectory, ["observer.lock"]);
1488
+ }
1489
+ finally {
1490
+ await removalLock.release();
1491
+ }
1492
+ const directoryRemoved = purged.leftBehind.length === 0 &&
1493
+ await removeEmptyDataDirectory(config.dataDirectory);
1494
+ const local = {
1495
+ serviceRemoved: serviceStopped,
1496
+ filesRemoved: purged.removed,
1497
+ dataDirectory: config.dataDirectory,
1498
+ dataDirectoryRemoved: directoryRemoved,
1499
+ leftBehind: purged.leftBehind,
1500
+ };
1501
+ output.stdout(json
1502
+ ? `${JSON.stringify({ ...(result ?? { status: "not_paired" }), purged: local }, null, 2)}\n`
1503
+ : [
1504
+ ...(result === undefined
1505
+ ? ["This computer was not paired, so nothing was sent to a workspace."]
1506
+ : [formatRemoveMachineResult(result).trimEnd()]),
1507
+ `Background service: ${serviceStopped ? "removed" : "was not installed"}`,
1508
+ directoryRemoved
1509
+ ? `Local data: removed, with ${config.dataDirectory}`
1510
+ : `Local data: removed from ${config.dataDirectory}${purged.leftBehind.length === 0 ? "" : `; ${purged.leftBehind.length} file(s) the observer did not create were left there`}`,
1511
+ "To remove the program too, run: npm uninstall --global @phigon/observer",
1512
+ "",
1513
+ ].join("\n"));
1514
+ return 0;
1515
+ }
1516
+ async function waitForObserverToStop(dataDirectory, timeoutMs) {
1517
+ const deadline = Date.now() + timeoutMs;
1518
+ while (Date.now() < deadline && (await InstanceLock.inspect(dataDirectory)).running) {
1519
+ await delay(250);
1520
+ }
1521
+ }
1522
+ function purgePrompt(machineId, dataDirectory) {
1523
+ return [
1524
+ "Remove this computer from Phigon and delete its local data?",
1525
+ ...(machineId === undefined ? ["This computer is not paired."] : [
1526
+ `Machine: ${machineId}`,
1527
+ "Collection stops at once and this computer's observer key is revoked.",
1528
+ "Activity already collected stays with your company until an administrator deletes this computer or it expires under the retention policy.",
1529
+ ]),
1530
+ "The background service is removed, and every file the observer keeps in",
1531
+ `${dataDirectory} is deleted, including activity not yet delivered.`,
1532
+ 'Type "remove" to continue: ',
1533
+ ].join("\n");
1534
+ }
1065
1535
  function removeMachinePrompt(machineId) {
1066
1536
  return [
1067
1537
  "Remove this machine from Phigon?",
1068
1538
  `Machine: ${machineId}`,
1069
- "This unregisters the machine and immediately revokes its observer credential.",
1070
- "Its stored telemetry and machine-scoped data will be durably purged; a minimal deletion tombstone plus consent and audit evidence remain according to retention policy.",
1539
+ "Collection stops at once and this computer's observer key is revoked.",
1540
+ "Activity already collected stays with your company until an administrator deletes this computer or it expires under the retention policy.",
1071
1541
  'Type "remove" to continue: ',
1072
1542
  ].join("\n");
1073
1543
  }
1074
1544
  function formatRemoveMachineResult(result) {
1075
1545
  return [
1076
- "Machine removed successfully.",
1546
+ result.status === "already_removed"
1547
+ ? "This machine was already removed from the workspace, or its key was revoked. The local copy is now cleaned up."
1548
+ : "Machine removed successfully.",
1077
1549
  `Machine: ${result.machineId}`,
1078
1550
  `Control plane: ${result.controlPlaneOrigin}`,
1551
+ "Collection: stopped; the observer key is revoked",
1079
1552
  "Machine credential: removed from this device",
1080
- "Stored machine telemetry: deletion scheduled",
1553
+ "Collected activity: kept until an administrator deletes this computer or it expires",
1081
1554
  "Next step: pair again to reconnect this observer.",
1082
1555
  "",
1083
1556
  ].join("\n");
@@ -1175,7 +1648,8 @@ function flagValue(flags, name) {
1175
1648
  for (let index = 0; index < flags.length; index++) {
1176
1649
  const flag = flags[index];
1177
1650
  if (flag === name) {
1178
- return flags[index + 1];
1651
+ const value = flags[index + 1];
1652
+ return value === undefined || value.startsWith("--") ? undefined : value;
1179
1653
  }
1180
1654
  if (flag?.startsWith(equalsPrefix)) {
1181
1655
  return flag.slice(equalsPrefix.length);
@@ -1204,8 +1678,8 @@ function observabilityEnvironment(command, flags, environment) {
1204
1678
  const profile = flagValue(flags, "--profile");
1205
1679
  if (profile === undefined)
1206
1680
  return environment;
1207
- if (command !== "doctor" && command !== "run") {
1208
- throw new Error("--profile is supported only by doctor and run.");
1681
+ if (command !== "doctor" && command !== "run" && !(command === "service" && flags[0] === "install")) {
1682
+ throw new Error("--profile is supported only by doctor, run and service install.");
1209
1683
  }
1210
1684
  if (profile !== "metadata" && profile !== "full") {
1211
1685
  throw new Error("--profile must be metadata or full.");
@@ -1215,6 +1689,15 @@ function observabilityEnvironment(command, flags, environment) {
1215
1689
  PHIGON_OBSERVABILITY_PROFILE: profile,
1216
1690
  };
1217
1691
  }
1692
+ function parseMaxDeliveryAge(value) {
1693
+ if (value === undefined)
1694
+ return defaultMaxDeliveryAgeMinutes;
1695
+ const minutes = Number(value);
1696
+ if (!/^\d{1,5}$/u.test(value) || minutes < 1 || minutes > 10_080) {
1697
+ throw new Error("--max-delivery-age must be a number of minutes from 1 to 10080.");
1698
+ }
1699
+ return minutes;
1700
+ }
1218
1701
  function parseConsentProfile(value) {
1219
1702
  if (value === "metadata" || value === "full")
1220
1703
  return value;
@@ -1257,6 +1740,7 @@ function formatHealth(health) {
1257
1740
  : [
1258
1741
  `Stream cursor: ${health.streamCursor.acknowledgedSequence} for ${health.streamCursor.machineId}`,
1259
1742
  ]),
1743
+ ...(health.consent === undefined ? [] : [`Consent: ${describeConsent(health.consent)}`]),
1260
1744
  `Content collection: ${health.safeCollectionDefaults ? "metadata only" : "explicit categories enabled"}`,
1261
1745
  "",
1262
1746
  ].join("\n");