@phigon/observer 8.2.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 (187) hide show
  1. package/README.md +194 -11
  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/activity/adapters/claude.d.ts.map +1 -1
  13. package/dist/src/activity/adapters/claude.js +9 -1
  14. package/dist/src/activity/adapters/claude.js.map +1 -1
  15. package/dist/src/activity/adapters/openai-compatible.d.ts.map +1 -1
  16. package/dist/src/activity/adapters/openai-compatible.js +9 -1
  17. package/dist/src/activity/adapters/openai-compatible.js.map +1 -1
  18. package/dist/src/activity/collector.d.ts +31 -1
  19. package/dist/src/activity/collector.d.ts.map +1 -1
  20. package/dist/src/activity/collector.js +337 -77
  21. package/dist/src/activity/collector.js.map +1 -1
  22. package/dist/src/activity/ingest.d.ts +9 -0
  23. package/dist/src/activity/ingest.d.ts.map +1 -1
  24. package/dist/src/activity/ingest.js +92 -3
  25. package/dist/src/activity/ingest.js.map +1 -1
  26. package/dist/src/activity/json-document-source.d.ts +15 -0
  27. package/dist/src/activity/json-document-source.d.ts.map +1 -1
  28. package/dist/src/activity/json-document-source.js +19 -2
  29. package/dist/src/activity/json-document-source.js.map +1 -1
  30. package/dist/src/activity/native-capabilities.d.ts.map +1 -1
  31. package/dist/src/activity/native-capabilities.js +8 -12
  32. package/dist/src/activity/native-capabilities.js.map +1 -1
  33. package/dist/src/activity/opencode-sqlite-source.js +1 -0
  34. package/dist/src/activity/opencode-sqlite-source.js.map +1 -1
  35. package/dist/src/activity/source-reader.d.ts +44 -3
  36. package/dist/src/activity/source-reader.d.ts.map +1 -1
  37. package/dist/src/activity/source-reader.js +325 -86
  38. package/dist/src/activity/source-reader.js.map +1 -1
  39. package/dist/src/agent-action-executor.d.ts +5 -0
  40. package/dist/src/agent-action-executor.d.ts.map +1 -1
  41. package/dist/src/agent-action-executor.js +9 -6
  42. package/dist/src/agent-action-executor.js.map +1 -1
  43. package/dist/src/atomic-replace.d.ts +7 -0
  44. package/dist/src/atomic-replace.d.ts.map +1 -1
  45. package/dist/src/atomic-replace.js +28 -3
  46. package/dist/src/atomic-replace.js.map +1 -1
  47. package/dist/src/bridge-journal.d.ts +32 -0
  48. package/dist/src/bridge-journal.d.ts.map +1 -0
  49. package/dist/src/bridge-journal.js +113 -0
  50. package/dist/src/bridge-journal.js.map +1 -0
  51. package/dist/src/cli.d.ts +7 -1
  52. package/dist/src/cli.d.ts.map +1 -1
  53. package/dist/src/cli.js +490 -43
  54. package/dist/src/cli.js.map +1 -1
  55. package/dist/src/collection-notice.d.ts +34 -0
  56. package/dist/src/collection-notice.d.ts.map +1 -0
  57. package/dist/src/collection-notice.js +73 -0
  58. package/dist/src/collection-notice.js.map +1 -0
  59. package/dist/src/config.d.ts +26 -0
  60. package/dist/src/config.d.ts.map +1 -1
  61. package/dist/src/config.js +88 -33
  62. package/dist/src/config.js.map +1 -1
  63. package/dist/src/content-redaction.d.ts +17 -1
  64. package/dist/src/content-redaction.d.ts.map +1 -1
  65. package/dist/src/content-redaction.js +44 -3
  66. package/dist/src/content-redaction.js.map +1 -1
  67. package/dist/src/credential-store.d.ts +20 -1
  68. package/dist/src/credential-store.d.ts.map +1 -1
  69. package/dist/src/credential-store.js +27 -3
  70. package/dist/src/credential-store.js.map +1 -1
  71. package/dist/src/discovery/engine.d.ts.map +1 -1
  72. package/dist/src/discovery/engine.js +1 -11
  73. package/dist/src/discovery/engine.js.map +1 -1
  74. package/dist/src/doctor-check.d.ts +51 -0
  75. package/dist/src/doctor-check.d.ts.map +1 -0
  76. package/dist/src/doctor-check.js +174 -0
  77. package/dist/src/doctor-check.js.map +1 -0
  78. package/dist/src/enroll.d.ts +86 -0
  79. package/dist/src/enroll.d.ts.map +1 -0
  80. package/dist/src/enroll.js +245 -0
  81. package/dist/src/enroll.js.map +1 -0
  82. package/dist/src/entry.d.ts +3 -0
  83. package/dist/src/entry.d.ts.map +1 -0
  84. package/dist/src/entry.js +14 -0
  85. package/dist/src/entry.js.map +1 -0
  86. package/dist/src/event-queue.d.ts +13 -1
  87. package/dist/src/event-queue.d.ts.map +1 -1
  88. package/dist/src/event-queue.js +50 -5
  89. package/dist/src/event-queue.js.map +1 -1
  90. package/dist/src/event-spool.d.ts +61 -3
  91. package/dist/src/event-spool.d.ts.map +1 -1
  92. package/dist/src/event-spool.js +290 -41
  93. package/dist/src/event-spool.js.map +1 -1
  94. package/dist/src/generated/phigon/v1/identity_pb.d.ts +7 -0
  95. package/dist/src/generated/phigon/v1/identity_pb.d.ts.map +1 -1
  96. package/dist/src/generated/phigon/v1/identity_pb.js +1 -1
  97. package/dist/src/generated/phigon/v1/identity_pb.js.map +1 -1
  98. package/dist/src/guardian-decision-journal.d.ts.map +1 -1
  99. package/dist/src/guardian-decision-journal.js +4 -20
  100. package/dist/src/guardian-decision-journal.js.map +1 -1
  101. package/dist/src/guardian-managed-exec.js +6 -19
  102. package/dist/src/guardian-managed-exec.js.map +1 -1
  103. package/dist/src/guardian-process-monitor.d.ts +1 -0
  104. package/dist/src/guardian-process-monitor.d.ts.map +1 -1
  105. package/dist/src/guardian-process-monitor.js +74 -26
  106. package/dist/src/guardian-process-monitor.js.map +1 -1
  107. package/dist/src/health.d.ts +19 -1
  108. package/dist/src/health.d.ts.map +1 -1
  109. package/dist/src/health.js +22 -1
  110. package/dist/src/health.js.map +1 -1
  111. package/dist/src/historical-identity-store.d.ts +8 -1
  112. package/dist/src/historical-identity-store.d.ts.map +1 -1
  113. package/dist/src/historical-identity-store.js +26 -9
  114. package/dist/src/historical-identity-store.js.map +1 -1
  115. package/dist/src/index.d.ts +2 -0
  116. package/dist/src/index.d.ts.map +1 -1
  117. package/dist/src/index.js +2 -0
  118. package/dist/src/index.js.map +1 -1
  119. package/dist/src/installation.d.ts +2 -0
  120. package/dist/src/installation.d.ts.map +1 -1
  121. package/dist/src/installation.js +3 -1
  122. package/dist/src/installation.js.map +1 -1
  123. package/dist/src/instance-lock.d.ts +45 -0
  124. package/dist/src/instance-lock.d.ts.map +1 -0
  125. package/dist/src/instance-lock.js +243 -0
  126. package/dist/src/instance-lock.js.map +1 -0
  127. package/dist/src/machine-command-executor.d.ts +2 -2
  128. package/dist/src/machine-command-executor.d.ts.map +1 -1
  129. package/dist/src/machine-command-executor.js +7 -20
  130. package/dist/src/machine-command-executor.js.map +1 -1
  131. package/dist/src/node-version.d.ts +8 -0
  132. package/dist/src/node-version.d.ts.map +1 -0
  133. package/dist/src/node-version.js +17 -0
  134. package/dist/src/node-version.js.map +1 -0
  135. package/dist/src/observer-log.d.ts +50 -0
  136. package/dist/src/observer-log.d.ts.map +1 -0
  137. package/dist/src/observer-log.js +141 -0
  138. package/dist/src/observer-log.js.map +1 -0
  139. package/dist/src/pair.d.ts +68 -1
  140. package/dist/src/pair.d.ts.map +1 -1
  141. package/dist/src/pair.js +204 -66
  142. package/dist/src/pair.js.map +1 -1
  143. package/dist/src/private-directory.d.ts +6 -0
  144. package/dist/src/private-directory.d.ts.map +1 -1
  145. package/dist/src/private-directory.js +33 -2
  146. package/dist/src/private-directory.js.map +1 -1
  147. package/dist/src/proxy.d.ts +68 -0
  148. package/dist/src/proxy.d.ts.map +1 -0
  149. package/dist/src/proxy.js +209 -0
  150. package/dist/src/proxy.js.map +1 -0
  151. package/dist/src/purge.d.ts +17 -0
  152. package/dist/src/purge.d.ts.map +1 -0
  153. package/dist/src/purge.js +84 -0
  154. package/dist/src/purge.js.map +1 -0
  155. package/dist/src/remove-machine.d.ts +5 -1
  156. package/dist/src/remove-machine.d.ts.map +1 -1
  157. package/dist/src/remove-machine.js +28 -14
  158. package/dist/src/remove-machine.js.map +1 -1
  159. package/dist/src/server-authority.d.ts +5 -2
  160. package/dist/src/server-authority.d.ts.map +1 -1
  161. package/dist/src/server-authority.js +10 -6
  162. package/dist/src/server-authority.js.map +1 -1
  163. package/dist/src/server-clock.d.ts +32 -0
  164. package/dist/src/server-clock.d.ts.map +1 -0
  165. package/dist/src/server-clock.js +72 -0
  166. package/dist/src/server-clock.js.map +1 -0
  167. package/dist/src/service-command.d.ts +20 -0
  168. package/dist/src/service-command.d.ts.map +1 -0
  169. package/dist/src/service-command.js +136 -0
  170. package/dist/src/service-command.js.map +1 -0
  171. package/dist/src/service.d.ts +98 -0
  172. package/dist/src/service.d.ts.map +1 -0
  173. package/dist/src/service.js +472 -0
  174. package/dist/src/service.js.map +1 -0
  175. package/dist/src/stream-client.d.ts +18 -0
  176. package/dist/src/stream-client.d.ts.map +1 -1
  177. package/dist/src/stream-client.js +91 -16
  178. package/dist/src/stream-client.js.map +1 -1
  179. package/dist/src/stream-state.d.ts +2 -0
  180. package/dist/src/stream-state.d.ts.map +1 -1
  181. package/dist/src/stream-state.js +3 -1
  182. package/dist/src/stream-state.js.map +1 -1
  183. package/dist/src/user-notices.d.ts +11 -0
  184. package/dist/src/user-notices.d.ts.map +1 -1
  185. package/dist/src/user-notices.js +24 -0
  186. package/dist/src/user-notices.js.map +1 -1
  187. package/package.json +7 -7
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,10 +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";
24
- import { forgetServerAuthority, parseAuthorityFingerprint, pinServerAuthority } from "./server-authority.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";
25
32
  import { clearEventSpool } from "./event-spool.js";
