@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.
- package/README.md +194 -11
- package/dist/bin/guardian-runner-darwin-amd64 +0 -0
- package/dist/bin/guardian-runner-darwin-arm64 +0 -0
- package/dist/bin/guardian-runner-linux-amd64 +0 -0
- package/dist/bin/guardian-runner-linux-arm64 +0 -0
- package/dist/bin/guardian-runner-windows-amd64.exe +0 -0
- package/dist/bin/guardian-runner-windows-arm64.exe +0 -0
- package/dist/src/abortable-delay.d.ts +10 -0
- package/dist/src/abortable-delay.d.ts.map +1 -0
- package/dist/src/abortable-delay.js +21 -0
- package/dist/src/abortable-delay.js.map +1 -0
- package/dist/src/activity/adapters/claude.d.ts.map +1 -1
- package/dist/src/activity/adapters/claude.js +9 -1
- package/dist/src/activity/adapters/claude.js.map +1 -1
- package/dist/src/activity/adapters/openai-compatible.d.ts.map +1 -1
- package/dist/src/activity/adapters/openai-compatible.js +9 -1
- package/dist/src/activity/adapters/openai-compatible.js.map +1 -1
- package/dist/src/activity/collector.d.ts +31 -1
- package/dist/src/activity/collector.d.ts.map +1 -1
- package/dist/src/activity/collector.js +337 -77
- package/dist/src/activity/collector.js.map +1 -1
- package/dist/src/activity/ingest.d.ts +9 -0
- package/dist/src/activity/ingest.d.ts.map +1 -1
- package/dist/src/activity/ingest.js +92 -3
- package/dist/src/activity/ingest.js.map +1 -1
- package/dist/src/activity/json-document-source.d.ts +15 -0
- package/dist/src/activity/json-document-source.d.ts.map +1 -1
- package/dist/src/activity/json-document-source.js +19 -2
- package/dist/src/activity/json-document-source.js.map +1 -1
- package/dist/src/activity/native-capabilities.d.ts.map +1 -1
- package/dist/src/activity/native-capabilities.js +8 -12
- package/dist/src/activity/native-capabilities.js.map +1 -1
- package/dist/src/activity/opencode-sqlite-source.js +1 -0
- package/dist/src/activity/opencode-sqlite-source.js.map +1 -1
- package/dist/src/activity/source-reader.d.ts +44 -3
- package/dist/src/activity/source-reader.d.ts.map +1 -1
- package/dist/src/activity/source-reader.js +325 -86
- package/dist/src/activity/source-reader.js.map +1 -1
- package/dist/src/agent-action-executor.d.ts +5 -0
- package/dist/src/agent-action-executor.d.ts.map +1 -1
- package/dist/src/agent-action-executor.js +9 -6
- package/dist/src/agent-action-executor.js.map +1 -1
- package/dist/src/atomic-replace.d.ts +7 -0
- package/dist/src/atomic-replace.d.ts.map +1 -1
- package/dist/src/atomic-replace.js +28 -3
- package/dist/src/atomic-replace.js.map +1 -1
- package/dist/src/bridge-journal.d.ts +32 -0
- package/dist/src/bridge-journal.d.ts.map +1 -0
- package/dist/src/bridge-journal.js +113 -0
- package/dist/src/bridge-journal.js.map +1 -0
- package/dist/src/cli.d.ts +7 -1
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +490 -43
- package/dist/src/cli.js.map +1 -1
- package/dist/src/collection-notice.d.ts +34 -0
- package/dist/src/collection-notice.d.ts.map +1 -0
- package/dist/src/collection-notice.js +73 -0
- package/dist/src/collection-notice.js.map +1 -0
- package/dist/src/config.d.ts +26 -0
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +88 -33
- package/dist/src/config.js.map +1 -1
- package/dist/src/content-redaction.d.ts +17 -1
- package/dist/src/content-redaction.d.ts.map +1 -1
- package/dist/src/content-redaction.js +44 -3
- package/dist/src/content-redaction.js.map +1 -1
- package/dist/src/credential-store.d.ts +20 -1
- package/dist/src/credential-store.d.ts.map +1 -1
- package/dist/src/credential-store.js +27 -3
- package/dist/src/credential-store.js.map +1 -1
- package/dist/src/discovery/engine.d.ts.map +1 -1
- package/dist/src/discovery/engine.js +1 -11
- package/dist/src/discovery/engine.js.map +1 -1
- package/dist/src/doctor-check.d.ts +51 -0
- package/dist/src/doctor-check.d.ts.map +1 -0
- package/dist/src/doctor-check.js +174 -0
- package/dist/src/doctor-check.js.map +1 -0
- package/dist/src/enroll.d.ts +86 -0
- package/dist/src/enroll.d.ts.map +1 -0
- package/dist/src/enroll.js +245 -0
- package/dist/src/enroll.js.map +1 -0
- package/dist/src/entry.d.ts +3 -0
- package/dist/src/entry.d.ts.map +1 -0
- package/dist/src/entry.js +14 -0
- package/dist/src/entry.js.map +1 -0
- package/dist/src/event-queue.d.ts +13 -1
- package/dist/src/event-queue.d.ts.map +1 -1
- package/dist/src/event-queue.js +50 -5
- package/dist/src/event-queue.js.map +1 -1
- package/dist/src/event-spool.d.ts +61 -3
- package/dist/src/event-spool.d.ts.map +1 -1
- package/dist/src/event-spool.js +290 -41
- package/dist/src/event-spool.js.map +1 -1
- package/dist/src/generated/phigon/v1/identity_pb.d.ts +7 -0
- package/dist/src/generated/phigon/v1/identity_pb.d.ts.map +1 -1
- package/dist/src/generated/phigon/v1/identity_pb.js +1 -1
- package/dist/src/generated/phigon/v1/identity_pb.js.map +1 -1
- package/dist/src/guardian-decision-journal.d.ts.map +1 -1
- package/dist/src/guardian-decision-journal.js +4 -20
- package/dist/src/guardian-decision-journal.js.map +1 -1
- package/dist/src/guardian-managed-exec.js +6 -19
- package/dist/src/guardian-managed-exec.js.map +1 -1
- package/dist/src/guardian-process-monitor.d.ts +1 -0
- package/dist/src/guardian-process-monitor.d.ts.map +1 -1
- package/dist/src/guardian-process-monitor.js +74 -26
- package/dist/src/guardian-process-monitor.js.map +1 -1
- package/dist/src/health.d.ts +19 -1
- package/dist/src/health.d.ts.map +1 -1
- package/dist/src/health.js +22 -1
- package/dist/src/health.js.map +1 -1
- package/dist/src/historical-identity-store.d.ts +8 -1
- package/dist/src/historical-identity-store.d.ts.map +1 -1
- package/dist/src/historical-identity-store.js +26 -9
- package/dist/src/historical-identity-store.js.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/installation.d.ts +2 -0
- package/dist/src/installation.d.ts.map +1 -1
- package/dist/src/installation.js +3 -1
- package/dist/src/installation.js.map +1 -1
- package/dist/src/instance-lock.d.ts +45 -0
- package/dist/src/instance-lock.d.ts.map +1 -0
- package/dist/src/instance-lock.js +243 -0
- package/dist/src/instance-lock.js.map +1 -0
- package/dist/src/machine-command-executor.d.ts +2 -2
- package/dist/src/machine-command-executor.d.ts.map +1 -1
- package/dist/src/machine-command-executor.js +7 -20
- package/dist/src/machine-command-executor.js.map +1 -1
- package/dist/src/node-version.d.ts +8 -0
- package/dist/src/node-version.d.ts.map +1 -0
- package/dist/src/node-version.js +17 -0
- package/dist/src/node-version.js.map +1 -0
- package/dist/src/observer-log.d.ts +50 -0
- package/dist/src/observer-log.d.ts.map +1 -0
- package/dist/src/observer-log.js +141 -0
- package/dist/src/observer-log.js.map +1 -0
- package/dist/src/pair.d.ts +68 -1
- package/dist/src/pair.d.ts.map +1 -1
- package/dist/src/pair.js +204 -66
- package/dist/src/pair.js.map +1 -1
- package/dist/src/private-directory.d.ts +6 -0
- package/dist/src/private-directory.d.ts.map +1 -1
- package/dist/src/private-directory.js +33 -2
- package/dist/src/private-directory.js.map +1 -1
- package/dist/src/proxy.d.ts +68 -0
- package/dist/src/proxy.d.ts.map +1 -0
- package/dist/src/proxy.js +209 -0
- package/dist/src/proxy.js.map +1 -0
- package/dist/src/purge.d.ts +17 -0
- package/dist/src/purge.d.ts.map +1 -0
- package/dist/src/purge.js +84 -0
- package/dist/src/purge.js.map +1 -0
- package/dist/src/remove-machine.d.ts +5 -1
- package/dist/src/remove-machine.d.ts.map +1 -1
- package/dist/src/remove-machine.js +28 -14
- package/dist/src/remove-machine.js.map +1 -1
- package/dist/src/server-authority.d.ts +5 -2
- package/dist/src/server-authority.d.ts.map +1 -1
- package/dist/src/server-authority.js +10 -6
- package/dist/src/server-authority.js.map +1 -1
- package/dist/src/server-clock.d.ts +32 -0
- package/dist/src/server-clock.d.ts.map +1 -0
- package/dist/src/server-clock.js +72 -0
- package/dist/src/server-clock.js.map +1 -0
- package/dist/src/service-command.d.ts +20 -0
- package/dist/src/service-command.d.ts.map +1 -0
- package/dist/src/service-command.js +136 -0
- package/dist/src/service-command.js.map +1 -0
- package/dist/src/service.d.ts +98 -0
- package/dist/src/service.d.ts.map +1 -0
- package/dist/src/service.js +472 -0
- package/dist/src/service.js.map +1 -0
- package/dist/src/stream-client.d.ts +18 -0
- package/dist/src/stream-client.d.ts.map +1 -1
- package/dist/src/stream-client.js +91 -16
- package/dist/src/stream-client.js.map +1 -1
- package/dist/src/stream-state.d.ts +2 -0
- package/dist/src/stream-state.d.ts.map +1 -1
- package/dist/src/stream-state.js +3 -1
- package/dist/src/stream-state.js.map +1 -1
- package/dist/src/user-notices.d.ts +11 -0
- package/dist/src/user-notices.d.ts.map +1 -1
- package/dist/src/user-notices.js +24 -0
- package/dist/src/user-notices.js.map +1 -1
- 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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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 -
|
|
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
|
|
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
|
|
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
|
|
570
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
1106
|
-
"
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
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");
|