@mono-agent/agent-app 0.11.2 → 0.11.4
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/LICENSE +674 -0
- package/README.md +222 -26
- package/dist/adapter-send-tools-main.js +7 -1
- package/dist/adapter-send-tools-main.js.map +1 -1
- package/dist/adapter-send-tools.d.ts +19 -1
- package/dist/adapter-send-tools.d.ts.map +1 -1
- package/dist/adapter-send-tools.js +127 -13
- package/dist/adapter-send-tools.js.map +1 -1
- package/dist/app-config.d.ts.map +1 -1
- package/dist/app-config.js +15 -2
- package/dist/app-config.js.map +1 -1
- package/dist/app-controller-channels.d.ts +8 -0
- package/dist/app-controller-channels.d.ts.map +1 -0
- package/dist/app-controller-channels.js +207 -0
- package/dist/app-controller-channels.js.map +1 -0
- package/dist/app-controller-continuation.d.ts +26 -0
- package/dist/app-controller-continuation.d.ts.map +1 -0
- package/dist/app-controller-continuation.js +198 -0
- package/dist/app-controller-continuation.js.map +1 -0
- package/dist/app-controller-lifecycle.d.ts +6 -0
- package/dist/app-controller-lifecycle.d.ts.map +1 -0
- package/dist/app-controller-lifecycle.js +81 -0
- package/dist/app-controller-lifecycle.js.map +1 -0
- package/dist/app-controller-maintenance.d.ts +13 -0
- package/dist/app-controller-maintenance.d.ts.map +1 -0
- package/dist/app-controller-maintenance.js +180 -0
- package/dist/app-controller-maintenance.js.map +1 -0
- package/dist/app-controller-memory-health.d.ts +12 -0
- package/dist/app-controller-memory-health.d.ts.map +1 -0
- package/dist/app-controller-memory-health.js +160 -0
- package/dist/app-controller-memory-health.js.map +1 -0
- package/dist/app-controller-memory.d.ts +9 -0
- package/dist/app-controller-memory.d.ts.map +1 -0
- package/dist/app-controller-memory.js +83 -0
- package/dist/app-controller-memory.js.map +1 -0
- package/dist/app-controller-responder.d.ts +20 -0
- package/dist/app-controller-responder.d.ts.map +1 -0
- package/dist/app-controller-responder.js +246 -0
- package/dist/app-controller-responder.js.map +1 -0
- package/dist/app-controller-traceability.d.ts +25 -0
- package/dist/app-controller-traceability.d.ts.map +1 -0
- package/dist/app-controller-traceability.js +418 -0
- package/dist/app-controller-traceability.js.map +1 -0
- package/dist/app-controller-utils.d.ts +38 -0
- package/dist/app-controller-utils.d.ts.map +1 -0
- package/dist/app-controller-utils.js +185 -0
- package/dist/app-controller-utils.js.map +1 -0
- package/dist/app-controller.d.ts +400 -0
- package/dist/app-controller.d.ts.map +1 -0
- package/dist/app-controller.js +336 -0
- package/dist/app-controller.js.map +1 -0
- package/dist/app.d.ts +7 -115
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +6 -1912
- package/dist/app.js.map +1 -1
- package/dist/artifact-retention.d.ts +4 -0
- package/dist/artifact-retention.d.ts.map +1 -1
- package/dist/artifact-retention.js +38 -0
- package/dist/artifact-retention.js.map +1 -1
- package/dist/backfill.d.ts +3 -1
- package/dist/backfill.d.ts.map +1 -1
- package/dist/backfill.js +5 -1
- package/dist/backfill.js.map +1 -1
- package/dist/background-runtime.d.ts +27 -0
- package/dist/background-runtime.d.ts.map +1 -1
- package/dist/background-runtime.js +383 -205
- package/dist/background-runtime.js.map +1 -1
- package/dist/background-worker-lease.d.ts.map +1 -1
- package/dist/background-worker-lease.js +41 -241
- package/dist/background-worker-lease.js.map +1 -1
- package/dist/background.d.ts +31 -9
- package/dist/background.d.ts.map +1 -1
- package/dist/background.js +543 -272
- package/dist/background.js.map +1 -1
- package/dist/channel-drivers/cron.d.ts +11 -0
- package/dist/channel-drivers/cron.d.ts.map +1 -0
- package/dist/channel-drivers/cron.js +168 -0
- package/dist/channel-drivers/cron.js.map +1 -0
- package/dist/channel-drivers/live.d.ts +11 -0
- package/dist/channel-drivers/live.d.ts.map +1 -0
- package/dist/channel-drivers/live.js +47 -0
- package/dist/channel-drivers/live.js.map +1 -0
- package/dist/channel-drivers/native-notify.d.ts +27 -0
- package/dist/channel-drivers/native-notify.d.ts.map +1 -0
- package/dist/channel-drivers/native-notify.js +94 -0
- package/dist/channel-drivers/native-notify.js.map +1 -0
- package/dist/channel-drivers/openai-api.d.ts +7 -0
- package/dist/channel-drivers/openai-api.d.ts.map +1 -0
- package/dist/channel-drivers/openai-api.js +63 -0
- package/dist/channel-drivers/openai-api.js.map +1 -0
- package/dist/channel-drivers/shared.d.ts +4 -0
- package/dist/channel-drivers/shared.d.ts.map +1 -0
- package/dist/channel-drivers/shared.js +5 -0
- package/dist/channel-drivers/shared.js.map +1 -0
- package/dist/channel-drivers/slack.d.ts +14 -0
- package/dist/channel-drivers/slack.d.ts.map +1 -0
- package/dist/channel-drivers/slack.js +227 -0
- package/dist/channel-drivers/slack.js.map +1 -0
- package/dist/channel-drivers/telegram.d.ts +11 -0
- package/dist/channel-drivers/telegram.d.ts.map +1 -0
- package/dist/channel-drivers/telegram.js +244 -0
- package/dist/channel-drivers/telegram.js.map +1 -0
- package/dist/channel-drivers/tui.d.ts +15 -0
- package/dist/channel-drivers/tui.d.ts.map +1 -0
- package/dist/channel-drivers/tui.js +108 -0
- package/dist/channel-drivers/tui.js.map +1 -0
- package/dist/channel-drivers/webhook.d.ts +7 -0
- package/dist/channel-drivers/webhook.d.ts.map +1 -0
- package/dist/channel-drivers/webhook.js +115 -0
- package/dist/channel-drivers/webhook.js.map +1 -0
- package/dist/channels.d.ts +21 -81
- package/dist/channels.d.ts.map +1 -1
- package/dist/channels.js +11 -1174
- package/dist/channels.js.map +1 -1
- package/dist/cli-args.d.ts +121 -0
- package/dist/cli-args.d.ts.map +1 -0
- package/dist/cli-args.js +532 -0
- package/dist/cli-args.js.map +1 -0
- package/dist/cli-background-command.d.ts +59 -0
- package/dist/cli-background-command.d.ts.map +1 -0
- package/dist/cli-background-command.js +475 -0
- package/dist/cli-background-command.js.map +1 -0
- package/dist/cli-cancellation.d.ts +6 -0
- package/dist/cli-cancellation.d.ts.map +1 -0
- package/dist/cli-cancellation.js +23 -0
- package/dist/cli-cancellation.js.map +1 -0
- package/dist/cli-commands.d.ts +12 -0
- package/dist/cli-commands.d.ts.map +1 -0
- package/dist/cli-commands.js +306 -0
- package/dist/cli-commands.js.map +1 -0
- package/dist/cli-help.d.ts +11 -0
- package/dist/cli-help.d.ts.map +1 -0
- package/dist/cli-help.js +262 -0
- package/dist/cli-help.js.map +1 -0
- package/dist/cli-init-command.d.ts +60 -0
- package/dist/cli-init-command.d.ts.map +1 -0
- package/dist/cli-init-command.js +1910 -0
- package/dist/cli-init-command.js.map +1 -0
- package/dist/cli-install-skill-command.d.ts +3 -0
- package/dist/cli-install-skill-command.d.ts.map +1 -0
- package/dist/cli-install-skill-command.js +51 -0
- package/dist/cli-install-skill-command.js.map +1 -0
- package/dist/cli-sandbox-command.d.ts +10 -0
- package/dist/cli-sandbox-command.d.ts.map +1 -0
- package/dist/cli-sandbox-command.js +100 -0
- package/dist/cli-sandbox-command.js.map +1 -0
- package/dist/cli-validate-config-command.d.ts +36 -0
- package/dist/cli-validate-config-command.d.ts.map +1 -0
- package/dist/cli-validate-config-command.js +317 -0
- package/dist/cli-validate-config-command.js.map +1 -0
- package/dist/cli.d.ts +6 -248
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -3792
- package/dist/cli.js.map +1 -1
- package/dist/config-reference.d.ts +2 -1
- package/dist/config-reference.d.ts.map +1 -1
- package/dist/config-reference.js +34 -8
- package/dist/config-reference.js.map +1 -1
- package/dist/configured-agent.d.ts +23 -0
- package/dist/configured-agent.d.ts.map +1 -1
- package/dist/configured-agent.js +92 -13
- package/dist/configured-agent.js.map +1 -1
- package/dist/continuation-dispatch-worker.d.ts +21 -0
- package/dist/continuation-dispatch-worker.d.ts.map +1 -0
- package/dist/continuation-dispatch-worker.js +425 -0
- package/dist/continuation-dispatch-worker.js.map +1 -0
- package/dist/continuation-http-routes.d.ts +15 -0
- package/dist/continuation-http-routes.d.ts.map +1 -0
- package/dist/continuation-http-routes.js +265 -0
- package/dist/continuation-http-routes.js.map +1 -0
- package/dist/continuation-operator-api.d.ts +18 -0
- package/dist/continuation-operator-api.d.ts.map +1 -0
- package/dist/continuation-operator-api.js +164 -0
- package/dist/continuation-operator-api.js.map +1 -0
- package/dist/continuation-origin-store.d.ts +19 -0
- package/dist/continuation-origin-store.d.ts.map +1 -0
- package/dist/continuation-origin-store.js +273 -0
- package/dist/continuation-origin-store.js.map +1 -0
- package/dist/continuation-service-errors.d.ts +18 -0
- package/dist/continuation-service-errors.d.ts.map +1 -0
- package/dist/continuation-service-errors.js +35 -0
- package/dist/continuation-service-errors.js.map +1 -0
- package/dist/continuation-service-helpers.d.ts +39 -0
- package/dist/continuation-service-helpers.d.ts.map +1 -0
- package/dist/continuation-service-helpers.js +308 -0
- package/dist/continuation-service-helpers.js.map +1 -0
- package/dist/continuation-service-origin.d.ts +55 -0
- package/dist/continuation-service-origin.d.ts.map +1 -0
- package/dist/continuation-service-origin.js +325 -0
- package/dist/continuation-service-origin.js.map +1 -0
- package/dist/continuation-service-types.d.ts +110 -0
- package/dist/continuation-service-types.d.ts.map +1 -0
- package/dist/continuation-service-types.js +11 -0
- package/dist/continuation-service-types.js.map +1 -0
- package/dist/continuation-service.d.ts +4 -97
- package/dist/continuation-service.d.ts.map +1 -1
- package/dist/continuation-service.js +9 -1525
- package/dist/continuation-service.js.map +1 -1
- package/dist/continuation-store-fs.d.ts +15 -0
- package/dist/continuation-store-fs.d.ts.map +1 -0
- package/dist/continuation-store-fs.js +247 -0
- package/dist/continuation-store-fs.js.map +1 -0
- package/dist/continuation-store-policy.d.ts +21 -0
- package/dist/continuation-store-policy.d.ts.map +1 -0
- package/dist/continuation-store-policy.js +278 -0
- package/dist/continuation-store-policy.js.map +1 -0
- package/dist/continuation-store-records.d.ts +9 -0
- package/dist/continuation-store-records.d.ts.map +1 -0
- package/dist/continuation-store-records.js +221 -0
- package/dist/continuation-store-records.js.map +1 -0
- package/dist/continuation-store-types.d.ts +173 -0
- package/dist/continuation-store-types.d.ts.map +1 -0
- package/dist/continuation-store-types.js +26 -0
- package/dist/continuation-store-types.js.map +1 -0
- package/dist/continuation-store.d.ts +4 -138
- package/dist/continuation-store.d.ts.map +1 -1
- package/dist/continuation-store.js +145 -1049
- package/dist/continuation-store.js.map +1 -1
- package/dist/doctor.d.ts +10 -5
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +629 -58
- package/dist/doctor.js.map +1 -1
- package/dist/init.d.ts +2 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +92 -239
- package/dist/init.js.map +1 -1
- package/dist/interaction-bridge.d.ts +19 -0
- package/dist/interaction-bridge.d.ts.map +1 -1
- package/dist/interaction-bridge.js +130 -0
- package/dist/interaction-bridge.js.map +1 -1
- package/dist/launchd-logs.d.ts +91 -0
- package/dist/launchd-logs.d.ts.map +1 -0
- package/dist/launchd-logs.js +1499 -0
- package/dist/launchd-logs.js.map +1 -0
- package/dist/launchd.d.ts +27 -0
- package/dist/launchd.d.ts.map +1 -1
- package/dist/launchd.js +89 -10
- package/dist/launchd.js.map +1 -1
- package/dist/memory-command.d.ts +2 -0
- package/dist/memory-command.d.ts.map +1 -1
- package/dist/memory-command.js +161 -48
- package/dist/memory-command.js.map +1 -1
- package/dist/memory-recall-settings.d.ts +64 -0
- package/dist/memory-recall-settings.d.ts.map +1 -0
- package/dist/memory-recall-settings.js +51 -0
- package/dist/memory-recall-settings.js.map +1 -0
- package/dist/memory-recall.d.ts +7 -95
- package/dist/memory-recall.d.ts.map +1 -1
- package/dist/memory-recall.js +8 -143
- package/dist/memory-recall.js.map +1 -1
- package/dist/memory-rituals.d.ts +3 -3
- package/dist/memory-rituals.d.ts.map +1 -1
- package/dist/memory-rituals.js +16 -111
- package/dist/memory-rituals.js.map +1 -1
- package/dist/modules/catalog.js +2 -2
- package/dist/modules/catalog.js.map +1 -1
- package/dist/modules/known-tools.d.ts +8 -6
- package/dist/modules/known-tools.d.ts.map +1 -1
- package/dist/modules/known-tools.js +8 -6
- package/dist/modules/known-tools.js.map +1 -1
- package/dist/notify-destinations.d.ts +5 -0
- package/dist/notify-destinations.d.ts.map +1 -1
- package/dist/notify-destinations.js +7 -1
- package/dist/notify-destinations.js.map +1 -1
- package/dist/owner-private-lock.d.ts +95 -0
- package/dist/owner-private-lock.d.ts.map +1 -0
- package/dist/owner-private-lock.js +566 -0
- package/dist/owner-private-lock.js.map +1 -0
- package/dist/pi-oauth-login.d.ts +2 -0
- package/dist/pi-oauth-login.d.ts.map +1 -1
- package/dist/pi-oauth-login.js +39 -5
- package/dist/pi-oauth-login.js.map +1 -1
- package/dist/posted-message-index.d.ts +35 -16
- package/dist/posted-message-index.d.ts.map +1 -1
- package/dist/posted-message-index.js +1068 -52
- package/dist/posted-message-index.js.map +1 -1
- package/dist/posted-reply-history.d.ts +17 -0
- package/dist/posted-reply-history.d.ts.map +1 -0
- package/dist/posted-reply-history.js +169 -0
- package/dist/posted-reply-history.js.map +1 -0
- package/dist/proactive-notify.d.ts.map +1 -1
- package/dist/proactive-notify.js +6 -4
- package/dist/proactive-notify.js.map +1 -1
- package/dist/project-skills.d.ts +4 -0
- package/dist/project-skills.d.ts.map +1 -1
- package/dist/project-skills.js +99 -130
- package/dist/project-skills.js.map +1 -1
- package/dist/provider-setup.d.ts +0 -1
- package/dist/provider-setup.d.ts.map +1 -1
- package/dist/provider-setup.js +0 -3
- package/dist/provider-setup.js.map +1 -1
- package/dist/readiness-probe-worker.d.ts +61 -1
- package/dist/readiness-probe-worker.d.ts.map +1 -1
- package/dist/readiness-probe-worker.js +54 -58
- package/dist/readiness-probe-worker.js.map +1 -1
- package/dist/readiness-probe.d.ts +6 -9
- package/dist/readiness-probe.d.ts.map +1 -1
- package/dist/readiness-probe.js +30 -56
- package/dist/readiness-probe.js.map +1 -1
- package/dist/redact-secrets.d.ts +15 -0
- package/dist/redact-secrets.d.ts.map +1 -0
- package/dist/redact-secrets.js +65 -0
- package/dist/redact-secrets.js.map +1 -0
- package/dist/run-history.js +34 -24
- package/dist/run-history.js.map +1 -1
- package/dist/runtime-provenance.d.ts +8 -0
- package/dist/runtime-provenance.d.ts.map +1 -0
- package/dist/runtime-provenance.js +225 -0
- package/dist/runtime-provenance.js.map +1 -0
- package/dist/sandbox-manager.d.ts +4 -1
- package/dist/sandbox-manager.d.ts.map +1 -1
- package/dist/sandbox-manager.js +114 -273
- package/dist/sandbox-manager.js.map +1 -1
- package/dist/secure-file-replace.d.ts +49 -0
- package/dist/secure-file-replace.d.ts.map +1 -0
- package/dist/secure-file-replace.js +270 -0
- package/dist/secure-file-replace.js.map +1 -0
- package/dist/seen-conversations.d.ts +32 -5
- package/dist/seen-conversations.d.ts.map +1 -1
- package/dist/seen-conversations.js +145 -15
- package/dist/seen-conversations.js.map +1 -1
- package/dist/sessions.d.ts +20 -0
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +41 -6
- package/dist/sessions.js.map +1 -1
- package/dist/wizard/index.d.ts +2 -2
- package/dist/wizard/index.d.ts.map +1 -1
- package/dist/wizard/index.js +1 -1
- package/dist/wizard/index.js.map +1 -1
- package/dist/wizard/presets.d.ts +2 -1
- package/dist/wizard/presets.d.ts.map +1 -1
- package/dist/wizard/presets.js +2 -1
- package/dist/wizard/presets.js.map +1 -1
- package/dist/wizard/run.d.ts +0 -26
- package/dist/wizard/run.d.ts.map +1 -1
- package/dist/wizard/run.js +0 -53
- package/dist/wizard/run.js.map +1 -1
- package/package.json +22 -17
- package/schema/mono-agent.config.schema.json +45 -10
- package/skills/mono-agent-composer/SKILL.md +3 -3
- package/skills/mono-agent-composer/references/config-blueprint.md +123 -29
- package/skills/mono-agent-composer/references/discovery-questions.md +10 -8
- package/skills/mono-agent-composer/references/feature-coverage.md +90 -76
- package/skills/mono-agent-composer/references/package-map.md +6 -4
- package/skills/mono-agent-composer/references/playbooks.md +48 -27
- package/skills/mono-agent-composer/references/validation.md +16 -3
- package/dist/notify-runtime.d.ts +0 -26
- package/dist/notify-runtime.d.ts.map +0 -1
- package/dist/notify-runtime.js +0 -27
- package/dist/notify-runtime.js.map +0 -1
- package/dist/notify-tool.d.ts +0 -51
- package/dist/notify-tool.d.ts.map +0 -1
- package/dist/notify-tool.js +0 -182
- package/dist/notify-tool.js.map +0 -1
- package/dist/recipes/base.d.ts +0 -15
- package/dist/recipes/base.d.ts.map +0 -1
- package/dist/recipes/base.js +0 -51
- package/dist/recipes/base.js.map +0 -1
- package/dist/recipes/catalog.d.ts +0 -4
- package/dist/recipes/catalog.d.ts.map +0 -1
- package/dist/recipes/catalog.js +0 -525
- package/dist/recipes/catalog.js.map +0 -1
- package/dist/recipes/index.d.ts +0 -11
- package/dist/recipes/index.d.ts.map +0 -1
- package/dist/recipes/index.js +0 -14
- package/dist/recipes/index.js.map +0 -1
- package/dist/recipes/types.d.ts +0 -70
- package/dist/recipes/types.d.ts.map +0 -1
- package/dist/recipes/types.js +0 -15
- package/dist/recipes/types.js.map +0 -1
- package/dist/setup.d.ts +0 -29
- package/dist/setup.d.ts.map +0 -1
- package/dist/setup.js +0 -97
- package/dist/setup.js.map +0 -1
|
@@ -0,0 +1,1499 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import { lstat, open, realpath, rename, rm } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { deriveLaunchdLabel, launchdPathsFor } from "./launchd.js";
|
|
7
|
+
export const LAUNCHD_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
8
|
+
export const LAUNCHD_LOG_ROTATION_COUNT = 3;
|
|
9
|
+
export const LAUNCHD_LOG_MAINTENANCE_INTERVAL_SECONDS = 5 * 60;
|
|
10
|
+
export const DEFAULT_LAUNCHD_LOG_POLICY = Object.freeze({
|
|
11
|
+
maxBytes: LAUNCHD_LOG_MAX_BYTES,
|
|
12
|
+
rotationCount: LAUNCHD_LOG_ROTATION_COUNT,
|
|
13
|
+
});
|
|
14
|
+
export function defaultLaunchdLogDependencies() {
|
|
15
|
+
return {
|
|
16
|
+
lstat,
|
|
17
|
+
realpath,
|
|
18
|
+
open,
|
|
19
|
+
rename,
|
|
20
|
+
rm: async (path) => await rm(path, { force: true }),
|
|
21
|
+
currentUid: () => process.getuid?.(),
|
|
22
|
+
syncHandle: async (handle) => await handle.sync(),
|
|
23
|
+
readTail: readFileTail,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Derive the same per-config launchd paths as the background controller without
|
|
28
|
+
* following a final-component symlink. Doctor and the private maintenance
|
|
29
|
+
* command use this read-only helper; neither accepts a caller-supplied log path.
|
|
30
|
+
*/
|
|
31
|
+
export async function launchdLogPathsForConfig(configPath, home) {
|
|
32
|
+
const lexical = resolve(configPath);
|
|
33
|
+
try {
|
|
34
|
+
const candidate = join(await realpath(dirname(lexical)), basename(lexical));
|
|
35
|
+
try {
|
|
36
|
+
const initial = await lstat(candidate);
|
|
37
|
+
if (initial.isSymbolicLink())
|
|
38
|
+
return launchdPathsFor(deriveLaunchdLabel(candidate), home);
|
|
39
|
+
if (!initial.isFile())
|
|
40
|
+
return launchdPathsFor(deriveLaunchdLabel(candidate), home);
|
|
41
|
+
const handle = await open(candidate, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
42
|
+
try {
|
|
43
|
+
const observed = await handle.stat();
|
|
44
|
+
if (!sameFileSnapshot(initial, observed)) {
|
|
45
|
+
throw new Error("Background config identity changed while launchd log paths were derived.");
|
|
46
|
+
}
|
|
47
|
+
const canonical = await realpath(candidate);
|
|
48
|
+
const [candidateAfter, canonicalAfter] = await Promise.all([lstat(candidate), lstat(canonical)]);
|
|
49
|
+
if (!sameFileSnapshot(observed, candidateAfter)
|
|
50
|
+
|| !sameFilesystemIdentity(observed, canonicalAfter)) {
|
|
51
|
+
throw new Error("Background config identity changed while launchd log paths were derived.");
|
|
52
|
+
}
|
|
53
|
+
return launchdPathsFor(deriveLaunchdLabel(canonical), home);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
await handle.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR")) {
|
|
61
|
+
return launchdPathsFor(deriveLaunchdLabel(candidate), home);
|
|
62
|
+
}
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR")) {
|
|
68
|
+
return launchdPathsFor(deriveLaunchdLabel(lexical), home);
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Read and authenticate the durable lifecycle intent without changing it. */
|
|
74
|
+
export async function readLaunchdLogMaintenanceIntent(paths, deps = defaultLaunchdLogDependencies()) {
|
|
75
|
+
assertCanonicalLogPaths(paths);
|
|
76
|
+
const directory = await inspectDirectoryChain(paths.logDir, deps);
|
|
77
|
+
if (directory.state === "missing")
|
|
78
|
+
return undefined;
|
|
79
|
+
if (directory.state === "unsafe" || directory.state === "unreadable") {
|
|
80
|
+
throw new Error("LaunchAgent log maintenance intent requires a safe canonical directory chain.");
|
|
81
|
+
}
|
|
82
|
+
return (await loadMaintenanceIntent(paths, deps))?.intent;
|
|
83
|
+
}
|
|
84
|
+
/** Publish pre-stop intent atomically before bootout; an unproven unloaded phase fails closed. */
|
|
85
|
+
export async function beginLaunchdLogMaintenanceIntent(paths, intent, deps = defaultLaunchdLogDependencies()) {
|
|
86
|
+
assertCanonicalLogPaths(paths);
|
|
87
|
+
validateMaintenanceIntent(intent);
|
|
88
|
+
if (intent.phase !== "stopping") {
|
|
89
|
+
throw new Error("LaunchAgent log maintenance must begin in the stopping phase.");
|
|
90
|
+
}
|
|
91
|
+
const existing = await readLaunchdLogMaintenanceIntent(paths, deps);
|
|
92
|
+
if (existing !== undefined) {
|
|
93
|
+
if (sameMaintenanceIntent(existing, intent))
|
|
94
|
+
return;
|
|
95
|
+
throw new Error("A different launchd log maintenance intent is already pending.");
|
|
96
|
+
}
|
|
97
|
+
const directories = await openValidatedDirectoryChain(paths.logDir, deps);
|
|
98
|
+
if (directories === undefined)
|
|
99
|
+
throw new Error("LaunchAgent log directory does not exist.");
|
|
100
|
+
try {
|
|
101
|
+
for (const directory of directories)
|
|
102
|
+
await secureDirectory(directory, deps);
|
|
103
|
+
await assertDirectoryChainIdentity(directories, deps);
|
|
104
|
+
const data = Buffer.from(`${JSON.stringify(intent)}\n`, "utf8");
|
|
105
|
+
if (data.length > MAX_MAINTENANCE_INTENT_BYTES) {
|
|
106
|
+
throw new Error("LaunchAgent log maintenance intent exceeds its fixed size bound.");
|
|
107
|
+
}
|
|
108
|
+
const nextPath = maintenanceIntentNextPath(paths);
|
|
109
|
+
await removeKnownArtifact(nextPath, directories, deps, MAX_MAINTENANCE_INTENT_BYTES);
|
|
110
|
+
await assertPathMissing(maintenanceIntentPath(paths), deps);
|
|
111
|
+
const nextStats = await writeOwnerPrivateArtifact(nextPath, data, directories, deps);
|
|
112
|
+
try {
|
|
113
|
+
await assertDirectoryChainIdentity(directories, deps);
|
|
114
|
+
await assertPathMissing(maintenanceIntentPath(paths), deps);
|
|
115
|
+
await assertKnownPath(nextPath, nextStats, deps);
|
|
116
|
+
await deps.rename(nextPath, maintenanceIntentPath(paths));
|
|
117
|
+
const published = await deps.lstat(maintenanceIntentPath(paths));
|
|
118
|
+
if (!matchesCommittedFingerprint(published, fingerprintFor(nextStats))) {
|
|
119
|
+
throw new Error("LaunchAgent log maintenance intent changed while it was published.");
|
|
120
|
+
}
|
|
121
|
+
await deps.syncHandle(directories.at(-1).handle);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
await removeKnownPath(nextPath, nextStats, directories, deps).catch(() => undefined);
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
await Promise.all(directories.map(async (entry) => await entry.handle.close().catch(() => undefined)));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Durably record that launchd is unloaded and every writer PID observed by this maintainer is dead. */
|
|
133
|
+
export async function markLaunchdLogMaintenanceStopped(paths, expected, deps = defaultLaunchdLogDependencies()) {
|
|
134
|
+
if (expected.phase !== "stopping" && expected.phase !== "restoring") {
|
|
135
|
+
throw new Error("LaunchAgent log maintenance can mark only an invalidated intent as stopped.");
|
|
136
|
+
}
|
|
137
|
+
return await replaceMaintenanceIntentPhase(paths, expected, "stopped", deps);
|
|
138
|
+
}
|
|
139
|
+
/** Invalidate stopped-writer proof before bootstrap can create a replacement writer. */
|
|
140
|
+
export async function markLaunchdLogMaintenanceRestoring(paths, expected, deps = defaultLaunchdLogDependencies()) {
|
|
141
|
+
if (expected.phase !== "stopped") {
|
|
142
|
+
throw new Error("LaunchAgent log maintenance can restore only from a stopped intent.");
|
|
143
|
+
}
|
|
144
|
+
return await replaceMaintenanceIntentPhase(paths, expected, "restoring", deps);
|
|
145
|
+
}
|
|
146
|
+
/** Invalidate old stopped-writer proof before booting out any newly loaded writer. */
|
|
147
|
+
export async function markLaunchdLogMaintenanceStopping(paths, expected, deps = defaultLaunchdLogDependencies()) {
|
|
148
|
+
if (expected.phase !== "stopped") {
|
|
149
|
+
throw new Error("LaunchAgent log maintenance can invalidate only a stopped intent.");
|
|
150
|
+
}
|
|
151
|
+
return await replaceMaintenanceIntentPhase(paths, expected, "stopping", deps);
|
|
152
|
+
}
|
|
153
|
+
async function replaceMaintenanceIntentPhase(paths, expected, phase, deps) {
|
|
154
|
+
assertCanonicalLogPaths(paths);
|
|
155
|
+
validateMaintenanceIntent(expected);
|
|
156
|
+
const observed = await readLaunchdLogMaintenanceIntent(paths, deps);
|
|
157
|
+
if (observed === undefined || !sameMaintenanceIntent(observed, expected)) {
|
|
158
|
+
throw new Error("LaunchAgent log maintenance intent changed before its lifecycle phase was recorded.");
|
|
159
|
+
}
|
|
160
|
+
const updated = { ...expected, phase };
|
|
161
|
+
const data = Buffer.from(`${JSON.stringify(updated)}\n`, "utf8");
|
|
162
|
+
if (data.length > MAX_MAINTENANCE_INTENT_BYTES) {
|
|
163
|
+
throw new Error("LaunchAgent log maintenance intent exceeds its fixed size bound.");
|
|
164
|
+
}
|
|
165
|
+
const directories = await openValidatedDirectoryChain(paths.logDir, deps);
|
|
166
|
+
if (directories === undefined)
|
|
167
|
+
throw new Error("LaunchAgent log directory disappeared before lifecycle proof update.");
|
|
168
|
+
try {
|
|
169
|
+
for (const directory of directories)
|
|
170
|
+
await secureDirectory(directory, deps);
|
|
171
|
+
await assertDirectoryChainIdentity(directories, deps);
|
|
172
|
+
const current = await loadMaintenanceIntent(paths, deps);
|
|
173
|
+
if (current === undefined || !sameMaintenanceIntent(current.intent, expected)) {
|
|
174
|
+
throw new Error("LaunchAgent log maintenance intent changed before its lifecycle phase was recorded.");
|
|
175
|
+
}
|
|
176
|
+
const nextPath = maintenanceIntentNextPath(paths);
|
|
177
|
+
await removeKnownArtifact(nextPath, directories, deps, MAX_MAINTENANCE_INTENT_BYTES);
|
|
178
|
+
const nextStats = await writeOwnerPrivateArtifact(nextPath, data, directories, deps);
|
|
179
|
+
try {
|
|
180
|
+
await assertDirectoryChainIdentity(directories, deps);
|
|
181
|
+
const currentIntent = await loadMaintenanceIntent(paths, deps);
|
|
182
|
+
if (currentIntent === undefined || !sameFileSnapshot(currentIntent.stats, current.stats)
|
|
183
|
+
|| !sameMaintenanceIntent(currentIntent.intent, expected)) {
|
|
184
|
+
throw new Error("LaunchAgent log maintenance intent changed before its lifecycle phase was committed.");
|
|
185
|
+
}
|
|
186
|
+
await assertKnownPath(nextPath, nextStats, deps);
|
|
187
|
+
await deps.rename(nextPath, maintenanceIntentPath(paths));
|
|
188
|
+
const published = await deps.lstat(maintenanceIntentPath(paths));
|
|
189
|
+
if (!matchesCommittedFingerprint(published, fingerprintFor(nextStats))) {
|
|
190
|
+
throw new Error("LaunchAgent log lifecycle phase changed while it was published.");
|
|
191
|
+
}
|
|
192
|
+
await deps.syncHandle(directories.at(-1).handle);
|
|
193
|
+
return updated;
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
await removeKnownPath(nextPath, nextStats, directories, deps).catch(() => undefined);
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
await Promise.all(directories.map(async (entry) => await entry.handle.close().catch(() => undefined)));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Remove only the exact authenticated intent after recovery or explicit stop. */
|
|
205
|
+
export async function clearLaunchdLogMaintenanceIntent(paths, expected, deps = defaultLaunchdLogDependencies()) {
|
|
206
|
+
assertCanonicalLogPaths(paths);
|
|
207
|
+
const observed = await readLaunchdLogMaintenanceIntent(paths, deps);
|
|
208
|
+
if (expected !== undefined && observed === undefined) {
|
|
209
|
+
throw new Error("LaunchAgent log maintenance intent disappeared before cleanup.");
|
|
210
|
+
}
|
|
211
|
+
if (observed !== undefined && expected === undefined) {
|
|
212
|
+
throw new Error("LaunchAgent log maintenance intent requires its exact authenticated value for cleanup.");
|
|
213
|
+
}
|
|
214
|
+
if (observed?.phase === "stopping" && expected !== undefined) {
|
|
215
|
+
throw new Error("Refusing to clear launchd log maintenance intent before durable stopped-writer proof.");
|
|
216
|
+
}
|
|
217
|
+
if (observed !== undefined && expected !== undefined && !sameMaintenanceIntent(observed, expected)) {
|
|
218
|
+
throw new Error("LaunchAgent log maintenance intent changed before cleanup.");
|
|
219
|
+
}
|
|
220
|
+
const directories = await openValidatedDirectoryChain(paths.logDir, deps);
|
|
221
|
+
if (directories === undefined) {
|
|
222
|
+
if (observed === undefined)
|
|
223
|
+
return;
|
|
224
|
+
throw new Error("LaunchAgent log directory disappeared before intent cleanup.");
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
for (const directory of directories)
|
|
228
|
+
await secureDirectory(directory, deps);
|
|
229
|
+
await assertDirectoryChainIdentity(directories, deps);
|
|
230
|
+
let changed = await removeKnownArtifact(maintenanceIntentNextPath(paths), directories, deps, MAX_MAINTENANCE_INTENT_BYTES);
|
|
231
|
+
if (observed !== undefined) {
|
|
232
|
+
const current = await loadMaintenanceIntent(paths, deps);
|
|
233
|
+
if (current === undefined || !sameMaintenanceIntent(current.intent, observed)) {
|
|
234
|
+
throw new Error("LaunchAgent log maintenance intent changed before cleanup.");
|
|
235
|
+
}
|
|
236
|
+
await deps.beforeCommit?.(maintenanceIntentPath(paths));
|
|
237
|
+
await removeExpectedPath(maintenanceIntentPath(paths), current.stats, directories, deps);
|
|
238
|
+
await assertPathMissing(maintenanceIntentPath(paths), deps);
|
|
239
|
+
changed = true;
|
|
240
|
+
}
|
|
241
|
+
if (changed)
|
|
242
|
+
await deps.syncHandle(directories.at(-1).handle);
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
await Promise.all(directories.map(async (entry) => await entry.handle.close().catch(() => undefined)));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** Read only: opens and stats metadata but never chmods, creates, or reads bytes. */
|
|
249
|
+
export async function inspectLaunchdLogs(paths, policy = DEFAULT_LAUNCHD_LOG_POLICY, deps = defaultLaunchdLogDependencies()) {
|
|
250
|
+
assertPolicy(policy);
|
|
251
|
+
assertCanonicalLogPaths(paths);
|
|
252
|
+
const directory = await inspectDirectoryChain(paths.logDir, deps);
|
|
253
|
+
if (directory.state === "missing") {
|
|
254
|
+
return emptyInspection();
|
|
255
|
+
}
|
|
256
|
+
if (directory.state === "unsafe" || directory.state === "unreadable") {
|
|
257
|
+
const issue = `log directory: ${directory.issue ?? directory.state}`;
|
|
258
|
+
return {
|
|
259
|
+
...emptyInspection(false),
|
|
260
|
+
canMaintain: false,
|
|
261
|
+
issues: [issue],
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const [stdout, stderr] = await Promise.all([
|
|
265
|
+
inspectStream(paths.stdoutPath, policy, deps),
|
|
266
|
+
inspectStream(paths.stderrPath, policy, deps),
|
|
267
|
+
]);
|
|
268
|
+
const artifacts = await inspectRotationArtifacts(paths, policy, deps);
|
|
269
|
+
const files = [...stdout.files, ...stderr.files];
|
|
270
|
+
const issues = [
|
|
271
|
+
...(directory.issue === undefined ? [] : [`log directory: ${directory.issue}`]),
|
|
272
|
+
...streamIssues("stdout", stdout),
|
|
273
|
+
...streamIssues("stderr", stderr),
|
|
274
|
+
...artifacts.issues,
|
|
275
|
+
];
|
|
276
|
+
const unsafe = files.some((file) => file.state === "unsafe" || file.state === "unreadable");
|
|
277
|
+
const repairable = directory.state === "repairable"
|
|
278
|
+
|| files.some((file) => file.state === "repairable");
|
|
279
|
+
const oversized = files.some((file) => file.bytes > policy.maxBytes);
|
|
280
|
+
return {
|
|
281
|
+
stdout,
|
|
282
|
+
stderr,
|
|
283
|
+
present: artifacts.present || files.some((file) => file.state !== "missing"),
|
|
284
|
+
canMaintain: !unsafe && artifacts.canMaintain,
|
|
285
|
+
needsMaintenance: artifacts.present || repairable || oversized,
|
|
286
|
+
pendingTransaction: artifacts.pendingTransaction,
|
|
287
|
+
pendingMaintenance: artifacts.pendingMaintenance,
|
|
288
|
+
issues,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Rotate only after the caller has proven every possible writer dead. All
|
|
293
|
+
* source bytes are read through validated handles, all replacements are capped,
|
|
294
|
+
* fsynced owner-only temporaries, and each destination identity is rechecked
|
|
295
|
+
* immediately before its atomic replacement.
|
|
296
|
+
*/
|
|
297
|
+
export async function rotateStoppedLaunchdLogs(paths, policy = DEFAULT_LAUNCHD_LOG_POLICY, deps = defaultLaunchdLogDependencies()) {
|
|
298
|
+
assertPolicy(policy);
|
|
299
|
+
assertCanonicalLogPaths(paths);
|
|
300
|
+
// Establish a complete read-only safety inventory before chmod, cleanup,
|
|
301
|
+
// journal recovery, or replacement. Every mutating step revalidates the
|
|
302
|
+
// identities it relies on, but an unsafe peer must prevent all repair.
|
|
303
|
+
const preflight = await inspectLaunchdLogs(paths, policy, deps);
|
|
304
|
+
if (!preflight.canMaintain) {
|
|
305
|
+
throw new Error(`LaunchAgent log maintenance refused unsafe inventory: ${preflight.issues.join("; ")}`);
|
|
306
|
+
}
|
|
307
|
+
if (preflight.pendingTransaction) {
|
|
308
|
+
// Metadata safety alone is insufficient: authenticate the bounded journal
|
|
309
|
+
// schema and canonical target set before repairing directory permissions.
|
|
310
|
+
if (await loadRotationJournal(paths, policy, deps) === undefined) {
|
|
311
|
+
throw new Error("Pending LaunchAgent log rotation journal disappeared after preflight.");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const directories = await openValidatedDirectoryChain(paths.logDir, deps);
|
|
315
|
+
if (directories === undefined)
|
|
316
|
+
return { changed: false, replacedFiles: 0 };
|
|
317
|
+
const directory = directories.at(-1);
|
|
318
|
+
const opened = [];
|
|
319
|
+
const preparedReplacements = [];
|
|
320
|
+
let journalPublished = false;
|
|
321
|
+
try {
|
|
322
|
+
for (const currentDirectory of directories) {
|
|
323
|
+
await secureDirectory(currentDirectory, deps);
|
|
324
|
+
}
|
|
325
|
+
const recovered = await recoverOrCleanRotation(paths, policy, directories, deps);
|
|
326
|
+
for (const path of streamPaths(paths.stdoutPath, policy.rotationCount)) {
|
|
327
|
+
const file = await openValidatedLogFile(path, deps);
|
|
328
|
+
if (file !== undefined)
|
|
329
|
+
opened.push(file);
|
|
330
|
+
}
|
|
331
|
+
for (const path of streamPaths(paths.stderrPath, policy.rotationCount)) {
|
|
332
|
+
const file = await openValidatedLogFile(path, deps);
|
|
333
|
+
if (file !== undefined)
|
|
334
|
+
opened.push(file);
|
|
335
|
+
}
|
|
336
|
+
// Do not mutate permissions until every present path has passed ownership,
|
|
337
|
+
// link-count, type, and identity validation.
|
|
338
|
+
for (const file of opened)
|
|
339
|
+
await secureLogFile(file, deps);
|
|
340
|
+
const byPath = new Map(opened.map((file) => [file.path, file]));
|
|
341
|
+
const expectedInventory = new Map(opened.map((file) => [file.path, file.stats]));
|
|
342
|
+
await assertOpenedInventory(paths, policy.rotationCount, expectedInventory, deps);
|
|
343
|
+
const plans = [
|
|
344
|
+
...await buildStreamPlans(paths.stdoutPath, byPath, policy, deps),
|
|
345
|
+
...await buildStreamPlans(paths.stderrPath, byPath, policy, deps),
|
|
346
|
+
];
|
|
347
|
+
if (plans.length === 0) {
|
|
348
|
+
await assertDirectoryChainIdentity(directories, deps);
|
|
349
|
+
await assertOpenedInventory(paths, policy.rotationCount, expectedInventory, deps);
|
|
350
|
+
await deps.syncHandle(directory.handle);
|
|
351
|
+
return {
|
|
352
|
+
changed: recovered.changed
|
|
353
|
+
|| opened.some((file) => file.permissionsRepaired)
|
|
354
|
+
|| directories.some((entry) => entry.permissionsRepaired),
|
|
355
|
+
replacedFiles: recovered.replacedFiles,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
await assertDirectoryChainIdentity(directories, deps);
|
|
359
|
+
await assertOpenedInventory(paths, policy.rotationCount, expectedInventory, deps);
|
|
360
|
+
for (const plan of plans) {
|
|
361
|
+
const replacement = await prepareReplacement(plan, directories, deps);
|
|
362
|
+
preparedReplacements.push(replacement);
|
|
363
|
+
}
|
|
364
|
+
await deps.syncHandle(directory.handle);
|
|
365
|
+
try {
|
|
366
|
+
await publishRotationJournal(paths, policy, preparedReplacements, directories, deps);
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
journalPublished = await lstatIfPresent(rotationJournalPath(paths), deps) !== undefined;
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
journalPublished = true;
|
|
373
|
+
const committed = await recoverPublishedRotation(paths, policy, directories, deps);
|
|
374
|
+
journalPublished = false;
|
|
375
|
+
return {
|
|
376
|
+
changed: true,
|
|
377
|
+
replacedFiles: recovered.replacedFiles + committed.replacedFiles,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
finally {
|
|
381
|
+
await Promise.all(opened.map(async (file) => await file.handle.close().catch(() => undefined)));
|
|
382
|
+
if (!journalPublished) {
|
|
383
|
+
await Promise.all(preparedReplacements.map(async (replacement) => {
|
|
384
|
+
await removeKnownTemporary(replacement, directories, deps).catch(() => undefined);
|
|
385
|
+
}));
|
|
386
|
+
await removeKnownArtifact(rotationJournalNextPath(paths), directories, deps).catch(() => undefined);
|
|
387
|
+
}
|
|
388
|
+
await Promise.all(directories.map(async (entry) => await entry.handle.close().catch(() => undefined)));
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const TRANSACTION_ARTIFACT_PREFIX = ".mono-agent-launchd-log-";
|
|
392
|
+
const ROTATION_JOURNAL_SUFFIX = "-rotation.v1.json";
|
|
393
|
+
const ROTATION_JOURNAL_NEXT_SUFFIX = "-rotation.v1.next";
|
|
394
|
+
const MAX_ROTATION_JOURNAL_BYTES = 64 * 1024;
|
|
395
|
+
const MAINTENANCE_INTENT_SUFFIX = "-maintenance.v1.json";
|
|
396
|
+
const MAINTENANCE_INTENT_NEXT_SUFFIX = "-maintenance.v1.next";
|
|
397
|
+
const MAX_MAINTENANCE_INTENT_BYTES = 4 * 1024;
|
|
398
|
+
async function inspectDirectoryChain(logDir, deps) {
|
|
399
|
+
let repairableIssue;
|
|
400
|
+
for (const entry of canonicalDirectoryChain(logDir)) {
|
|
401
|
+
const inspection = await inspectDirectory(entry.path, entry.requireOwnerPrivate, deps);
|
|
402
|
+
if (inspection.state === "missing")
|
|
403
|
+
return inspection;
|
|
404
|
+
if (inspection.state === "unsafe" || inspection.state === "unreadable") {
|
|
405
|
+
return {
|
|
406
|
+
...inspection,
|
|
407
|
+
issue: `${entry.path === logDir ? "log directory" : "log parent"}: ${inspection.issue ?? inspection.state}`,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
if (inspection.state === "repairable") {
|
|
411
|
+
repairableIssue = `${entry.path === logDir ? "log directory" : "log parent"}: ${inspection.issue ?? "permissions require repair"}`;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
await assertCanonicalDirectoryRealpath(logDir, deps);
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
return { state: "unsafe", issue: safeReason(error, logDir) };
|
|
419
|
+
}
|
|
420
|
+
return repairableIssue === undefined
|
|
421
|
+
? { state: "ok" }
|
|
422
|
+
: { state: "repairable", issue: repairableIssue };
|
|
423
|
+
}
|
|
424
|
+
async function inspectRotationArtifacts(paths, policy, deps) {
|
|
425
|
+
const candidates = [
|
|
426
|
+
{ path: rotationJournalPath(paths), maxBytes: MAX_ROTATION_JOURNAL_BYTES, kind: "rotation" },
|
|
427
|
+
{ path: rotationJournalNextPath(paths), maxBytes: MAX_ROTATION_JOURNAL_BYTES, kind: "preparation" },
|
|
428
|
+
{ path: maintenanceIntentPath(paths), maxBytes: MAX_MAINTENANCE_INTENT_BYTES, kind: "maintenance" },
|
|
429
|
+
{ path: maintenanceIntentNextPath(paths), maxBytes: MAX_MAINTENANCE_INTENT_BYTES, kind: "preparation" },
|
|
430
|
+
...rotationStagePaths(paths, policy.rotationCount).map((path) => ({
|
|
431
|
+
path,
|
|
432
|
+
maxBytes: policy.maxBytes,
|
|
433
|
+
kind: "preparation",
|
|
434
|
+
})),
|
|
435
|
+
];
|
|
436
|
+
let present = false;
|
|
437
|
+
let canMaintain = true;
|
|
438
|
+
let pendingTransaction = false;
|
|
439
|
+
let pendingMaintenance = false;
|
|
440
|
+
const issues = [];
|
|
441
|
+
for (const candidate of candidates) {
|
|
442
|
+
const state = await inspectRotationArtifact(candidate.path, candidate.maxBytes, deps);
|
|
443
|
+
if (state === undefined)
|
|
444
|
+
continue;
|
|
445
|
+
present = true;
|
|
446
|
+
if (candidate.kind === "rotation")
|
|
447
|
+
pendingTransaction = true;
|
|
448
|
+
if (candidate.kind === "maintenance")
|
|
449
|
+
pendingMaintenance = true;
|
|
450
|
+
if (state !== "ok") {
|
|
451
|
+
canMaintain = false;
|
|
452
|
+
issues.push(`rotation transaction artifact: ${state}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (issues.length === 0) {
|
|
456
|
+
if (pendingMaintenance)
|
|
457
|
+
issues.push("pending launchd-log maintenance lifecycle requires recovery");
|
|
458
|
+
if (pendingTransaction)
|
|
459
|
+
issues.push("pending launchd-log rotation transaction requires recovery");
|
|
460
|
+
if (present && !pendingMaintenance && !pendingTransaction) {
|
|
461
|
+
issues.push("interrupted launchd-log rotation preparation requires cleanup");
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return { present, canMaintain, pendingTransaction, pendingMaintenance, issues };
|
|
465
|
+
}
|
|
466
|
+
async function inspectRotationArtifact(path, maxBytes, deps) {
|
|
467
|
+
let initial;
|
|
468
|
+
try {
|
|
469
|
+
initial = await deps.lstat(path);
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
if (isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR"))
|
|
473
|
+
return undefined;
|
|
474
|
+
return "unreadable";
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
assertOwnerRegularFile(initial, path, deps.currentUid(), "LaunchAgent log transaction artifact");
|
|
478
|
+
if ((initial.mode & 0o777) !== 0o600 || initial.size > maxBytes)
|
|
479
|
+
return "unsafe";
|
|
480
|
+
const handle = await deps.open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
481
|
+
try {
|
|
482
|
+
const opened = await handle.stat();
|
|
483
|
+
const current = await deps.lstat(path);
|
|
484
|
+
return sameFileSnapshot(initial, opened) && sameFileSnapshot(opened, current) ? "ok" : "unsafe";
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
await handle.close();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
return "unsafe";
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
async function inspectDirectory(path, requireOwnerPrivate, deps) {
|
|
495
|
+
let initial;
|
|
496
|
+
try {
|
|
497
|
+
initial = await deps.lstat(path);
|
|
498
|
+
}
|
|
499
|
+
catch (error) {
|
|
500
|
+
if (isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR"))
|
|
501
|
+
return { state: "missing" };
|
|
502
|
+
return { state: "unreadable", issue: errnoDescription(error) };
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
assertOwnerDirectory(initial, path, deps.currentUid(), "LaunchAgent log directory");
|
|
506
|
+
const handle = await deps.open(path, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
507
|
+
try {
|
|
508
|
+
const observed = await handle.stat();
|
|
509
|
+
assertOwnerDirectory(observed, path, deps.currentUid(), "LaunchAgent log directory");
|
|
510
|
+
if (!sameFilesystemIdentity(initial, observed)) {
|
|
511
|
+
throw new Error("directory identity changed during inspection");
|
|
512
|
+
}
|
|
513
|
+
const current = await deps.lstat(path);
|
|
514
|
+
if (!sameFilesystemIdentity(observed, current)) {
|
|
515
|
+
throw new Error("directory identity changed during inspection");
|
|
516
|
+
}
|
|
517
|
+
return !requireOwnerPrivate || (observed.mode & 0o777) === 0o700
|
|
518
|
+
? { state: "ok" }
|
|
519
|
+
: { state: "repairable", issue: "permissions are not owner-only read/write/search" };
|
|
520
|
+
}
|
|
521
|
+
finally {
|
|
522
|
+
await handle.close();
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
return { state: "unsafe", issue: safeReason(error, path) };
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
async function inspectStream(activePath, policy, deps) {
|
|
530
|
+
const files = await Promise.all(streamPaths(activePath, policy.rotationCount).map(async (path, generation) => await inspectLogFile(path, generation, deps)));
|
|
531
|
+
const activeBytes = files[0]?.bytes ?? 0;
|
|
532
|
+
const retainedBytes = files.slice(1).reduce((sum, file) => sum + file.bytes, 0);
|
|
533
|
+
if (!Number.isSafeInteger(retainedBytes) || !Number.isSafeInteger(activeBytes + retainedBytes)) {
|
|
534
|
+
throw new Error("LaunchAgent log inventory exceeds safe byte accounting.");
|
|
535
|
+
}
|
|
536
|
+
return {
|
|
537
|
+
activeBytes,
|
|
538
|
+
retainedBytes,
|
|
539
|
+
totalBytes: activeBytes + retainedBytes,
|
|
540
|
+
byteAccountingComplete: files.every((file) => file.state !== "unsafe" && file.state !== "unreadable"),
|
|
541
|
+
files,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
async function inspectLogFile(path, generation, deps) {
|
|
545
|
+
let initial;
|
|
546
|
+
try {
|
|
547
|
+
initial = await deps.lstat(path);
|
|
548
|
+
}
|
|
549
|
+
catch (error) {
|
|
550
|
+
return isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR")
|
|
551
|
+
? { generation, state: "missing", bytes: 0 }
|
|
552
|
+
: { generation, state: "unreadable", bytes: 0, issue: errnoDescription(error) };
|
|
553
|
+
}
|
|
554
|
+
try {
|
|
555
|
+
assertOwnerRegularFile(initial, path, deps.currentUid(), "LaunchAgent log");
|
|
556
|
+
const handle = await deps.open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
557
|
+
try {
|
|
558
|
+
const observed = await handle.stat();
|
|
559
|
+
assertOwnerRegularFile(observed, path, deps.currentUid(), "LaunchAgent log");
|
|
560
|
+
if (!sameFileSnapshot(initial, observed))
|
|
561
|
+
throw new Error("identity changed during inspection");
|
|
562
|
+
const current = await deps.lstat(path);
|
|
563
|
+
if (!sameFileSnapshot(observed, current))
|
|
564
|
+
throw new Error("identity changed during inspection");
|
|
565
|
+
return (observed.mode & 0o777) === 0o600
|
|
566
|
+
? { generation, state: "ok", bytes: observed.size }
|
|
567
|
+
: { generation, state: "repairable", bytes: observed.size, issue: "permissions are not owner-only read/write" };
|
|
568
|
+
}
|
|
569
|
+
finally {
|
|
570
|
+
await handle.close();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
catch (error) {
|
|
574
|
+
return {
|
|
575
|
+
generation,
|
|
576
|
+
state: "unsafe",
|
|
577
|
+
bytes: Number.isSafeInteger(initial.size) && initial.size >= 0 ? initial.size : 0,
|
|
578
|
+
issue: safeReason(error, path),
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
async function openValidatedDirectoryChain(logDir, deps) {
|
|
583
|
+
const opened = [];
|
|
584
|
+
try {
|
|
585
|
+
for (const entry of canonicalDirectoryChain(logDir)) {
|
|
586
|
+
const directory = await openValidatedDirectory(entry.path, entry.requireOwnerPrivate, deps);
|
|
587
|
+
if (directory === undefined) {
|
|
588
|
+
await Promise.all(opened.map(async (item) => await item.handle.close().catch(() => undefined)));
|
|
589
|
+
return undefined;
|
|
590
|
+
}
|
|
591
|
+
opened.push(directory);
|
|
592
|
+
}
|
|
593
|
+
await assertDirectoryChainIdentity(opened, deps, false);
|
|
594
|
+
return opened;
|
|
595
|
+
}
|
|
596
|
+
catch (error) {
|
|
597
|
+
await Promise.all(opened.map(async (item) => await item.handle.close().catch(() => undefined)));
|
|
598
|
+
throw error;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
async function openValidatedDirectory(path, requireOwnerPrivate, deps) {
|
|
602
|
+
let initial;
|
|
603
|
+
try {
|
|
604
|
+
initial = await deps.lstat(path);
|
|
605
|
+
}
|
|
606
|
+
catch (error) {
|
|
607
|
+
if (isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR"))
|
|
608
|
+
return undefined;
|
|
609
|
+
throw error;
|
|
610
|
+
}
|
|
611
|
+
assertOwnerDirectory(initial, path, deps.currentUid(), "LaunchAgent log directory");
|
|
612
|
+
const handle = await deps.open(path, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
613
|
+
try {
|
|
614
|
+
const observed = await handle.stat();
|
|
615
|
+
assertOwnerDirectory(observed, path, deps.currentUid(), "LaunchAgent log directory");
|
|
616
|
+
if (!sameFilesystemIdentity(initial, observed))
|
|
617
|
+
throw new Error(`LaunchAgent log directory ${path} changed while opened.`);
|
|
618
|
+
await assertDirectoryIdentity(path, observed, false, deps);
|
|
619
|
+
return { path, requireOwnerPrivate, handle, stats: observed, permissionsRepaired: false };
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
await handle.close().catch(() => undefined);
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
async function openValidatedLogFile(path, deps) {
|
|
627
|
+
let initial;
|
|
628
|
+
try {
|
|
629
|
+
initial = await deps.lstat(path);
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
if (isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR"))
|
|
633
|
+
return undefined;
|
|
634
|
+
throw error;
|
|
635
|
+
}
|
|
636
|
+
assertOwnerRegularFile(initial, path, deps.currentUid(), "LaunchAgent log");
|
|
637
|
+
const handle = await deps.open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
638
|
+
try {
|
|
639
|
+
const observed = await handle.stat();
|
|
640
|
+
assertOwnerRegularFile(observed, path, deps.currentUid(), "LaunchAgent log");
|
|
641
|
+
if (!sameFileSnapshot(initial, observed))
|
|
642
|
+
throw new Error(`LaunchAgent log ${path} changed while opened.`);
|
|
643
|
+
const current = await deps.lstat(path);
|
|
644
|
+
if (!sameFileSnapshot(observed, current))
|
|
645
|
+
throw new Error(`LaunchAgent log ${path} changed while opened.`);
|
|
646
|
+
return { path, handle, stats: observed, permissionsRepaired: false };
|
|
647
|
+
}
|
|
648
|
+
catch (error) {
|
|
649
|
+
await handle.close().catch(() => undefined);
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
async function secureDirectory(directory, deps) {
|
|
654
|
+
if (directory.requireOwnerPrivate && (directory.stats.mode & 0o777) !== 0o700) {
|
|
655
|
+
await directory.handle.chmod(0o700);
|
|
656
|
+
directory.permissionsRepaired = true;
|
|
657
|
+
}
|
|
658
|
+
const secured = await directory.handle.stat();
|
|
659
|
+
assertOwnerDirectory(secured, directory.path, deps.currentUid(), "LaunchAgent log directory");
|
|
660
|
+
if (!sameFilesystemIdentity(directory.stats, secured)
|
|
661
|
+
|| (directory.requireOwnerPrivate && (secured.mode & 0o777) !== 0o700)) {
|
|
662
|
+
throw new Error(`LaunchAgent log directory ${directory.path} changed or remained broadly accessible while secured.`);
|
|
663
|
+
}
|
|
664
|
+
await assertDirectoryIdentity(directory.path, secured, directory.requireOwnerPrivate, deps);
|
|
665
|
+
directory.stats = secured;
|
|
666
|
+
}
|
|
667
|
+
async function secureLogFile(file, deps) {
|
|
668
|
+
if ((file.stats.mode & 0o777) !== 0o600) {
|
|
669
|
+
await file.handle.chmod(0o600);
|
|
670
|
+
file.permissionsRepaired = true;
|
|
671
|
+
}
|
|
672
|
+
const secured = await file.handle.stat();
|
|
673
|
+
assertOwnerRegularFile(secured, file.path, deps.currentUid(), "LaunchAgent log");
|
|
674
|
+
if (!sameFilesystemIdentity(file.stats, secured) || (secured.mode & 0o777) !== 0o600) {
|
|
675
|
+
throw new Error(`LaunchAgent log ${file.path} changed or remained broadly accessible while secured.`);
|
|
676
|
+
}
|
|
677
|
+
const current = await deps.lstat(file.path);
|
|
678
|
+
if (!sameFilesystemIdentity(secured, current))
|
|
679
|
+
throw new Error(`LaunchAgent log ${file.path} changed while secured.`);
|
|
680
|
+
if (file.permissionsRepaired)
|
|
681
|
+
await deps.syncHandle(file.handle);
|
|
682
|
+
file.stats = secured;
|
|
683
|
+
}
|
|
684
|
+
async function buildStreamPlans(activePath, files, policy, deps) {
|
|
685
|
+
const paths = streamPaths(activePath, policy.rotationCount);
|
|
686
|
+
const active = files.get(activePath);
|
|
687
|
+
if (active !== undefined && active.stats.size > policy.maxBytes) {
|
|
688
|
+
const plans = [];
|
|
689
|
+
for (let generation = policy.rotationCount; generation >= 1; generation -= 1) {
|
|
690
|
+
const targetPath = paths[generation];
|
|
691
|
+
const source = files.get(paths[generation - 1]);
|
|
692
|
+
const expectedTarget = files.get(targetPath);
|
|
693
|
+
if (source === undefined && expectedTarget === undefined)
|
|
694
|
+
continue;
|
|
695
|
+
plans.push({
|
|
696
|
+
targetPath,
|
|
697
|
+
...(expectedTarget === undefined ? {} : { expectedTarget }),
|
|
698
|
+
bytes: source === undefined ? Buffer.alloc(0) : await stableTail(source, policy.maxBytes, deps),
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
plans.push({
|
|
702
|
+
targetPath: activePath,
|
|
703
|
+
expectedTarget: active,
|
|
704
|
+
bytes: Buffer.alloc(0),
|
|
705
|
+
});
|
|
706
|
+
return plans;
|
|
707
|
+
}
|
|
708
|
+
const plans = [];
|
|
709
|
+
for (const path of paths) {
|
|
710
|
+
const file = files.get(path);
|
|
711
|
+
if (file === undefined || file.stats.size <= policy.maxBytes)
|
|
712
|
+
continue;
|
|
713
|
+
plans.push({
|
|
714
|
+
targetPath: path,
|
|
715
|
+
expectedTarget: file,
|
|
716
|
+
bytes: await stableTail(file, policy.maxBytes, deps),
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
return plans;
|
|
720
|
+
}
|
|
721
|
+
async function stableTail(file, maxBytes, deps) {
|
|
722
|
+
const bytes = await deps.readTail(file.handle, file.stats.size, maxBytes);
|
|
723
|
+
if (bytes.length > maxBytes) {
|
|
724
|
+
throw new Error(`LaunchAgent log ${file.path} produced an oversized bounded tail.`);
|
|
725
|
+
}
|
|
726
|
+
const after = await file.handle.stat();
|
|
727
|
+
if (!sameFileSnapshot(file.stats, after)) {
|
|
728
|
+
throw new Error(`LaunchAgent log ${file.path} changed while its bounded tail was copied.`);
|
|
729
|
+
}
|
|
730
|
+
const current = await deps.lstat(file.path);
|
|
731
|
+
if (!sameFileSnapshot(after, current)) {
|
|
732
|
+
throw new Error(`LaunchAgent log ${file.path} changed while its bounded tail was copied.`);
|
|
733
|
+
}
|
|
734
|
+
return bytes;
|
|
735
|
+
}
|
|
736
|
+
async function prepareReplacement(plan, directories, deps) {
|
|
737
|
+
const temporaryPath = rotationStagePath(plan.targetPath);
|
|
738
|
+
await assertDirectoryChainIdentity(directories, deps);
|
|
739
|
+
let handle;
|
|
740
|
+
let openedStats;
|
|
741
|
+
try {
|
|
742
|
+
handle = await deps.open(temporaryPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600);
|
|
743
|
+
openedStats = await handle.stat();
|
|
744
|
+
assertOwnerRegularFile(openedStats, temporaryPath, deps.currentUid(), "temporary LaunchAgent log");
|
|
745
|
+
await assertDirectoryChainIdentity(directories, deps);
|
|
746
|
+
await handle.writeFile(plan.bytes);
|
|
747
|
+
await handle.chmod(0o600);
|
|
748
|
+
await deps.syncHandle(handle);
|
|
749
|
+
const writtenStats = await handle.stat();
|
|
750
|
+
assertOwnerRegularFile(writtenStats, temporaryPath, deps.currentUid(), "temporary LaunchAgent log");
|
|
751
|
+
if ((writtenStats.mode & 0o777) !== 0o600 || writtenStats.size !== plan.bytes.length) {
|
|
752
|
+
throw new Error(`Temporary LaunchAgent log ${temporaryPath} does not match its owner-only bounded payload.`);
|
|
753
|
+
}
|
|
754
|
+
await handle.close();
|
|
755
|
+
handle = undefined;
|
|
756
|
+
const temporaryStats = await deps.lstat(temporaryPath);
|
|
757
|
+
assertOwnerRegularFile(temporaryStats, temporaryPath, deps.currentUid(), "temporary LaunchAgent log");
|
|
758
|
+
if (!sameFileSnapshot(writtenStats, temporaryStats) || (temporaryStats.mode & 0o777) !== 0o600) {
|
|
759
|
+
throw new Error(`Temporary LaunchAgent log ${temporaryPath} changed after its bounded payload was synced.`);
|
|
760
|
+
}
|
|
761
|
+
return { ...plan, temporaryPath, temporaryStats };
|
|
762
|
+
}
|
|
763
|
+
catch (error) {
|
|
764
|
+
await handle?.close().catch(() => undefined);
|
|
765
|
+
if (openedStats !== undefined) {
|
|
766
|
+
await removeKnownPath(temporaryPath, openedStats, directories, deps).catch(() => undefined);
|
|
767
|
+
}
|
|
768
|
+
throw error;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
async function assertPreparedReplacement(replacement, deps) {
|
|
772
|
+
const current = await deps.lstat(replacement.temporaryPath);
|
|
773
|
+
assertOwnerRegularFile(current, replacement.temporaryPath, deps.currentUid(), "temporary LaunchAgent log");
|
|
774
|
+
if (!sameFileSnapshot(current, replacement.temporaryStats)) {
|
|
775
|
+
throw new Error(`Temporary LaunchAgent log ${replacement.temporaryPath} changed before commit.`);
|
|
776
|
+
}
|
|
777
|
+
if (await hashValidatedFile(replacement.temporaryPath, replacement.bytes.length, deps) !== sha256(replacement.bytes)) {
|
|
778
|
+
throw new Error(`Temporary LaunchAgent log ${replacement.temporaryPath} payload changed before commit.`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
async function recoverOrCleanRotation(paths, policy, directories, deps) {
|
|
782
|
+
const loaded = await loadRotationJournal(paths, policy, deps);
|
|
783
|
+
if (loaded !== undefined) {
|
|
784
|
+
await removeKnownArtifact(rotationJournalNextPath(paths), directories, deps, MAX_ROTATION_JOURNAL_BYTES);
|
|
785
|
+
await assertNoUnexpectedRotationStages(paths, policy, loaded.journal, deps);
|
|
786
|
+
return await commitLoadedRotation(paths, policy, loaded, directories, deps);
|
|
787
|
+
}
|
|
788
|
+
let changed = await removeKnownArtifact(rotationJournalNextPath(paths), directories, deps, MAX_ROTATION_JOURNAL_BYTES);
|
|
789
|
+
// A stage can exist without a journal only when the process stopped before
|
|
790
|
+
// publishing durable intent. Its deterministic reserved name bounds debris,
|
|
791
|
+
// and no canonical destination can have been changed yet.
|
|
792
|
+
for (const stagePath of rotationStagePaths(paths, policy.rotationCount)) {
|
|
793
|
+
changed = await removeKnownArtifact(stagePath, directories, deps, policy.maxBytes) || changed;
|
|
794
|
+
}
|
|
795
|
+
return { changed, replacedFiles: 0 };
|
|
796
|
+
}
|
|
797
|
+
async function publishRotationJournal(paths, policy, replacements, directories, deps) {
|
|
798
|
+
if (replacements.length < 1)
|
|
799
|
+
throw new Error("LaunchAgent log rotation journal requires a replacement.");
|
|
800
|
+
const journal = {
|
|
801
|
+
version: 1,
|
|
802
|
+
policy: { maxBytes: policy.maxBytes, rotationCount: policy.rotationCount },
|
|
803
|
+
records: replacements.map((replacement) => ({
|
|
804
|
+
target: basename(replacement.targetPath),
|
|
805
|
+
stage: basename(replacement.temporaryPath),
|
|
806
|
+
original: replacement.expectedTarget === undefined
|
|
807
|
+
? null
|
|
808
|
+
: fingerprintFor(replacement.expectedTarget.stats),
|
|
809
|
+
prepared: fingerprintFor(replacement.temporaryStats),
|
|
810
|
+
preparedSha256: sha256(replacement.bytes),
|
|
811
|
+
})),
|
|
812
|
+
};
|
|
813
|
+
validateRotationJournal(journal, paths, policy);
|
|
814
|
+
const data = Buffer.from(`${JSON.stringify(journal)}\n`, "utf8");
|
|
815
|
+
if (data.length > MAX_ROTATION_JOURNAL_BYTES) {
|
|
816
|
+
throw new Error("LaunchAgent log rotation journal exceeds its fixed size bound.");
|
|
817
|
+
}
|
|
818
|
+
await assertDirectoryChainIdentity(directories, deps);
|
|
819
|
+
for (const replacement of replacements)
|
|
820
|
+
await assertPreparedReplacement(replacement, deps);
|
|
821
|
+
const nextPath = rotationJournalNextPath(paths);
|
|
822
|
+
const nextStats = await writeOwnerPrivateArtifact(nextPath, data, directories, deps);
|
|
823
|
+
try {
|
|
824
|
+
await assertDirectoryChainIdentity(directories, deps);
|
|
825
|
+
for (const replacement of replacements) {
|
|
826
|
+
await assertPreparedReplacement(replacement, deps);
|
|
827
|
+
await assertExpectedDestination(replacement, deps);
|
|
828
|
+
}
|
|
829
|
+
await assertPathMissing(rotationJournalPath(paths), deps);
|
|
830
|
+
const currentNext = await deps.lstat(nextPath);
|
|
831
|
+
if (!sameFileSnapshot(currentNext, nextStats)) {
|
|
832
|
+
throw new Error("LaunchAgent log rotation journal changed before publication.");
|
|
833
|
+
}
|
|
834
|
+
await deps.rename(nextPath, rotationJournalPath(paths));
|
|
835
|
+
const published = await deps.lstat(rotationJournalPath(paths));
|
|
836
|
+
if (!matchesCommittedFingerprint(published, fingerprintFor(nextStats))) {
|
|
837
|
+
throw new Error("LaunchAgent log rotation journal changed while it was published.");
|
|
838
|
+
}
|
|
839
|
+
await deps.syncHandle(directories.at(-1).handle);
|
|
840
|
+
}
|
|
841
|
+
catch (error) {
|
|
842
|
+
await removeKnownPath(nextPath, nextStats, directories, deps).catch(() => undefined);
|
|
843
|
+
throw error;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
async function recoverPublishedRotation(paths, policy, directories, deps) {
|
|
847
|
+
const loaded = await loadRotationJournal(paths, policy, deps);
|
|
848
|
+
if (loaded === undefined)
|
|
849
|
+
throw new Error("Published LaunchAgent log rotation journal disappeared.");
|
|
850
|
+
await assertNoUnexpectedRotationStages(paths, policy, loaded.journal, deps);
|
|
851
|
+
return await commitLoadedRotation(paths, policy, loaded, directories, deps);
|
|
852
|
+
}
|
|
853
|
+
async function commitLoadedRotation(paths, policy, loaded, directories, deps) {
|
|
854
|
+
let replacedFiles = 0;
|
|
855
|
+
for (const record of loaded.journal.records) {
|
|
856
|
+
const targetPath = join(paths.logDir, record.target);
|
|
857
|
+
const stagePath = join(paths.logDir, record.stage);
|
|
858
|
+
const stage = await lstatIfPresent(stagePath, deps);
|
|
859
|
+
const target = await lstatIfPresent(targetPath, deps);
|
|
860
|
+
if (stage === undefined) {
|
|
861
|
+
if (target === undefined || !matchesCommittedFingerprint(target, record.prepared)) {
|
|
862
|
+
throw new Error(`LaunchAgent log ${targetPath} does not match its committed rotation record.`);
|
|
863
|
+
}
|
|
864
|
+
if (await hashValidatedFile(targetPath, record.prepared.size, deps) !== record.preparedSha256) {
|
|
865
|
+
throw new Error(`LaunchAgent log ${targetPath} payload does not match its committed rotation record.`);
|
|
866
|
+
}
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
assertOwnerRegularFile(stage, stagePath, deps.currentUid(), "staged LaunchAgent log");
|
|
870
|
+
if ((stage.mode & 0o777) !== 0o600 || stage.size > policy.maxBytes
|
|
871
|
+
|| !matchesFingerprint(stage, record.prepared)) {
|
|
872
|
+
throw new Error(`Staged LaunchAgent log ${stagePath} changed before recovery.`);
|
|
873
|
+
}
|
|
874
|
+
if (await hashValidatedFile(stagePath, record.prepared.size, deps) !== record.preparedSha256) {
|
|
875
|
+
throw new Error(`Staged LaunchAgent log ${stagePath} payload changed before recovery.`);
|
|
876
|
+
}
|
|
877
|
+
if (record.original === null ? target !== undefined : target === undefined || !matchesFingerprint(target, record.original)) {
|
|
878
|
+
throw new Error(`LaunchAgent log ${targetPath} changed before its journaled replacement.`);
|
|
879
|
+
}
|
|
880
|
+
await deps.beforeCommit?.(targetPath);
|
|
881
|
+
await assertDirectoryChainIdentity(directories, deps);
|
|
882
|
+
await assertKnownPath(rotationJournalPath(paths), loaded.stats, deps);
|
|
883
|
+
const currentStage = await deps.lstat(stagePath);
|
|
884
|
+
if (!sameFileSnapshot(currentStage, stage)) {
|
|
885
|
+
throw new Error(`Staged LaunchAgent log ${stagePath} changed before commit.`);
|
|
886
|
+
}
|
|
887
|
+
if (await hashValidatedFile(stagePath, record.prepared.size, deps) !== record.preparedSha256) {
|
|
888
|
+
throw new Error(`Staged LaunchAgent log ${stagePath} payload changed before commit.`);
|
|
889
|
+
}
|
|
890
|
+
const currentTarget = await lstatIfPresent(targetPath, deps);
|
|
891
|
+
if (record.original === null
|
|
892
|
+
? currentTarget !== undefined
|
|
893
|
+
: currentTarget === undefined || !matchesFingerprint(currentTarget, record.original)) {
|
|
894
|
+
throw new Error(`LaunchAgent log ${targetPath} changed before commit.`);
|
|
895
|
+
}
|
|
896
|
+
await deps.rename(stagePath, targetPath);
|
|
897
|
+
const committed = await deps.lstat(targetPath);
|
|
898
|
+
assertOwnerRegularFile(committed, targetPath, deps.currentUid(), "committed LaunchAgent log");
|
|
899
|
+
if ((committed.mode & 0o777) !== 0o600 || committed.size > policy.maxBytes
|
|
900
|
+
|| !matchesCommittedFingerprint(committed, record.prepared)) {
|
|
901
|
+
throw new Error(`Committed LaunchAgent log ${targetPath} violates its journaled owner-only size policy.`);
|
|
902
|
+
}
|
|
903
|
+
if (await hashValidatedFile(targetPath, record.prepared.size, deps) !== record.preparedSha256) {
|
|
904
|
+
throw new Error(`Committed LaunchAgent log ${targetPath} payload differs from its journaled value.`);
|
|
905
|
+
}
|
|
906
|
+
await deps.syncHandle(directories.at(-1).handle);
|
|
907
|
+
replacedFiles += 1;
|
|
908
|
+
}
|
|
909
|
+
await assertDirectoryChainIdentity(directories, deps);
|
|
910
|
+
for (const record of loaded.journal.records) {
|
|
911
|
+
const target = await deps.lstat(join(paths.logDir, record.target));
|
|
912
|
+
if (!matchesCommittedFingerprint(target, record.prepared)) {
|
|
913
|
+
throw new Error("LaunchAgent log rotation did not reach its complete journaled state.");
|
|
914
|
+
}
|
|
915
|
+
if (await hashValidatedFile(join(paths.logDir, record.target), record.prepared.size, deps) !== record.preparedSha256) {
|
|
916
|
+
throw new Error("LaunchAgent log rotation payload did not reach its complete journaled state.");
|
|
917
|
+
}
|
|
918
|
+
await assertPathMissing(join(paths.logDir, record.stage), deps);
|
|
919
|
+
}
|
|
920
|
+
await removeExpectedPath(rotationJournalPath(paths), loaded.stats, directories, deps);
|
|
921
|
+
await assertPathMissing(rotationJournalPath(paths), deps);
|
|
922
|
+
await deps.syncHandle(directories.at(-1).handle);
|
|
923
|
+
return { changed: true, replacedFiles };
|
|
924
|
+
}
|
|
925
|
+
async function loadRotationJournal(paths, policy, deps) {
|
|
926
|
+
const path = rotationJournalPath(paths);
|
|
927
|
+
const initial = await lstatIfPresent(path, deps);
|
|
928
|
+
if (initial === undefined)
|
|
929
|
+
return undefined;
|
|
930
|
+
assertOwnerRegularFile(initial, path, deps.currentUid(), "LaunchAgent log rotation journal");
|
|
931
|
+
if ((initial.mode & 0o777) !== 0o600 || initial.size < 1 || initial.size > MAX_ROTATION_JOURNAL_BYTES) {
|
|
932
|
+
throw new Error("LaunchAgent log rotation journal violates its owner-only size bound.");
|
|
933
|
+
}
|
|
934
|
+
const handle = await deps.open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
935
|
+
try {
|
|
936
|
+
const opened = await handle.stat();
|
|
937
|
+
if (!sameFileSnapshot(initial, opened))
|
|
938
|
+
throw new Error("LaunchAgent log rotation journal changed while opened.");
|
|
939
|
+
const data = await readExactFile(handle, opened.size, "LaunchAgent log rotation journal");
|
|
940
|
+
const after = await handle.stat();
|
|
941
|
+
const current = await deps.lstat(path);
|
|
942
|
+
if (!sameFileSnapshot(opened, after) || !sameFileSnapshot(after, current) || data.length !== after.size) {
|
|
943
|
+
throw new Error("LaunchAgent log rotation journal changed while read.");
|
|
944
|
+
}
|
|
945
|
+
const parsed = JSON.parse(data.toString("utf8"));
|
|
946
|
+
const journal = parseRotationJournal(parsed);
|
|
947
|
+
validateRotationJournal(journal, paths, policy);
|
|
948
|
+
return { journal, stats: after };
|
|
949
|
+
}
|
|
950
|
+
finally {
|
|
951
|
+
await handle.close();
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
async function loadMaintenanceIntent(paths, deps) {
|
|
955
|
+
const path = maintenanceIntentPath(paths);
|
|
956
|
+
const initial = await lstatIfPresent(path, deps);
|
|
957
|
+
if (initial === undefined)
|
|
958
|
+
return undefined;
|
|
959
|
+
assertOwnerRegularFile(initial, path, deps.currentUid(), "LaunchAgent log maintenance intent");
|
|
960
|
+
if ((initial.mode & 0o777) !== 0o600 || initial.size < 1 || initial.size > MAX_MAINTENANCE_INTENT_BYTES) {
|
|
961
|
+
throw new Error("LaunchAgent log maintenance intent violates its owner-only size bound.");
|
|
962
|
+
}
|
|
963
|
+
const handle = await deps.open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
964
|
+
try {
|
|
965
|
+
const opened = await handle.stat();
|
|
966
|
+
if (!sameFileSnapshot(initial, opened))
|
|
967
|
+
throw new Error("LaunchAgent log maintenance intent changed while opened.");
|
|
968
|
+
const data = await readExactFile(handle, opened.size, "LaunchAgent log maintenance intent");
|
|
969
|
+
const after = await handle.stat();
|
|
970
|
+
const current = await deps.lstat(path);
|
|
971
|
+
if (!sameFileSnapshot(opened, after) || !sameFileSnapshot(after, current) || data.length !== after.size) {
|
|
972
|
+
throw new Error("LaunchAgent log maintenance intent changed while read.");
|
|
973
|
+
}
|
|
974
|
+
const parsed = JSON.parse(data.toString("utf8"));
|
|
975
|
+
if (!isPlainRecord(parsed) || parsed.version !== 1
|
|
976
|
+
|| (parsed.phase !== "stopping" && parsed.phase !== "stopped" && parsed.phase !== "restoring")
|
|
977
|
+
|| typeof parsed.label !== "string" || typeof parsed.plistFingerprint !== "string") {
|
|
978
|
+
throw new Error("LaunchAgent log maintenance intent has an invalid schema.");
|
|
979
|
+
}
|
|
980
|
+
const intent = {
|
|
981
|
+
version: 1,
|
|
982
|
+
phase: parsed.phase,
|
|
983
|
+
label: parsed.label,
|
|
984
|
+
plistFingerprint: parsed.plistFingerprint,
|
|
985
|
+
};
|
|
986
|
+
validateMaintenanceIntent(intent);
|
|
987
|
+
return { intent, stats: after };
|
|
988
|
+
}
|
|
989
|
+
finally {
|
|
990
|
+
await handle.close();
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
function validateMaintenanceIntent(intent) {
|
|
994
|
+
if (intent.phase !== "stopping" && intent.phase !== "stopped" && intent.phase !== "restoring") {
|
|
995
|
+
throw new Error("LaunchAgent log maintenance intent has an invalid lifecycle phase.");
|
|
996
|
+
}
|
|
997
|
+
if (!/^com\.mono-agent\.[a-z0-9][a-z0-9-]*$/u.test(intent.label)) {
|
|
998
|
+
throw new Error("LaunchAgent log maintenance intent has an invalid label.");
|
|
999
|
+
}
|
|
1000
|
+
if (!/^[A-Za-z0-9._:-]{1,1024}$/u.test(intent.plistFingerprint)) {
|
|
1001
|
+
throw new Error("LaunchAgent log maintenance intent has an invalid plist fingerprint.");
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
function sameMaintenanceIntent(left, right) {
|
|
1005
|
+
return left.version === right.version
|
|
1006
|
+
&& left.phase === right.phase
|
|
1007
|
+
&& left.label === right.label
|
|
1008
|
+
&& left.plistFingerprint === right.plistFingerprint;
|
|
1009
|
+
}
|
|
1010
|
+
function parseRotationJournal(value) {
|
|
1011
|
+
if (!isPlainRecord(value) || value.version !== 1 || !isPlainRecord(value.policy)
|
|
1012
|
+
|| !Array.isArray(value.records)) {
|
|
1013
|
+
throw new Error("LaunchAgent log rotation journal has an invalid schema.");
|
|
1014
|
+
}
|
|
1015
|
+
const policy = value.policy;
|
|
1016
|
+
const maxBytes = policy.maxBytes;
|
|
1017
|
+
const rotationCount = policy.rotationCount;
|
|
1018
|
+
if (!Number.isSafeInteger(maxBytes) || !Number.isSafeInteger(rotationCount)) {
|
|
1019
|
+
throw new Error("LaunchAgent log rotation journal has an invalid policy.");
|
|
1020
|
+
}
|
|
1021
|
+
return {
|
|
1022
|
+
version: 1,
|
|
1023
|
+
policy: { maxBytes: maxBytes, rotationCount: rotationCount },
|
|
1024
|
+
records: value.records.map((record) => parseRotationJournalRecord(record)),
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
function parseRotationJournalRecord(value) {
|
|
1028
|
+
if (!isPlainRecord(value) || typeof value.target !== "string" || typeof value.stage !== "string"
|
|
1029
|
+
|| typeof value.preparedSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.preparedSha256)) {
|
|
1030
|
+
throw new Error("LaunchAgent log rotation journal has an invalid record.");
|
|
1031
|
+
}
|
|
1032
|
+
return {
|
|
1033
|
+
target: value.target,
|
|
1034
|
+
stage: value.stage,
|
|
1035
|
+
original: value.original === null ? null : parseRotationFingerprint(value.original),
|
|
1036
|
+
prepared: parseRotationFingerprint(value.prepared),
|
|
1037
|
+
preparedSha256: value.preparedSha256,
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
function parseRotationFingerprint(value) {
|
|
1041
|
+
if (!isPlainRecord(value) || typeof value.dev !== "string" || typeof value.ino !== "string") {
|
|
1042
|
+
throw new Error("LaunchAgent log rotation journal has an invalid fingerprint.");
|
|
1043
|
+
}
|
|
1044
|
+
for (const key of ["uid", "nlink", "mode", "size"]) {
|
|
1045
|
+
if (!Number.isSafeInteger(value[key]) || value[key] < 0) {
|
|
1046
|
+
throw new Error("LaunchAgent log rotation journal has an invalid fingerprint.");
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
for (const key of ["mtimeMs", "ctimeMs"]) {
|
|
1050
|
+
if (typeof value[key] !== "number" || !Number.isFinite(value[key])) {
|
|
1051
|
+
throw new Error("LaunchAgent log rotation journal has an invalid fingerprint.");
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
return {
|
|
1055
|
+
dev: value.dev,
|
|
1056
|
+
ino: value.ino,
|
|
1057
|
+
uid: value.uid,
|
|
1058
|
+
nlink: value.nlink,
|
|
1059
|
+
mode: value.mode,
|
|
1060
|
+
size: value.size,
|
|
1061
|
+
mtimeMs: value.mtimeMs,
|
|
1062
|
+
ctimeMs: value.ctimeMs,
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
function validateRotationJournal(journal, paths, policy) {
|
|
1066
|
+
if (journal.policy.maxBytes !== policy.maxBytes || journal.policy.rotationCount !== policy.rotationCount) {
|
|
1067
|
+
throw new Error("LaunchAgent log rotation journal policy does not match the fixed active policy.");
|
|
1068
|
+
}
|
|
1069
|
+
const allowedTargets = new Set([
|
|
1070
|
+
...streamPaths(paths.stdoutPath, policy.rotationCount),
|
|
1071
|
+
...streamPaths(paths.stderrPath, policy.rotationCount),
|
|
1072
|
+
].map((path) => basename(path)));
|
|
1073
|
+
if (journal.records.length < 1 || journal.records.length > allowedTargets.size) {
|
|
1074
|
+
throw new Error("LaunchAgent log rotation journal has an invalid record count.");
|
|
1075
|
+
}
|
|
1076
|
+
const targets = new Set();
|
|
1077
|
+
const stages = new Set();
|
|
1078
|
+
for (const record of journal.records) {
|
|
1079
|
+
if (!allowedTargets.has(record.target) || basename(record.target) !== record.target) {
|
|
1080
|
+
throw new Error("LaunchAgent log rotation journal names a non-canonical target.");
|
|
1081
|
+
}
|
|
1082
|
+
const expectedStage = basename(rotationStagePath(join(paths.logDir, record.target)));
|
|
1083
|
+
if (record.stage !== expectedStage || basename(record.stage) !== record.stage) {
|
|
1084
|
+
throw new Error("LaunchAgent log rotation journal names a non-canonical stage.");
|
|
1085
|
+
}
|
|
1086
|
+
if (targets.has(record.target) || stages.has(record.stage)) {
|
|
1087
|
+
throw new Error("LaunchAgent log rotation journal contains duplicate records.");
|
|
1088
|
+
}
|
|
1089
|
+
if (record.prepared.size > policy.maxBytes || (record.prepared.mode & 0o777) !== 0o600
|
|
1090
|
+
|| record.prepared.nlink !== 1) {
|
|
1091
|
+
throw new Error("LaunchAgent log rotation journal contains an unsafe prepared file.");
|
|
1092
|
+
}
|
|
1093
|
+
targets.add(record.target);
|
|
1094
|
+
stages.add(record.stage);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
async function assertNoUnexpectedRotationStages(paths, policy, journal, deps) {
|
|
1098
|
+
const expected = new Set(journal.records.map((record) => join(paths.logDir, record.stage)));
|
|
1099
|
+
for (const stagePath of rotationStagePaths(paths, policy.rotationCount)) {
|
|
1100
|
+
if (!expected.has(stagePath) && await lstatIfPresent(stagePath, deps) !== undefined) {
|
|
1101
|
+
throw new Error(`Unexpected staged LaunchAgent log ${stagePath} exists beside a pending transaction.`);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
async function writeOwnerPrivateArtifact(path, data, directories, deps) {
|
|
1106
|
+
await assertDirectoryChainIdentity(directories, deps);
|
|
1107
|
+
await assertPathMissing(path, deps);
|
|
1108
|
+
let handle;
|
|
1109
|
+
let identity;
|
|
1110
|
+
try {
|
|
1111
|
+
handle = await deps.open(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600);
|
|
1112
|
+
identity = await handle.stat();
|
|
1113
|
+
assertOwnerRegularFile(identity, path, deps.currentUid(), "temporary LaunchAgent log artifact");
|
|
1114
|
+
await assertDirectoryChainIdentity(directories, deps);
|
|
1115
|
+
await handle.writeFile(data);
|
|
1116
|
+
await handle.chmod(0o600);
|
|
1117
|
+
await deps.syncHandle(handle);
|
|
1118
|
+
const written = await handle.stat();
|
|
1119
|
+
if ((written.mode & 0o777) !== 0o600 || written.size !== data.length) {
|
|
1120
|
+
throw new Error("Temporary LaunchAgent log artifact violates its owner-only payload.");
|
|
1121
|
+
}
|
|
1122
|
+
await handle.close();
|
|
1123
|
+
handle = undefined;
|
|
1124
|
+
const current = await deps.lstat(path);
|
|
1125
|
+
if (!sameFileSnapshot(written, current)) {
|
|
1126
|
+
throw new Error("Temporary LaunchAgent log artifact changed after it was synced.");
|
|
1127
|
+
}
|
|
1128
|
+
return current;
|
|
1129
|
+
}
|
|
1130
|
+
catch (error) {
|
|
1131
|
+
await handle?.close().catch(() => undefined);
|
|
1132
|
+
if (identity !== undefined)
|
|
1133
|
+
await removeKnownPath(path, identity, directories, deps).catch(() => undefined);
|
|
1134
|
+
throw error;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
async function removeKnownArtifact(path, directories, deps, maxBytes = MAX_ROTATION_JOURNAL_BYTES) {
|
|
1138
|
+
const current = await lstatIfPresent(path, deps);
|
|
1139
|
+
if (current === undefined)
|
|
1140
|
+
return false;
|
|
1141
|
+
assertOwnerRegularFile(current, path, deps.currentUid(), "LaunchAgent log transaction artifact");
|
|
1142
|
+
if ((current.mode & 0o777) !== 0o600 || current.size > maxBytes) {
|
|
1143
|
+
throw new Error(`LaunchAgent log transaction artifact ${path} violates its owner-only size bound.`);
|
|
1144
|
+
}
|
|
1145
|
+
await assertDirectoryChainIdentity(directories, deps);
|
|
1146
|
+
const confirmed = await deps.lstat(path);
|
|
1147
|
+
if (!sameFileSnapshot(current, confirmed)) {
|
|
1148
|
+
throw new Error(`LaunchAgent log transaction artifact ${path} changed before cleanup.`);
|
|
1149
|
+
}
|
|
1150
|
+
await deps.rm(path);
|
|
1151
|
+
return true;
|
|
1152
|
+
}
|
|
1153
|
+
async function assertKnownPath(path, expected, deps) {
|
|
1154
|
+
const current = await deps.lstat(path);
|
|
1155
|
+
if (!sameFileSnapshot(current, expected))
|
|
1156
|
+
throw new Error(`LaunchAgent log transaction artifact ${path} changed.`);
|
|
1157
|
+
}
|
|
1158
|
+
async function assertPathMissing(path, deps) {
|
|
1159
|
+
if (await lstatIfPresent(path, deps) !== undefined) {
|
|
1160
|
+
throw new Error(`LaunchAgent log transaction artifact ${path} already exists.`);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
async function lstatIfPresent(path, deps) {
|
|
1164
|
+
try {
|
|
1165
|
+
return await deps.lstat(path);
|
|
1166
|
+
}
|
|
1167
|
+
catch (error) {
|
|
1168
|
+
if (isErrno(error, "ENOENT"))
|
|
1169
|
+
return undefined;
|
|
1170
|
+
throw error;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
async function hashValidatedFile(path, expectedSize, deps) {
|
|
1174
|
+
const initial = await deps.lstat(path);
|
|
1175
|
+
assertOwnerRegularFile(initial, path, deps.currentUid(), "LaunchAgent log transaction file");
|
|
1176
|
+
if (initial.size !== expectedSize) {
|
|
1177
|
+
throw new Error(`LaunchAgent log transaction file ${path} changed size before hashing.`);
|
|
1178
|
+
}
|
|
1179
|
+
const handle = await deps.open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
|
|
1180
|
+
try {
|
|
1181
|
+
const opened = await handle.stat();
|
|
1182
|
+
if (!sameFileSnapshot(initial, opened)) {
|
|
1183
|
+
throw new Error(`LaunchAgent log transaction file ${path} changed while opened.`);
|
|
1184
|
+
}
|
|
1185
|
+
const data = await readExactFile(handle, expectedSize, "LaunchAgent log transaction file");
|
|
1186
|
+
const after = await handle.stat();
|
|
1187
|
+
const current = await deps.lstat(path);
|
|
1188
|
+
if (!sameFileSnapshot(opened, after) || !sameFileSnapshot(after, current) || data.length !== after.size) {
|
|
1189
|
+
throw new Error(`LaunchAgent log transaction file ${path} changed while hashed.`);
|
|
1190
|
+
}
|
|
1191
|
+
return sha256(data);
|
|
1192
|
+
}
|
|
1193
|
+
finally {
|
|
1194
|
+
await handle.close();
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
function sha256(data) {
|
|
1198
|
+
return createHash("sha256").update(data).digest("hex");
|
|
1199
|
+
}
|
|
1200
|
+
function fingerprintFor(stats) {
|
|
1201
|
+
return {
|
|
1202
|
+
dev: String(stats.dev),
|
|
1203
|
+
ino: String(stats.ino),
|
|
1204
|
+
uid: stats.uid,
|
|
1205
|
+
nlink: stats.nlink,
|
|
1206
|
+
mode: stats.mode,
|
|
1207
|
+
size: stats.size,
|
|
1208
|
+
mtimeMs: stats.mtimeMs,
|
|
1209
|
+
ctimeMs: stats.ctimeMs,
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
function matchesFingerprint(stats, fingerprint) {
|
|
1213
|
+
return String(stats.dev) === fingerprint.dev
|
|
1214
|
+
&& String(stats.ino) === fingerprint.ino
|
|
1215
|
+
&& stats.uid === fingerprint.uid
|
|
1216
|
+
&& stats.nlink === fingerprint.nlink
|
|
1217
|
+
&& stats.mode === fingerprint.mode
|
|
1218
|
+
&& stats.size === fingerprint.size
|
|
1219
|
+
&& stats.mtimeMs === fingerprint.mtimeMs
|
|
1220
|
+
&& stats.ctimeMs === fingerprint.ctimeMs;
|
|
1221
|
+
}
|
|
1222
|
+
function matchesCommittedFingerprint(stats, fingerprint) {
|
|
1223
|
+
// An atomic rename may update ctime while preserving the staged inode and
|
|
1224
|
+
// payload. Identity, ownership, links, mode, size, and content mtime remain
|
|
1225
|
+
// the durable proof that this is the published stage.
|
|
1226
|
+
return String(stats.dev) === fingerprint.dev
|
|
1227
|
+
&& String(stats.ino) === fingerprint.ino
|
|
1228
|
+
&& stats.uid === fingerprint.uid
|
|
1229
|
+
&& stats.nlink === fingerprint.nlink
|
|
1230
|
+
&& stats.mode === fingerprint.mode
|
|
1231
|
+
&& stats.size === fingerprint.size
|
|
1232
|
+
&& stats.mtimeMs === fingerprint.mtimeMs;
|
|
1233
|
+
}
|
|
1234
|
+
function rotationJournalPath(paths) {
|
|
1235
|
+
return join(paths.logDir, `${TRANSACTION_ARTIFACT_PREFIX}${transactionArtifactKey(paths)}${ROTATION_JOURNAL_SUFFIX}`);
|
|
1236
|
+
}
|
|
1237
|
+
function maintenanceIntentPath(paths) {
|
|
1238
|
+
return join(paths.logDir, `${TRANSACTION_ARTIFACT_PREFIX}${transactionArtifactKey(paths)}${MAINTENANCE_INTENT_SUFFIX}`);
|
|
1239
|
+
}
|
|
1240
|
+
function maintenanceIntentNextPath(paths) {
|
|
1241
|
+
return join(paths.logDir, `${TRANSACTION_ARTIFACT_PREFIX}${transactionArtifactKey(paths)}${MAINTENANCE_INTENT_NEXT_SUFFIX}`);
|
|
1242
|
+
}
|
|
1243
|
+
function rotationJournalNextPath(paths) {
|
|
1244
|
+
return join(paths.logDir, `${TRANSACTION_ARTIFACT_PREFIX}${transactionArtifactKey(paths)}${ROTATION_JOURNAL_NEXT_SUFFIX}`);
|
|
1245
|
+
}
|
|
1246
|
+
function transactionArtifactKey(paths) {
|
|
1247
|
+
return createHash("sha256")
|
|
1248
|
+
.update(paths.stdoutPath)
|
|
1249
|
+
.update("\0")
|
|
1250
|
+
.update(paths.stderrPath)
|
|
1251
|
+
.digest("hex")
|
|
1252
|
+
.slice(0, 24);
|
|
1253
|
+
}
|
|
1254
|
+
function rotationStagePath(targetPath) {
|
|
1255
|
+
return join(dirname(targetPath), `.${basename(targetPath)}.rotate.stage`);
|
|
1256
|
+
}
|
|
1257
|
+
function rotationStagePaths(paths, rotationCount) {
|
|
1258
|
+
return [
|
|
1259
|
+
...streamPaths(paths.stdoutPath, rotationCount),
|
|
1260
|
+
...streamPaths(paths.stderrPath, rotationCount),
|
|
1261
|
+
].map((path) => rotationStagePath(path));
|
|
1262
|
+
}
|
|
1263
|
+
function isPlainRecord(value) {
|
|
1264
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1265
|
+
}
|
|
1266
|
+
async function assertExpectedDestination(replacement, deps) {
|
|
1267
|
+
let current;
|
|
1268
|
+
try {
|
|
1269
|
+
current = await deps.lstat(replacement.targetPath);
|
|
1270
|
+
}
|
|
1271
|
+
catch (error) {
|
|
1272
|
+
if (!isErrno(error, "ENOENT"))
|
|
1273
|
+
throw error;
|
|
1274
|
+
}
|
|
1275
|
+
if (replacement.expectedTarget === undefined) {
|
|
1276
|
+
if (current !== undefined)
|
|
1277
|
+
throw new Error(`LaunchAgent log ${replacement.targetPath} appeared before commit.`);
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
if (current === undefined || !sameFileSnapshot(current, replacement.expectedTarget.stats)) {
|
|
1281
|
+
throw new Error(`LaunchAgent log ${replacement.targetPath} changed before commit.`);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
async function assertDirectoryIdentity(path, expected, requireOwnerPrivate, deps) {
|
|
1285
|
+
const current = await deps.lstat(path);
|
|
1286
|
+
assertOwnerDirectory(current, path, deps.currentUid(), "LaunchAgent log directory");
|
|
1287
|
+
if (!sameFilesystemIdentity(expected, current))
|
|
1288
|
+
throw new Error(`LaunchAgent log directory ${path} changed.`);
|
|
1289
|
+
if (requireOwnerPrivate && (current.mode & 0o777) !== 0o700) {
|
|
1290
|
+
throw new Error(`LaunchAgent log directory ${path} is not owner-private.`);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
async function assertDirectoryChainIdentity(directories, deps, enforcePrivateModes = true) {
|
|
1294
|
+
for (const directory of directories) {
|
|
1295
|
+
await assertDirectoryIdentity(directory.path, directory.stats, enforcePrivateModes && directory.requireOwnerPrivate, deps);
|
|
1296
|
+
}
|
|
1297
|
+
const logDir = directories.at(-1)?.path;
|
|
1298
|
+
if (logDir !== undefined)
|
|
1299
|
+
await assertCanonicalDirectoryRealpath(logDir, deps);
|
|
1300
|
+
}
|
|
1301
|
+
async function assertCanonicalDirectoryRealpath(logDir, deps) {
|
|
1302
|
+
const canonical = await deps.realpath(logDir);
|
|
1303
|
+
if (canonical !== logDir) {
|
|
1304
|
+
throw new Error(`LaunchAgent log directory ${logDir} traverses a symbolic-link or non-canonical parent.`);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
async function assertOpenedInventory(paths, rotationCount, opened, deps) {
|
|
1308
|
+
for (const path of [
|
|
1309
|
+
...streamPaths(paths.stdoutPath, rotationCount),
|
|
1310
|
+
...streamPaths(paths.stderrPath, rotationCount),
|
|
1311
|
+
]) {
|
|
1312
|
+
const expected = opened.get(path);
|
|
1313
|
+
let current;
|
|
1314
|
+
try {
|
|
1315
|
+
current = await deps.lstat(path);
|
|
1316
|
+
}
|
|
1317
|
+
catch (error) {
|
|
1318
|
+
if (!isErrno(error, "ENOENT"))
|
|
1319
|
+
throw error;
|
|
1320
|
+
}
|
|
1321
|
+
if (expected === undefined) {
|
|
1322
|
+
if (current !== undefined)
|
|
1323
|
+
throw new Error(`LaunchAgent log ${path} appeared after inventory.`);
|
|
1324
|
+
}
|
|
1325
|
+
else if (current === undefined || !sameFileSnapshot(current, expected)) {
|
|
1326
|
+
throw new Error(`LaunchAgent log ${path} changed after inventory.`);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
async function removeKnownTemporary(replacement, directories, deps) {
|
|
1331
|
+
await removeKnownPath(replacement.temporaryPath, replacement.temporaryStats, directories, deps);
|
|
1332
|
+
}
|
|
1333
|
+
async function removeKnownPath(path, expected, directories, deps) {
|
|
1334
|
+
await assertDirectoryChainIdentity(directories, deps);
|
|
1335
|
+
let current;
|
|
1336
|
+
try {
|
|
1337
|
+
current = await deps.lstat(path);
|
|
1338
|
+
}
|
|
1339
|
+
catch (error) {
|
|
1340
|
+
if (isErrno(error, "ENOENT"))
|
|
1341
|
+
return;
|
|
1342
|
+
throw error;
|
|
1343
|
+
}
|
|
1344
|
+
if (!sameFilesystemIdentity(current, expected))
|
|
1345
|
+
return;
|
|
1346
|
+
await deps.rm(path);
|
|
1347
|
+
}
|
|
1348
|
+
async function removeExpectedPath(path, expected, directories, deps) {
|
|
1349
|
+
await assertDirectoryChainIdentity(directories, deps);
|
|
1350
|
+
const current = await deps.lstat(path);
|
|
1351
|
+
if (!sameFileSnapshot(current, expected)) {
|
|
1352
|
+
throw new Error(`LaunchAgent log transaction artifact ${path} changed before required cleanup.`);
|
|
1353
|
+
}
|
|
1354
|
+
await deps.rm(path);
|
|
1355
|
+
}
|
|
1356
|
+
async function readFileTail(handle, size, maxBytes) {
|
|
1357
|
+
if (!Number.isSafeInteger(size) || size < 0)
|
|
1358
|
+
throw new Error("LaunchAgent log size is not a safe integer.");
|
|
1359
|
+
const length = Math.min(size, maxBytes);
|
|
1360
|
+
const bytes = Buffer.alloc(length);
|
|
1361
|
+
let offset = 0;
|
|
1362
|
+
while (offset < length) {
|
|
1363
|
+
const result = await handle.read(bytes, offset, length - offset, size - length + offset);
|
|
1364
|
+
if (result.bytesRead === 0)
|
|
1365
|
+
throw new Error("LaunchAgent log ended while its bounded tail was copied.");
|
|
1366
|
+
offset += result.bytesRead;
|
|
1367
|
+
}
|
|
1368
|
+
return bytes;
|
|
1369
|
+
}
|
|
1370
|
+
async function readExactFile(handle, size, description) {
|
|
1371
|
+
if (!Number.isSafeInteger(size) || size < 0)
|
|
1372
|
+
throw new Error(`${description} has an unsafe size.`);
|
|
1373
|
+
const bytes = Buffer.alloc(size);
|
|
1374
|
+
let offset = 0;
|
|
1375
|
+
while (offset < size) {
|
|
1376
|
+
const result = await handle.read(bytes, offset, size - offset, offset);
|
|
1377
|
+
if (result.bytesRead === 0)
|
|
1378
|
+
throw new Error(`${description} ended while its bounded contents were read.`);
|
|
1379
|
+
offset += result.bytesRead;
|
|
1380
|
+
}
|
|
1381
|
+
return bytes;
|
|
1382
|
+
}
|
|
1383
|
+
function streamPaths(activePath, rotationCount) {
|
|
1384
|
+
return [activePath, ...Array.from({ length: rotationCount }, (_, index) => `${activePath}.${index + 1}`)];
|
|
1385
|
+
}
|
|
1386
|
+
function streamIssues(label, stream) {
|
|
1387
|
+
return stream.files.flatMap((file) => file.issue === undefined
|
|
1388
|
+
? []
|
|
1389
|
+
: [`${label}${file.generation === 0 ? "" : `.${file.generation}`}: ${file.issue}`]);
|
|
1390
|
+
}
|
|
1391
|
+
function emptyStream(byteAccountingComplete = true) {
|
|
1392
|
+
return { activeBytes: 0, retainedBytes: 0, totalBytes: 0, byteAccountingComplete, files: [] };
|
|
1393
|
+
}
|
|
1394
|
+
function emptyInspection(byteAccountingComplete = true) {
|
|
1395
|
+
return {
|
|
1396
|
+
stdout: emptyStream(byteAccountingComplete),
|
|
1397
|
+
stderr: emptyStream(byteAccountingComplete),
|
|
1398
|
+
present: false,
|
|
1399
|
+
canMaintain: true,
|
|
1400
|
+
needsMaintenance: false,
|
|
1401
|
+
pendingTransaction: false,
|
|
1402
|
+
pendingMaintenance: false,
|
|
1403
|
+
issues: [],
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
function assertCanonicalLogPaths(paths) {
|
|
1407
|
+
for (const [label, path] of [
|
|
1408
|
+
["log directory", paths.logDir],
|
|
1409
|
+
["stdout", paths.stdoutPath],
|
|
1410
|
+
["stderr", paths.stderrPath],
|
|
1411
|
+
]) {
|
|
1412
|
+
if (path !== resolve(path))
|
|
1413
|
+
throw new Error(`LaunchAgent ${label} path must be normalized and absolute.`);
|
|
1414
|
+
}
|
|
1415
|
+
const logDir = resolve(paths.logDir);
|
|
1416
|
+
canonicalDirectoryChain(logDir);
|
|
1417
|
+
if (resolve(paths.stdoutPath) === resolve(paths.stderrPath)) {
|
|
1418
|
+
throw new Error("LaunchAgent stdout and stderr logs must be distinct canonical files.");
|
|
1419
|
+
}
|
|
1420
|
+
for (const [label, path] of [["stdout", paths.stdoutPath], ["stderr", paths.stderrPath]]) {
|
|
1421
|
+
if (dirname(resolve(path)) !== logDir) {
|
|
1422
|
+
throw new Error(`LaunchAgent ${label} log must be a direct child of its canonical log directory.`);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
const canonicalTargets = [
|
|
1426
|
+
...streamPaths(paths.stdoutPath, 32),
|
|
1427
|
+
...streamPaths(paths.stderrPath, 32),
|
|
1428
|
+
];
|
|
1429
|
+
if (new Set(canonicalTargets).size !== canonicalTargets.length) {
|
|
1430
|
+
throw new Error("LaunchAgent stdout and stderr rotation generations must not overlap.");
|
|
1431
|
+
}
|
|
1432
|
+
if (canonicalTargets.some((path) => basename(path).startsWith(TRANSACTION_ARTIFACT_PREFIX)
|
|
1433
|
+
|| basename(path).endsWith(".rotate.stage"))) {
|
|
1434
|
+
throw new Error("LaunchAgent log filenames must not collide with reserved maintenance artifacts.");
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
function canonicalDirectoryChain(logDir) {
|
|
1438
|
+
const stateDir = dirname(logDir);
|
|
1439
|
+
const homeDir = dirname(stateDir);
|
|
1440
|
+
if (basename(logDir) !== "logs" || basename(stateDir) !== ".mono-agent" || homeDir === stateDir) {
|
|
1441
|
+
throw new Error("LaunchAgent log directory must be the canonical <account-home>/.mono-agent/logs path.");
|
|
1442
|
+
}
|
|
1443
|
+
return [
|
|
1444
|
+
{ path: homeDir, requireOwnerPrivate: false },
|
|
1445
|
+
{ path: stateDir, requireOwnerPrivate: true },
|
|
1446
|
+
{ path: logDir, requireOwnerPrivate: true },
|
|
1447
|
+
];
|
|
1448
|
+
}
|
|
1449
|
+
function assertPolicy(policy) {
|
|
1450
|
+
if (!Number.isSafeInteger(policy.maxBytes) || policy.maxBytes < 1) {
|
|
1451
|
+
throw new Error("LaunchAgent log maxBytes must be a positive safe integer.");
|
|
1452
|
+
}
|
|
1453
|
+
if (!Number.isSafeInteger(policy.rotationCount) || policy.rotationCount < 1 || policy.rotationCount > 32) {
|
|
1454
|
+
throw new Error("LaunchAgent log rotationCount must be an integer between 1 and 32.");
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
function assertOwnerDirectory(details, path, uid, description) {
|
|
1458
|
+
if (!details.isDirectory() || details.isSymbolicLink())
|
|
1459
|
+
throw new Error(`${description} ${path} must be a real directory.`);
|
|
1460
|
+
assertOwner(details, path, uid, description);
|
|
1461
|
+
}
|
|
1462
|
+
function assertOwnerRegularFile(details, path, uid, description) {
|
|
1463
|
+
if (!details.isFile() || details.isSymbolicLink())
|
|
1464
|
+
throw new Error(`${description} ${path} must be a regular non-symbolic-link file.`);
|
|
1465
|
+
if (details.nlink !== 1)
|
|
1466
|
+
throw new Error(`${description} ${path} must have exactly one filesystem link.`);
|
|
1467
|
+
if (!Number.isSafeInteger(details.size) || details.size < 0)
|
|
1468
|
+
throw new Error(`${description} ${path} has an unsafe size.`);
|
|
1469
|
+
assertOwner(details, path, uid, description);
|
|
1470
|
+
}
|
|
1471
|
+
function assertOwner(details, path, uid, description) {
|
|
1472
|
+
if (uid !== undefined && details.uid !== uid)
|
|
1473
|
+
throw new Error(`${description} ${path} is not owned by the current user.`);
|
|
1474
|
+
}
|
|
1475
|
+
function sameFilesystemIdentity(left, right) {
|
|
1476
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
1477
|
+
}
|
|
1478
|
+
function sameFileSnapshot(left, right) {
|
|
1479
|
+
return sameFilesystemIdentity(left, right)
|
|
1480
|
+
&& left.uid === right.uid
|
|
1481
|
+
&& left.nlink === right.nlink
|
|
1482
|
+
&& left.mode === right.mode
|
|
1483
|
+
&& left.size === right.size
|
|
1484
|
+
&& left.mtimeMs === right.mtimeMs
|
|
1485
|
+
&& left.ctimeMs === right.ctimeMs;
|
|
1486
|
+
}
|
|
1487
|
+
function safeReason(error, path) {
|
|
1488
|
+
return (error instanceof Error ? error.message : String(error)).replaceAll(path, "<path>");
|
|
1489
|
+
}
|
|
1490
|
+
function errnoDescription(error) {
|
|
1491
|
+
const code = typeof error === "object" && error !== null && typeof error.code === "string"
|
|
1492
|
+
? error.code
|
|
1493
|
+
: "unreadable";
|
|
1494
|
+
return code;
|
|
1495
|
+
}
|
|
1496
|
+
function isErrno(error, code) {
|
|
1497
|
+
return typeof error === "object" && error !== null && error.code === code;
|
|
1498
|
+
}
|
|
1499
|
+
//# sourceMappingURL=launchd-logs.js.map
|