26
33
  import { GuardianDecisionJournal } from "./guardian-decision-journal.js";
27
34
  import { GuardianContainmentExecutor, } from "./guardian-containment.js";
@@ -35,16 +42,29 @@ import { semanticDecision } from "./agentic-guardian.js";
35
42
  import { guardianRunnerPath } from "./guardian-native.js";
36
43
  import { GuardianProcessMonitor } from "./guardian-process-monitor.js";
37
44
  import { setTimeout as delay } from "node:timers/promises";
45
+ import { ServerClock, describeClockSkew } from "./server-clock.js";
38
46
  import { StreamStateStore } from "./stream-state.js";
39
47
  import { connectionEnvironment } from "./connection-config.js";
40
48
  import { latestObserverVersion, newerVersion, offerObserverUpdate } from "./update.js";
41
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";
42
54
  const defaultOutput = {
43
55
  stdout: (value) => process.stdout.write(value),
44
56
  stderr: (value) => process.stderr.write(value),
45
57
  };
46
- 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 = {}) {
47
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
+ }
48
68
  if (command === "--version" || command === "-v" || command === "version") {
49
69
  output.stdout(`${await readVersion()}\n`);
50
70
  return 0;
@@ -63,17 +83,51 @@ export async function run(argv, environment = process.env, output = defaultOutpu
63
83
  command !== "pair" &&
64
84
  command !== "remove-machine" &&
65
85
  command !== "unpair" &&
86
+ command !== "service" &&
66
87
  command !== "run") {
67
88
  output.stderr(`Unknown command: ${command}\n\n${helpText()}`);
68
89
  return 2;
69
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;
70
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
+ }
71
114
  environment = command === "update" ? { PHIGON_DATA_DIR: environment["PHIGON_DATA_DIR"] } : await connectionEnvironment(environment, command === "pair" || command === "run" || command === "doctor" ? flags : []);
72
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`);
73
121
  const version = await readVersion();
74
122
  const json = flags.includes("--json");
75
- const interactive = input === process.stdin && process.stdin.isTTY === true && process.stdout.isTTY === true && !json;
76
- 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")) {
77
131
  const latest = await latestObserverVersion(config.dataDirectory, { force: command === "update" });
78
132
  if (command === "update" && (json || flags.includes("--check"))) {
79
133
  output.stdout(`${JSON.stringify({ current: version, latest, updateAvailable: latest !== null && newerVersion(version, latest), status: latest === null ? "unavailable" : "checked" })}\n`);
@@ -90,6 +144,9 @@ export async function run(argv, environment = process.env, output = defaultOutpu
90
144
  }
91
145
  }
92
146
  const customAgentRegistry = new CustomAgentRegistry(config.dataDirectory);
147
+ if (command === "service") {
148
+ return await runServiceCommand(flags, config, callerEnvironment, output, json, dependencies);
149
+ }
93
150
  if (command === "guardian") {
94
151
  const subcommand = flags[0] ?? "status";
95
152
  if (subcommand === "exec") {
@@ -347,6 +404,9 @@ export async function run(argv, environment = process.env, output = defaultOutpu
347
404
  return result.records > 0 ? 0 : 1;
348
405
  }
349
406
  if (command === "remove-machine" || command === "unpair") {
407
+ if (flags.includes("--purge")) {
408
+ return await removeMachineAndPurge(config, callerEnvironment, flags, json, output, input, dependencies);
409
+ }
350
410
  const credentialStore = new FileCredentialStore(config.dataDirectory);
351
411
  const credential = await credentialStore.load();
352
412
  if (credential === null) {
@@ -355,22 +415,42 @@ export async function run(argv, environment = process.env, output = defaultOutpu
355
415
  if (json && !flags.includes("--yes")) {
356
416
  throw new Error("JSON machine removal requires --yes after reviewing the removal scope.");
357
417
  }
358
- if (!flags.includes("--yes")) {
359
- output.stdout(removeMachinePrompt(credential.machineId));
360
- const confirmed = await readConfirmation(input);
361
- if (confirmed !== "remove") {
362
- 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
+ }
363
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();
364
453
  }
365
- const result = await removeMachine({
366
- config,
367
- credential,
368
- clearCredential: async () => {
369
- clearEventSpool(config.dataDirectory);
370
- await forgetServerAuthority(config.dataDirectory);
371
- return await credentialStore.clear();
372
- },
373
- });
374
454
  output.stdout(json
375
455
  ? `${JSON.stringify(result, null, 2)}\n`
376
456
  : formatRemoveMachineResult(result));
@@ -391,6 +471,22 @@ export async function run(argv, environment = process.env, output = defaultOutpu
391
471
  const stop = () => controller.abort();
392
472
  process.once("SIGINT", stop);
393
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;
394
490
  try {
395
491
  const credential = await new FileCredentialStore(config.dataDirectory).load();
396
492
  if (credential === null) {
@@ -409,7 +505,15 @@ export async function run(argv, environment = process.env, output = defaultOutpu
409
505
  credential.consentProfile !== "full") {
410
506
  throw new Error("Full-content collection is outside the consented metadata profile.");
411
507
  }
412
- 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);
413
517
  let collector;
414
518
  let resolveCollector;
415
519
  const collectorReady = new Promise((resolve) => {
@@ -420,6 +524,9 @@ export async function run(argv, environment = process.env, output = defaultOutpu
420
524
  customAgentRegistry,
421
525
  });
422
526
  const userNotices = new UserNotices({ enabled: config.userNotices });
527
+ const serverClock = new ServerClock({
528
+ onLargeSkew: (offsetMs) => output.stdout(`${describeClockSkew(offsetMs)}\n`),
529
+ });
423
530
  const actionExecutor = new AgentActionExecutor({
424
531
  notices: userNotices,
425
532
  enabled: config.remoteActionsEnabled,
@@ -427,10 +534,13 @@ export async function run(argv, environment = process.env, output = defaultOutpu
427
534
  machineID: credential.machineId,
428
535
  dataDirectory: config.dataDirectory,
429
536
  inventory: async () => (await discoveryEngine.discover(credential.machineId)).inventory,
537
+ serverNow: () => serverClock.now(),
430
538
  });
431
539
  const client = new ObserverStreamClient({
432
540
  config,
433
541
  actionExecutor,
542
+ serverClock,
543
+ observerVersion: version,
434
544
  onHistorySync: async (syncCommand) => {
435
545
  const rejected = (detail) => create(HistorySyncResultSchema, {
436
546
  jobId: syncCommand.jobId,
@@ -452,7 +562,7 @@ export async function run(argv, environment = process.env, output = defaultOutpu
452
562
  return rejected("Workspace collection policy does not allow readable prompts, replies, and tool activity.");
453
563
  }
454
564
  const expiresAt = timestampDate(syncCommand.expiresAt).getTime();
455
- const remainingMs = expiresAt - Date.now();
565
+ const remainingMs = expiresAt - serverClock.now();
456
566
  if (!Number.isFinite(remainingMs) || remainingMs <= 0) {
457
567
  return rejected("History sync command expired before execution.");
458
568
  }
@@ -475,7 +585,7 @@ export async function run(argv, environment = process.env, output = defaultOutpu
475
585
  },
476
586
  onStatus: (message) => output.stdout(`${message}\n`),
477
587
  onPolicy: (policy) => {
478
- effectivePolicy = intersectCollectionPolicy(config.policy, policy);
588
+ effectivePolicy = intersectCollectionPolicy(config.policy, credential.consentProfile, policy);
479
589
  collector?.updatePolicy(effectivePolicy);
480
590
  },
481
591
  onCursorReset: (acknowledgedSequence, allocationSequence) => {
@@ -498,6 +608,10 @@ export async function run(argv, environment = process.env, output = defaultOutpu
498
608
  const historicalStore = new HistoricalIdentityStore(config.dataDirectory, {
499
609
  tenantId: credential.tenantId,
500
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`),
501
615
  });
