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