502
616
  let historicalIdentities = await historicalStore.load();
503
617
  const initialDiscovery = await discoveryEngine.discover(credential.machineId);
@@ -537,8 +651,20 @@ export async function run(argv, environment = process.env, output = defaultOutpu
537
651
  notices: userNotices,
538
652
  onStatus: (message) => output.stdout(`${message}\n`),
539
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
+ };
540
665
  const monitoring = (async () => {
541
- await client.waitUntilReady(controller.signal);
666
+ if (await streamReady() === undefined)
667
+ return;
542
668
  while (!controller.signal.aborted) {
543
669
  try {
544
670
  await processMonitor.review(latestDiscoveryInventory, controller.signal);
@@ -555,24 +681,47 @@ export async function run(argv, environment = process.env, output = defaultOutpu
555
681
  }
556
682
  })();
557
683
  const collecting = (async () => {
558
- 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"));
559
698
  collector = new ActivityCollector({
560
699
  config,
561
700
  credential,
562
701
  initialSequence: allocationSequence,
563
702
  initialAcknowledgedSequence: client.metrics().lastAcknowledgedSequence,
564
703
  durableDelivery: true,
704
+ flushDelivery: () => client.flush(),
565
705
  policy: effectivePolicy,
566
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.
567
709
  canCollect: () => {
568
710
  const queue = client.metrics().queue;
569
- return (queue.capacity - queue.depth >=
570
- Math.max(32, config.activityRateLimitPerMinute));
711
+ return (hasByteHeadroom(queue) &&
712
+ queue.capacity - queue.depth >=
713
+ Math.max(32, config.activityRateLimitPerMinute));
571
714
  },
572
715
  canSyncHistory: () => {
573
716
  const queue = client.metrics().queue;
574
- return queue.capacity - queue.depth >= Math.min(queue.capacity, historySyncQueueHeadroom);
717
+ return hasByteHeadroom(queue) &&
718
+ queue.capacity - queue.depth >= Math.min(queue.capacity, historySyncQueueHeadroom);
575
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"),
576
725
  additionalSources: async () => registeredAgentActivitySources(await customAgentRegistry.list()),
577
726
  onSourceIdentity: async (identity) => {
578
727
  // Make the source target addressable immediately, then durably
@@ -635,15 +784,36 @@ export async function run(argv, environment = process.env, output = defaultOutpu
635
784
  }
636
785
  }
637
786
  })();
638
- 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
+ }
639
799
  output.stdout("Observer stopped.\n");
640
800
  }
641
801
  finally {
642
802
  process.removeListener("SIGINT", stop);
643
803
  process.removeListener("SIGTERM", stop);
804
+ stopWatchingParent?.();
805
+ process.stdout.removeListener("error", ignoreConsoleError);
806
+ process.stderr.removeListener("error", ignoreConsoleError);
807
+ await instanceLock?.release();
644
808
  }
645
809
  return 0;
646
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
+ }
647
817
  if (command === "pair") {
648
818
  if (config.controlPlaneUrl.origin !== DEFAULT_CONTROL_PLANE_ORIGIN && !config.grpcEndpointConfigured) {
649
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.");
@@ -667,7 +837,9 @@ export async function run(argv, environment = process.env, output = defaultOutpu
667
837
  }
668
838
  }
669
839
  if (authority !== undefined) {
670
- await pinServerAuthority(config.controlPlaneUrl, authority, config.dataDirectory);
840
+ await pinServerAuthority(config.controlPlaneUrl, authority, config.dataDirectory, undefined, {
841
+ proxy: proxyForUrl(config.controlPlaneUrl, proxySettings(callerEnvironment)),
842
+ });
671
843
  }
672
844
  let result;
673
845
  try {
@@ -690,6 +862,15 @@ export async function run(argv, environment = process.env, output = defaultOutpu
690
862
  : formatPairResult(result));
691
863
  return 0;
692
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
+ }
693
874
  const storedCredential = await new FileCredentialStore(config.dataDirectory).load();
694
875
  let streamCursor;
695
876
  let streamCursorIssue;
@@ -709,15 +890,107 @@ export async function run(argv, environment = process.env, output = defaultOutpu
709
890
  const health = createHealthSnapshot({
710
891
  ...config,
711
892
  machineTokenConfigured: config.machineTokenConfigured || storedCredential !== null,
712
- }, version, new Date(), streamCursor, streamCursorIssue);
893
+ }, version, new Date(), streamCursor, streamCursorIssue, storedCredential === null ? undefined : consentHealth(storedCredential));
713
894
  output.stdout(json ? `${JSON.stringify(health, null, 2)}\n` : formatHealth(health));
714
895
  return 0;
715
896
  }
716
897
  catch (error) {
717
898
  const message = error instanceof Error ? error.message : "Unknown error.";
718
- 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
+ }
719
910
  return 1;
720
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");
721
994
  }
722
995
  function guardianAgentMessage(action, allowed, approvalRequired, reason, adminContract, approvalState) {
723
996
  const operation = action.replace(".", " ");
@@ -946,6 +1219,37 @@ async function runGuardedCommand(executable, arguments_, flags, model, environme
946
1219
  await bridge?.close();
947
1220
  }
948
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
+ }
949
1253
  export async function settleObserverTasks(tasks, controller, fatalError) {
950
1254
  try {
951
1255
  await Promise.all(tasks);
@@ -958,7 +1262,8 @@ export async function settleObserverTasks(tasks, controller, fatalError) {
958
1262
  await Promise.allSettled(tasks);
959
1263
  }
960
1264
  }
961
- function intersectCollectionPolicy(local, server) {
1265
+ function intersectCollectionPolicy(configured, consentProfile, server) {
1266
+ const local = consentedCollectionPolicy(configured, consentProfile);
962
1267
  return Object.freeze({
963
1268
  promptContent: local.promptContent &&
964
1269
  server?.promptContent === true,
@@ -1025,16 +1330,22 @@ function helpText() {
1025
1330
  " phigon-observer agent list [--json]",
1026
1331
  " phigon-observer agent remove --id <id> [--yes] [--json]",
1027
1332
  " phigon-observer doctor [--profile <metadata|full>] [--json]",
1333
+ " phigon-observer doctor --check [--max-delivery-age <minutes>] [--json]",
1028
1334
  " phigon-observer update [--check] [--yes] [--json]",
1029
1335
  " phigon-observer --version",
1030
1336
  " phigon-observer discover [--json]",
1031
1337
  " phigon-observer ingest --agent <agent-id|custom> [--name <agent-name>] [--quiet] [--json]",
1032
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]",
1033
- " phigon-observer remove-machine [--yes] [--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]",
1034
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]",
1035
1346
  " phigon-observer help",
1036
1347
  "",
1037
- "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.",
1038
1349
  "The agent commands manage dynamic local monitoring definitions for custom agents without changing observer code.",
1039
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.",
1040
1351
  "The optional Guardian context file supplies bounded task_id, user_prompt, agent_plan, justification, and recent_activity fields for semantic review.",
@@ -1044,8 +1355,10 @@ function helpText() {
1044
1355
  "The discover command reports a sanitized one-time local inventory.",
1045
1356
  "The ingest command accepts NDJSON on stdin for every supported bridge; use --agent custom --name <agent-name> for any other agent.",
1046
1357
  "The pair command shows the selected collection scope, requires machine-owner acceptance, and stores that evidence with the mode-0600 credential.",
1047
- "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.",
1048
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.",
1049
1362
  "",
1050
1363
  ].join("\n");
1051
1364
  }
@@ -1095,25 +1408,149 @@ function formatPairResult(result) {
1095
1408
  `Machine: ${result.displayName} (${result.machineId})`,
1096
1409
  `Control plane: ${result.controlPlaneOrigin}`,
1097
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
+ ]),
1098
1417
  "",
1099
1418
  ].join("\n");
1100
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
+ }
1101
1535
  function removeMachinePrompt(machineId) {
1102
1536
  return [
1103
1537
  "Remove this machine from Phigon?",
1104
1538
  `Machine: ${machineId}`,
1105
- "This unregisters the machine and immediately revokes its observer credential.",
1106
- "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.",
1107
1541
  'Type "remove" to continue: ',
1108
1542
  ].join("\n");
1109
1543
  }
1110
1544
  function formatRemoveMachineResult(result) {
1111
1545
  return [
1112
- "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.",
1113
1549
  `Machine: ${result.machineId}`,
1114
1550
  `Control plane: ${result.controlPlaneOrigin}`,
1551
+ "Collection: stopped; the observer key is revoked",
1115
1552
  "Machine credential: removed from this device",
1116
- "Stored machine telemetry: deletion scheduled",
1553
+ "Collected activity: kept until an administrator deletes this computer or it expires",
1117
1554
  "Next step: pair again to reconnect this observer.",
1118
1555
  "",
1119
1556
  ].join("\n");
@@ -1241,8 +1678,8 @@ function observabilityEnvironment(command, flags, environment) {
1241
1678
  const profile = flagValue(flags, "--profile");
1242
1679
  if (profile === undefined)
1243
1680
  return environment;
1244
- if (command !== "doctor" && command !== "run") {
1245
- 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.");
1246
1683
  }
1247
1684
  if (profile !== "metadata" && profile !== "full") {
1248
1685
  throw new Error("--profile must be metadata or full.");
@@ -1252,6 +1689,15 @@ function observabilityEnvironment(command, flags, environment) {
1252
1689
  PHIGON_OBSERVABILITY_PROFILE: profile,
1253
1690
  };
1254
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
+ }
1255
1701
  function parseConsentProfile(value) {
1256
1702
  if (value === "metadata" || value === "full")
1257
1703
  return value;
@@ -1294,6 +1740,7 @@ function formatHealth(health) {
1294
1740
  : [
1295
1741
  `Stream cursor: ${health.streamCursor.acknowledgedSequence} for ${health.streamCursor.machineId}`,
1296
1742
  ]),
1743
+ ...(health.consent === undefined ? [] : [`Consent: ${describeConsent(health.consent)}`]),
1297
1744
  `Content collection: ${health.safeCollectionDefaults ? "metadata only" : "explicit categories enabled"}`,
1298
1745
  "",
1299
1746
  ].join("\n");