akm-cli 0.9.1 → 0.9.2-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (350) hide show
  1. package/CHANGELOG.md +103 -28
  2. package/README.md +3 -1
  3. package/SECURITY.md +1 -1
  4. package/STABILITY.md +1 -1
  5. package/dist/akm +2 -2
  6. package/dist/akm-migrate +2 -2
  7. package/dist/assets/hints/cli-hints-full.md +14 -9
  8. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -1
  9. package/dist/assets/improve-strategies/reflect-distill.json +1 -1
  10. package/dist/assets/models.json +35 -0
  11. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +3 -4
  12. package/dist/assets/stash-skeleton/facts/conventions/organization.md +1 -3
  13. package/dist/assets/tasks/core/extract.yml +6 -5
  14. package/dist/assets/tasks/core/improve.yml +6 -5
  15. package/dist/assets/tasks/core/index-refresh.yml +6 -5
  16. package/dist/assets/tasks/core/sync.yml +6 -5
  17. package/dist/assets/tasks/core/version-check.yml +6 -5
  18. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +6 -5
  19. package/dist/assets/tasks/improve/akm-improve-catchup.yml +6 -5
  20. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +6 -5
  21. package/dist/assets/tasks/improve/akm-improve-frequent.yml +6 -5
  22. package/dist/assets/tasks/improve/akm-improve-nightly.yml +6 -5
  23. package/dist/cli/confirm.js +2 -2
  24. package/dist/cli/parse-args.js +3 -24
  25. package/dist/cli/retired-commands.js +1 -1
  26. package/dist/cli/shared.js +2 -2
  27. package/dist/cli.js +11 -9
  28. package/dist/commands/agent/agent-dispatch.js +55 -89
  29. package/dist/commands/agent/contribute-cli.js +12 -45
  30. package/dist/commands/command/builtin-action.js +32 -0
  31. package/dist/commands/command/command-cli.js +99 -0
  32. package/dist/commands/command/command-execution.js +308 -0
  33. package/dist/commands/command/execution-source-loader.js +176 -0
  34. package/dist/commands/command/portable-template.js +60 -0
  35. package/dist/commands/config-cli.js +10 -4
  36. package/dist/commands/env/env.js +4 -2
  37. package/dist/commands/feedback-cli.js +1 -1
  38. package/dist/commands/health/checks.js +241 -29
  39. package/dist/commands/health/html-report.js +0 -14
  40. package/dist/commands/health/report-view-model.js +0 -1
  41. package/dist/commands/health/surfaces.js +6 -7
  42. package/dist/commands/health/types.js +0 -2
  43. package/dist/commands/health.js +63 -18
  44. package/dist/commands/improve/collapse-detector.js +5 -6
  45. package/dist/commands/improve/consolidate.js +251 -214
  46. package/dist/commands/improve/distill/promote-memory.js +71 -34
  47. package/dist/commands/improve/distill/quality-gate.js +17 -5
  48. package/dist/commands/improve/distill.js +232 -155
  49. package/dist/commands/improve/eligibility.js +112 -79
  50. package/dist/commands/improve/execution.js +57 -0
  51. package/dist/commands/improve/extract-cli.js +5 -5
  52. package/dist/commands/improve/extract-prompt.js +64 -22
  53. package/dist/commands/improve/extract.js +608 -360
  54. package/dist/commands/improve/improve-strategies.js +43 -14
  55. package/dist/commands/improve/improve.js +249 -29
  56. package/dist/commands/improve/loop-stages.js +11 -17
  57. package/dist/commands/improve/memory/memory-contradiction-detect.js +90 -66
  58. package/dist/commands/improve/outcome-loop.js +22 -38
  59. package/dist/commands/improve/planner.js +134 -0
  60. package/dist/commands/improve/preparation.js +730 -409
  61. package/dist/commands/improve/reflect.js +386 -223
  62. package/dist/commands/improve/run-context.js +3 -4
  63. package/dist/commands/improve/salience.js +6 -58
  64. package/dist/commands/improve/session-asset.js +12 -12
  65. package/dist/commands/lint/index.js +101 -29
  66. package/dist/commands/migrate-cli.js +11 -69
  67. package/dist/commands/migration-tool.js +6 -9
  68. package/dist/commands/models-cli.js +27 -0
  69. package/dist/commands/proposal/drain.js +258 -186
  70. package/dist/commands/proposal/proposal-cli.js +32 -10
  71. package/dist/commands/proposal/proposal.js +2 -5
  72. package/dist/commands/proposal/propose.js +192 -172
  73. package/dist/commands/proposal/repository.js +54 -91
  74. package/dist/commands/proposal/validators/proposal-validators.js +9 -7
  75. package/dist/commands/read/curate.js +53 -22
  76. package/dist/commands/read/registry-search.js +25 -9
  77. package/dist/commands/read/remember-cli.js +14 -2
  78. package/dist/commands/read/search.js +10 -4
  79. package/dist/commands/read/show.js +139 -153
  80. package/dist/commands/registry-cli.js +16 -7
  81. package/dist/commands/remember.js +33 -18
  82. package/dist/commands/sources/add-cli.js +19 -178
  83. package/dist/commands/sources/bundle-cli.js +15 -3
  84. package/dist/commands/sources/dangerous-env-audit.js +135 -0
  85. package/dist/commands/sources/info.js +2 -1
  86. package/dist/commands/sources/installed-stashes.js +901 -177
  87. package/dist/commands/sources/schema-repair.js +174 -95
  88. package/dist/commands/sources/self-update.js +30 -74
  89. package/dist/commands/sources/source-add.js +3 -5
  90. package/dist/commands/sources/sources-cli.js +2 -15
  91. package/dist/commands/sources/update-transaction.js +220 -0
  92. package/dist/commands/tasks/tasks-cli.js +3 -3
  93. package/dist/commands/tasks/tasks.js +736 -317
  94. package/dist/commands/workflow-cli.js +2 -2
  95. package/dist/core/adapter/adapters/agent-skills-adapter.js +3 -0
  96. package/dist/core/adapter/adapters/akm-adapter.js +85 -35
  97. package/dist/core/adapter/adapters/akm-lint.js +54 -39
  98. package/dist/core/adapter/adapters/akm-metadata.js +45 -45
  99. package/dist/core/adapter/adapters/akm-task-adapter.js +32 -49
  100. package/dist/core/adapter/adapters/akm-workflow-adapter.js +38 -23
  101. package/dist/core/adapter/adapters/dotenv-adapter.js +30 -1
  102. package/dist/core/adapter/adapters/generic-files-adapter.js +11 -0
  103. package/dist/core/adapter/adapters/index.js +0 -9
  104. package/dist/core/adapter/adapters/llm-wiki-adapter.js +4 -0
  105. package/dist/core/adapter/adapters/okf-adapter.js +4 -0
  106. package/dist/core/adapter/adapters/opencode-adapter.js +5 -8
  107. package/dist/core/adapter/adapters/tool-dir-shared.js +63 -6
  108. package/dist/core/adapter/adapters/website-snapshot-adapter.js +4 -0
  109. package/dist/core/adapter/execution-source.js +308 -0
  110. package/dist/core/adapter/recognize-match.js +36 -13
  111. package/dist/core/adapter/registry.js +0 -9
  112. package/dist/core/asset/stash-meta.js +94 -4
  113. package/dist/core/common.js +6 -11
  114. package/dist/core/config/config-io.js +3 -3
  115. package/dist/core/config/config-schema.js +18 -40
  116. package/dist/core/config/config-sources.js +11 -21
  117. package/dist/core/config/config-walker.js +31 -13
  118. package/dist/core/config/config.js +23 -26
  119. package/dist/core/config/schema/engines.js +8 -7
  120. package/dist/core/config/schema/improve-processes.js +29 -5
  121. package/dist/core/config/schema/index-config.js +0 -27
  122. package/dist/core/config/schema/primitives.js +1 -23
  123. package/dist/core/config/schema/sources-bundles.js +13 -16
  124. package/dist/core/errors.js +2 -0
  125. package/dist/core/events.js +68 -32
  126. package/dist/core/extra-params.js +1 -0
  127. package/dist/core/improve-result.js +315 -0
  128. package/dist/core/lesson-lint.js +0 -6
  129. package/dist/core/maintenance-barrier.js +4 -4
  130. package/dist/core/network-policy.js +152 -0
  131. package/dist/core/paths.js +1 -1
  132. package/dist/core/recognition-util.js +4 -4
  133. package/dist/core/registry-url.js +456 -0
  134. package/dist/core/state/migrations.js +161 -47
  135. package/dist/core/state-db.js +453 -80
  136. package/dist/core/system-error.js +32 -0
  137. package/dist/core/time.js +2 -12
  138. package/dist/core/write-source.js +0 -18
  139. package/dist/execution/directory-identity.js +52 -0
  140. package/dist/execution/executable-identity.js +107 -0
  141. package/dist/execution/guarded-source.js +398 -0
  142. package/dist/execution/json.js +95 -0
  143. package/dist/{commands/health/types-session-log.js → execution/limits.js} +2 -1
  144. package/dist/execution/record.js +55 -0
  145. package/dist/execution/resolved-request.js +730 -0
  146. package/dist/execution/source.js +320 -0
  147. package/dist/indexer/bundle-identity-guard.js +5 -4
  148. package/dist/indexer/db/graph-db.js +33 -0
  149. package/dist/indexer/graph/graph-boost.js +3 -4
  150. package/dist/indexer/graph/graph-extraction.js +562 -373
  151. package/dist/indexer/index-written-assets.js +78 -39
  152. package/dist/indexer/indexer.js +471 -432
  153. package/dist/indexer/installations.js +6 -0
  154. package/dist/indexer/lookup/adapter-concept-owner.js +283 -0
  155. package/dist/indexer/materialize-embeddings.js +155 -0
  156. package/dist/indexer/passes/memory-inference.js +227 -174
  157. package/dist/indexer/passes/metadata.js +263 -118
  158. package/dist/indexer/scan/doc-to-entry.js +7 -10
  159. package/dist/indexer/scan/drain-dir.js +51 -23
  160. package/dist/indexer/search/db-search.js +156 -50
  161. package/dist/indexer/search/fts-query.js +40 -40
  162. package/dist/indexer/search/ranking.js +36 -1
  163. package/dist/indexer/search/search-attribution.js +3 -1
  164. package/dist/indexer/search/search-fields.js +23 -14
  165. package/dist/indexer/search/search-hit-enrichers.js +1 -1
  166. package/dist/indexer/search/search-source.js +7 -16
  167. package/dist/indexer/search/semantic-status.js +10 -1
  168. package/dist/indexer/usage/show-usage.js +105 -0
  169. package/dist/indexer/usage/usage-events.js +7 -2
  170. package/dist/indexer/walk/matchers.js +40 -10
  171. package/dist/indexer/walk/path-resolver.js +5 -2
  172. package/dist/indexer/walk/walker.js +20 -2
  173. package/dist/integrations/agent/builder-shared.js +3 -6
  174. package/dist/integrations/agent/conversation-fallback.js +16 -0
  175. package/dist/integrations/agent/engine-resolution.js +87 -87
  176. package/dist/integrations/agent/execution-cascade.js +566 -0
  177. package/dist/integrations/agent/execution-definitions.js +211 -0
  178. package/dist/integrations/agent/execution-lowering.js +811 -0
  179. package/dist/integrations/agent/execution-preparation.js +67 -0
  180. package/dist/integrations/agent/index.js +0 -2
  181. package/dist/integrations/agent/inline-execution.js +74 -0
  182. package/dist/integrations/agent/model-map.js +515 -0
  183. package/dist/integrations/agent/persona-fallback.js +30 -0
  184. package/dist/integrations/agent/request-lowering.js +186 -0
  185. package/dist/integrations/agent/runner-dispatch.js +230 -37
  186. package/dist/integrations/agent/runner.js +12 -83
  187. package/dist/integrations/harnesses/aider/agent-builder.js +8 -0
  188. package/dist/integrations/harnesses/aider/index.js +0 -1
  189. package/dist/integrations/harnesses/amazonq/agent-builder.js +8 -0
  190. package/dist/integrations/harnesses/amazonq/index.js +0 -1
  191. package/dist/integrations/harnesses/claude/agent-builder.js +14 -1
  192. package/dist/integrations/harnesses/claude/index.js +1 -5
  193. package/dist/integrations/harnesses/claude/session-log.js +3 -33
  194. package/dist/integrations/harnesses/codex/agent-builder.js +8 -0
  195. package/dist/integrations/harnesses/codex/index.js +0 -1
  196. package/dist/integrations/harnesses/copilot/agent-builder.js +8 -0
  197. package/dist/integrations/harnesses/copilot/index.js +0 -1
  198. package/dist/integrations/harnesses/gemini/agent-builder.js +8 -0
  199. package/dist/integrations/harnesses/gemini/index.js +0 -1
  200. package/dist/integrations/harnesses/index.js +4 -44
  201. package/dist/integrations/harnesses/opencode/agent-builder.js +16 -9
  202. package/dist/integrations/harnesses/opencode/index.js +0 -2
  203. package/dist/integrations/harnesses/opencode/session-log.js +14 -204
  204. package/dist/integrations/harnesses/opencode-sdk/harness.js +12 -1
  205. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +40 -42
  206. package/dist/integrations/harnesses/openhands/agent-builder.js +8 -0
  207. package/dist/integrations/harnesses/openhands/index.js +0 -1
  208. package/dist/integrations/harnesses/pi/agent-builder.js +8 -0
  209. package/dist/integrations/harnesses/pi/index.js +0 -1
  210. package/dist/integrations/harnesses/shared.js +0 -1
  211. package/dist/integrations/harnesses/types.js +1 -3
  212. package/dist/integrations/lockfile.js +82 -79
  213. package/dist/integrations/session-logs/index.js +6 -17
  214. package/dist/integrations/session-logs/provider-base.js +1 -29
  215. package/dist/llm/client.js +10 -5
  216. package/dist/llm/embedder.js +6 -7
  217. package/dist/llm/embedders/local.js +37 -88
  218. package/dist/llm/embedders/types.js +1 -1
  219. package/dist/llm/graph-extract.js +75 -50
  220. package/dist/llm/index-passes.js +43 -5
  221. package/dist/llm/memory-infer.js +8 -6
  222. package/dist/llm/metadata-enhance.js +5 -3
  223. package/dist/llm/structured-call.js +122 -25
  224. package/dist/output/format-exempt.js +1 -1
  225. package/dist/output/render-registry.js +0 -16
  226. package/dist/output/renderers.js +12 -7
  227. package/dist/output/shapes/curate.js +1 -0
  228. package/dist/output/shapes/helpers.js +10 -2
  229. package/dist/output/shapes/passthrough.js +2 -0
  230. package/dist/output/text/command-format.js +31 -33
  231. package/dist/output/text/health-format.js +1 -29
  232. package/dist/output/text/migrate.js +6 -56
  233. package/dist/output/text/proposal-format.js +16 -1
  234. package/dist/output/text/workflow-format.js +16 -0
  235. package/dist/registry/network.js +279 -0
  236. package/dist/registry/pinned-request-helper.js +247 -0
  237. package/dist/registry/pinned-transport.js +717 -0
  238. package/dist/registry/providers/skills-sh.js +18 -6
  239. package/dist/registry/providers/static-index.js +20 -7
  240. package/dist/registry/resolve.js +53 -28
  241. package/dist/scripts/akm-migrate-node.js +19334 -52269
  242. package/dist/scripts/akm-migrate.js +19270 -51612
  243. package/dist/setup/registry-stash-loader.js +64 -20
  244. package/dist/setup/semantic-assets.js +9 -34
  245. package/dist/setup/setup.js +12 -30
  246. package/dist/setup/source-identity.js +17 -0
  247. package/dist/setup/steps/sources.js +36 -15
  248. package/dist/setup/steps/tasks.js +39 -11
  249. package/dist/sources/providers/git-provider.js +3 -3
  250. package/dist/sources/providers/npm.js +2 -2
  251. package/dist/sources/providers/provider-utils.js +4 -3
  252. package/dist/sources/providers/website.js +11 -7
  253. package/dist/sources/snapshot-fetchers/host-guard.js +9 -136
  254. package/dist/sources/snapshot-fetchers/website-ingest.js +25 -109
  255. package/dist/sources/website-url.js +73 -0
  256. package/dist/storage/engines/sqlite-migrations.js +81 -26
  257. package/dist/storage/managed-db.js +27 -24
  258. package/dist/storage/repositories/events-repository.js +3 -0
  259. package/dist/storage/repositories/index-connection.js +42 -10
  260. package/dist/storage/repositories/index-entries-repository.js +203 -229
  261. package/dist/storage/repositories/index-entry-mapper.js +8 -12
  262. package/dist/storage/repositories/index-entry-schema.js +255 -0
  263. package/dist/storage/repositories/index-fts-repository.js +64 -71
  264. package/dist/storage/repositories/index-llm-cache-repository.js +8 -13
  265. package/dist/storage/repositories/index-meta-repository.js +0 -11
  266. package/dist/storage/repositories/index-schema.js +74 -350
  267. package/dist/storage/repositories/index-utility-repository.js +12 -17
  268. package/dist/storage/repositories/index-vec-repository.js +56 -7
  269. package/dist/storage/repositories/proposals-repository.js +4 -127
  270. package/dist/storage/repositories/registry-cache.js +2 -1
  271. package/dist/storage/repositories/task-history-repository.js +20 -40
  272. package/dist/storage/repositories/workflow-runs-repository.js +228 -129
  273. package/dist/storage/sqlite-read-snapshot.js +148 -0
  274. package/dist/tasks/backends/cron.js +170 -42
  275. package/dist/tasks/backends/index.js +1 -1
  276. package/dist/tasks/backends/launchd.js +787 -202
  277. package/dist/tasks/backends/schtasks.js +282 -83
  278. package/dist/tasks/embedded.js +7 -7
  279. package/dist/tasks/frozen-script.js +50 -0
  280. package/dist/tasks/resolve-akm-bin.js +5 -1
  281. package/dist/tasks/runner.js +239 -251
  282. package/dist/tasks/runtime-v3.js +281 -0
  283. package/dist/tasks/scheduler-binding.js +272 -0
  284. package/dist/tasks/scheduler-invocation.js +57 -43
  285. package/dist/tasks/scheduler-sync.js +654 -0
  286. package/dist/tasks/source-v3.js +752 -0
  287. package/dist/tasks/standalone-script-entry.js +5 -0
  288. package/dist/tasks/task-id.js +29 -0
  289. package/dist/workflows/authoring/authoring.js +15 -32
  290. package/dist/workflows/exec/dispatch-redaction.js +14 -8
  291. package/dist/workflows/exec/exec-unit.js +7 -28
  292. package/dist/workflows/exec/frozen-judge.js +57 -89
  293. package/dist/workflows/exec/lowering-notices.js +23 -0
  294. package/dist/workflows/exec/native-executor.js +301 -458
  295. package/dist/workflows/exec/param-secrets.js +4 -3
  296. package/dist/workflows/exec/run-workflow.js +26 -32
  297. package/dist/workflows/exec/step-work.js +105 -109
  298. package/dist/workflows/exec/unit-dispatch.js +103 -27
  299. package/dist/workflows/exec/unit-writer.js +3 -3
  300. package/dist/workflows/exec/worktree.js +2 -2
  301. package/dist/workflows/ir/compile.js +86 -72
  302. package/dist/workflows/ir/environment-v4.js +328 -0
  303. package/dist/workflows/ir/freeze-v4.js +122 -0
  304. package/dist/workflows/ir/plan-hash.js +13 -7
  305. package/dist/workflows/ir/schema-v4.js +525 -0
  306. package/dist/workflows/ir/schema.js +25 -284
  307. package/dist/workflows/ir/source-freeze-v4.js +506 -0
  308. package/dist/workflows/parser.js +27 -24
  309. package/dist/workflows/program/schema.js +1 -2
  310. package/dist/workflows/renderer.js +42 -29
  311. package/dist/workflows/resource-limits.js +4 -5
  312. package/dist/workflows/runtime/agent-identity.js +11 -13
  313. package/dist/workflows/runtime/plan-classifier.js +8 -8
  314. package/dist/workflows/runtime/runs.js +27 -43
  315. package/dist/workflows/runtime/workflow-asset-loader.js +45 -205
  316. package/dist/workflows/source-files.js +373 -0
  317. package/dist/workflows/source-ir/compile.js +196 -0
  318. package/dist/workflows/source-ir/github-yaml.js +577 -0
  319. package/dist/workflows/source-ir/ordering.js +38 -0
  320. package/dist/workflows/source-ir/program.js +50 -0
  321. package/dist/workflows/source-ir/result.js +26 -0
  322. package/dist/workflows/source-ir/schema.js +772 -0
  323. package/dist/workflows/source-ir/semantics.js +242 -0
  324. package/dist/workflows/source-ir/uses.js +14 -0
  325. package/docs/README.md +2 -0
  326. package/docs/migration/README.md +3 -1
  327. package/docs/migration/release-notes/0.9.2.md +55 -0
  328. package/docs/migration/release-notes/README.md +5 -0
  329. package/docs/migration/v0.8-to-v0.9.md +76 -1077
  330. package/docs/migration/v0.9.0-troubleshooting.md +104 -516
  331. package/docs/migration/v0.9.1-to-v0.9.2.md +150 -0
  332. package/docs/reference/README.md +1 -0
  333. package/docs/reference/cli.md +230 -98
  334. package/docs/reference/configuration.md +159 -36
  335. package/docs/reference/data-and-telemetry.md +19 -1
  336. package/docs/reference/supported-formats.md +23 -3
  337. package/docs/reference/tasks.md +182 -0
  338. package/docs/reference/workflow-schema.md +91 -40
  339. package/docs/reference/workflows.md +33 -6
  340. package/package.json +10 -6
  341. package/schemas/akm-config.json +372 -224
  342. package/schemas/akm-task.json +324 -80
  343. package/schemas/akm-workflow.json +6 -9
  344. package/dist/core/migration-operation.js +0 -75
  345. package/dist/integrations/agent/model-aliases.js +0 -74
  346. package/dist/tasks/parser.js +0 -380
  347. package/dist/tasks/schema.js +0 -123
  348. package/dist/tasks/validator.js +0 -80
  349. package/dist/workflows/ir/freeze.js +0 -320
  350. package/dist/workflows/runtime/document-cache.js +0 -13
@@ -29,9 +29,14 @@ import { ConfigError } from "../../core/errors.js";
29
29
  import { getTaskLogDir } from "../../core/paths.js";
30
30
  import { resolveAkmInvocation } from "../resolve-akm-bin.js";
31
31
  import { parseSchedule, translateToLaunchd } from "../schedule.js";
32
- import { buildScheduledTaskInvocation, parseScheduledTaskArgv, resolveScheduledTaskContext, schedulerContextDescriptor, schedulerContextPath, } from "../scheduler-invocation.js";
32
+ import { assertSchedulerExecutionEvidenceDigest, assertSchedulerExpectationIdentity, assertSchedulerMutationArtifact, assertSchedulerNativeArtifactCardinality, assertSchedulerNativeArtifactOwner, assertSchedulerRemovalArtifact, assertSchedulerRollbackArtifactCardinality, schedulerBindingNativeId, schedulerLogicalBindingId, schedulerLogicalBindingOwner, schedulerNativeArtifactKey, } from "../scheduler-binding.js";
33
+ import { buildScheduledBindingInvocation, parseScheduledBindingArgv, resolveScheduledTaskContext, schedulerContextDescriptor, schedulerContextPath, } from "../scheduler-invocation.js";
33
34
  import { escapeXml, nodeExec, nodeFs, runOrThrow } from "./exec-utils.js";
34
35
  export const LAUNCHD_LABEL_PREFIX = "com.akm.task.";
36
+ const MAX_LAUNCHD_DOMAIN_OUTPUT_BYTES = 4 * 1024 * 1024;
37
+ const MAX_LAUNCHD_AKM_NAMESPACE_ENTRIES = 4096;
38
+ const LAUNCHD_AKM_LABEL_RE = /^com\.akm\.task\.[A-Za-z0-9._-]{1,1024}$/u;
39
+ const LAUNCHD_SNAPSHOT = Symbol("akm-launchd-binding-snapshot");
35
40
  export function LAUNCHD_BACKEND(options = {}) {
36
41
  const exec = options.exec ?? defaultLaunchdExec();
37
42
  const fsLike = options.fs ?? defaultLaunchdFs();
@@ -39,240 +44,708 @@ export function LAUNCHD_BACKEND(options = {}) {
39
44
  const logDir = options.logDir ?? getTaskLogDir();
40
45
  const akmArgv = options.akmArgv ?? resolveAkmInvocation().argv;
41
46
  const scheduledContext = options.scheduledContext ?? resolveScheduledTaskContext();
42
- const plistPath = (id) => path.join(agentsDir, `${LAUNCHD_LABEL_PREFIX}${id}.plist`);
43
- const label = (id) => `${LAUNCHD_LABEL_PREFIX}${id}`;
44
- const target = (id) => `gui/${exec.uid()}/${label(id)}`;
45
- const pathEnv = () => {
46
- if (options.envPath === false)
47
- return undefined;
48
- if (typeof options.envPath === "string")
49
- return options.envPath;
50
- return process.env.PATH ?? "";
51
- };
52
- const defaultContextPath = schedulerContextPath(schedulerContextDescriptor(scheduledContext, pathEnv() ?? ""));
53
- const setEnableState = (id, enabled) => {
54
- const verb = enabled ? "enable" : "disable";
55
- runOrThrow(exec, ["launchctl", verb, target(id)], {
56
- message: (r) => `launchctl ${verb} failed: ${r.stderr || r.stdout || "no output"}.`,
57
- });
58
- };
47
+ const plistPath = (nativeId) => path.join(agentsDir, `${LAUNCHD_LABEL_PREFIX}${nativeId}.plist`);
48
+ const label = (nativeId) => `${LAUNCHD_LABEL_PREFIX}${nativeId}`;
49
+ const target = (nativeId) => `gui/${exec.uid()}/${label(nativeId)}`;
50
+ const defaultContextPath = launchdDefaultContextPath(options, scheduledContext);
51
+ const setEnableState = (nativeId, enabled) => setLaunchdEnableState(exec, target(nativeId), enabled);
59
52
  return {
60
53
  name: "launchd",
61
- install(task, opts) {
62
- // Capture PATH at install time so launchd (which strips the environment
63
- // aggressively) can find the same binaries the user sees interactively.
64
- const xml = buildPlistXml(task, [...(opts?.binding ?? akmArgv)], logDir, opts?.contextPath ?? defaultContextPath, opts?.target);
65
- const file = plistPath(task.id);
66
- const previousPlist = fsLike.exists(file) ? fsLike.readFile(file) : undefined;
67
- let previousEnabled = true;
68
- if (previousPlist !== undefined) {
69
- const disabledLabels = readDisabledLabels(exec);
70
- if (disabledLabels === undefined) {
71
- throw new ConfigError(`launchctl print-disabled failed; cannot safely replace existing task "${task.id}".`, "INVALID_CONFIG_FILE");
54
+ install(task, opts, expected) {
55
+ installLaunchdBinding(task, opts, expected, {
56
+ exec,
57
+ fsLike,
58
+ agentsDir,
59
+ logDir,
60
+ akmArgv,
61
+ defaultContextPath,
62
+ plistPath,
63
+ label,
64
+ target,
65
+ setEnableState,
66
+ });
67
+ },
68
+ uninstall(nativeId, expected) {
69
+ const inspectionContext = { exec, fsLike, agentsDir, plistPath, target, label };
70
+ const initialNamespace = inspectStableLaunchdNamespace([nativeId], inspectionContext);
71
+ let initialArtifact;
72
+ if (expected) {
73
+ assertSchedulerExpectationIdentity({ ...expected, state: "present" });
74
+ initialArtifact = assertSchedulerNativeArtifactCardinality(initialNamespace.inspection.artifacts, nativeId, 1);
75
+ if (!initialArtifact) {
76
+ throw new ConfigError(`launchd artifact ${JSON.stringify(nativeId)} disappeared during inspection.`, "INVALID_CONFIG_FILE");
72
77
  }
73
- previousEnabled = !disabledLabels.has(label(task.id));
78
+ assertSchedulerRemovalArtifact(initialArtifact.nativeId, expected, initialArtifact.invocation, initialArtifact.fingerprint);
74
79
  }
75
- fsLike.ensureDir(agentsDir);
76
- // launchd refuses to start a job when StandardOutPath/StandardErrorPath
77
- // points at a non-existent directory; create it before bootstrap.
78
- fsLike.ensureDir(logDir);
79
- const tempFile = path.join(agentsDir, `.${task.id}.${Date.now()}.tmp`);
80
- fsLike.writeFile(tempFile, xml);
81
- let bootoutCompleted = false;
82
- let previousWasLoaded = false;
83
- let fileReplaced = false;
84
- let enableStateTouched = false;
80
+ else {
81
+ initialArtifact = assertLaunchdDirectRemovalOwner(initialNamespace.inspection.artifacts, nativeId);
82
+ }
83
+ let resolvedNativeId = initialArtifact?.nativeId ?? nativeId;
84
+ const file = plistPath(resolvedNativeId);
85
+ const snapshot = snapshotLaunchdBindings([resolvedNativeId], {
86
+ exec,
87
+ fsLike,
88
+ agentsDir,
89
+ plistPath,
90
+ target,
91
+ label,
92
+ });
93
+ const finalArtifact = expected
94
+ ? assertSchedulerNativeArtifactCardinality(snapshot.artifacts, nativeId, 1)
95
+ : assertLaunchdDirectRemovalOwner(snapshot.artifacts, nativeId);
96
+ if (expected) {
97
+ if (!finalArtifact) {
98
+ throw new ConfigError(`launchd artifact ${JSON.stringify(nativeId)} disappeared during snapshot.`, "INVALID_CONFIG_FILE");
99
+ }
100
+ assertSchedulerRemovalArtifact(finalArtifact.nativeId, expected, finalArtifact.invocation, finalArtifact.fingerprint);
101
+ }
102
+ if (!finalArtifact)
103
+ return;
104
+ resolvedNativeId = finalArtifact.nativeId;
85
105
  try {
86
- const bootout = runOrThrow(exec, ["launchctl", "bootout", target(task.id)], {
106
+ runOrThrow(exec, ["launchctl", "bootout", target(resolvedNativeId)], {
87
107
  isOk: (r) => r.status === 0 || isServiceNotFoundResult(r),
88
108
  message: (r) => `launchctl bootout failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`,
89
109
  });
90
- bootoutCompleted = true;
91
- previousWasLoaded = previousPlist !== undefined && bootout.status === 0;
92
- fsLike.replaceFile(tempFile, file);
93
- fileReplaced = true;
94
- // A disable override survives bootout and plist replacement. Clear it
95
- // before bootstrap, then apply the desired state after registration.
96
- enableStateTouched = true;
97
- setEnableState(task.id, true);
98
- runOrThrow(exec, ["launchctl", "bootstrap", `gui/${exec.uid()}`, file], {
99
- message: (r) => `launchctl bootstrap failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`,
100
- hint: "Ensure `launchctl` is available; on macOS it is part of the base system.",
101
- });
102
- if (!task.enabled) {
103
- setEnableState(task.id, false);
104
- }
110
+ // launchctl disable overrides persist after the plist is removed.
111
+ setEnableState(resolvedNativeId, true);
112
+ if (fsLike.exists(file))
113
+ fsLike.removeFile(file);
105
114
  }
106
- catch (err) {
107
- if (!bootoutCompleted)
108
- throw err;
109
- const rollbackErrors = [];
110
- let priorFileRestored = !fileReplaced;
111
- if (fileReplaced) {
112
- let replacementUnloaded = false;
113
- try {
114
- const rollbackBootout = exec.run(["launchctl", "bootout", target(task.id)]);
115
- replacementUnloaded = rollbackBootout.status === 0 || isServiceNotFoundResult(rollbackBootout);
116
- if (!replacementUnloaded) {
117
- rollbackErrors.push(new ConfigError(`launchctl bootout during rollback failed: ${rollbackBootout.stderr || rollbackBootout.stdout || "no output"}.`, "INVALID_CONFIG_FILE"));
118
- }
119
- }
120
- catch (rollbackError) {
121
- rollbackErrors.push(rollbackError);
122
- }
123
- if (replacementUnloaded) {
124
- try {
125
- if (previousPlist === undefined) {
126
- if (fsLike.exists(file))
127
- fsLike.removeFile(file);
128
- }
129
- else {
130
- fsLike.writeFile(tempFile, previousPlist);
131
- fsLike.replaceFile(tempFile, file);
132
- }
133
- priorFileRestored = true;
134
- }
135
- catch (rollbackError) {
136
- rollbackErrors.push(rollbackError);
137
- }
138
- }
139
- }
140
- if (previousPlist !== undefined && previousWasLoaded && priorFileRestored) {
141
- try {
142
- setEnableState(task.id, true);
143
- const restore = exec.run(["launchctl", "bootstrap", `gui/${exec.uid()}`, file]);
144
- if (restore.status !== 0) {
145
- rollbackErrors.push(new ConfigError(`launchctl bootstrap during rollback failed: ${restore.stderr || restore.stdout || "no output"}.`, "INVALID_CONFIG_FILE"));
146
- }
147
- }
148
- catch (rollbackError) {
149
- rollbackErrors.push(rollbackError);
150
- }
151
- if (!previousEnabled) {
152
- try {
153
- setEnableState(task.id, false);
154
- }
155
- catch (rollbackError) {
156
- rollbackErrors.push(rollbackError);
157
- }
158
- }
159
- }
160
- else if (enableStateTouched) {
161
- try {
162
- setEnableState(task.id, previousPlist === undefined || previousEnabled);
163
- }
164
- catch (rollbackError) {
165
- rollbackErrors.push(rollbackError);
166
- }
115
+ catch (primaryError) {
116
+ try {
117
+ const rollbackExpected = [
118
+ launchdRollbackExpectationForCurrent(resolvedNativeId, inspectionContext, initialArtifact),
119
+ ];
120
+ restoreLaunchdBindings(snapshot, { exec, fsLike, agentsDir, plistPath, target, label, setEnableState }, rollbackExpected);
167
121
  }
168
- if (rollbackErrors.length > 0) {
169
- const message = err instanceof Error ? err.message : String(err);
170
- throw new AggregateError([err, ...rollbackErrors], `${message}; rollback for launchd task "${task.id}" was incomplete.`);
122
+ catch (rollbackError) {
123
+ throw new AggregateError([primaryError, rollbackError], `${errorMessage(primaryError)}; rollback for launchd removal ${JSON.stringify(nativeId)} was incomplete.`);
171
124
  }
172
- throw err;
125
+ throw primaryError;
173
126
  }
174
- finally {
175
- if (fsLike.exists(tempFile))
176
- fsLike.removeFile(tempFile);
177
- }
178
- },
179
- uninstall(id) {
180
- runOrThrow(exec, ["launchctl", "bootout", target(id)], {
181
- isOk: (r) => r.status === 0 || isServiceNotFoundResult(r),
182
- message: (r) => `launchctl bootout failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`,
183
- });
184
- // launchctl disable overrides persist after the plist is removed.
185
- setEnableState(id, true);
186
- const file = plistPath(id);
187
- if (fsLike.exists(file))
188
- fsLike.removeFile(file);
189
127
  },
190
- setEnabled(id, enabled) {
191
- setEnableState(id, enabled);
128
+ setEnabled(nativeId, enabled) {
129
+ setEnableState(nativeId, enabled);
192
130
  },
193
131
  list() {
194
- if (!fsLike.exists(agentsDir))
195
- return [];
196
- const ids = [];
197
- for (const file of fsLike.list(agentsDir)) {
198
- if (file.startsWith(LAUNCHD_LABEL_PREFIX) && file.endsWith(".plist")) {
199
- ids.push(file.slice(LAUNCHD_LABEL_PREFIX.length, -".plist".length));
200
- }
201
- }
202
- if (ids.length === 0)
203
- return [];
204
- const disabledLabels = readDisabledLabels(exec);
205
- return ids
206
- .map((id) => inspectInstalledLaunchdTask(id, fsLike.readFile(plistPath(id)), disabledLabels, exec))
207
- .filter((ref) => ref !== undefined);
132
+ return [
133
+ ...inspectLaunchdState({ exec, fsLike, agentsDir, plistPath, target, label }).installed,
134
+ ];
208
135
  },
209
136
  listForRebind() {
210
- if (!fsLike.exists(agentsDir))
211
- return [];
137
+ const namespace = inspectStableLaunchdNamespace([], { exec, fsLike, agentsDir, plistPath, target, label });
212
138
  const refs = [];
213
- for (const file of fsLike.list(agentsDir)) {
214
- if (!file.startsWith(LAUNCHD_LABEL_PREFIX) || !file.endsWith(".plist"))
139
+ for (const entry of namespace.entries) {
140
+ if (entry.plist === undefined)
215
141
  continue;
216
- const id = file.slice(LAUNCHD_LABEL_PREFIX.length, -".plist".length);
217
- const installed = extractPlistInvocation(fsLike.readFile(plistPath(id)));
218
- refs.push({ id, ...(installed?.target !== undefined ? { target: installed.target } : {}) });
142
+ const installed = extractPlistInvocation(entry.plist);
143
+ const id = entry.nativeId;
144
+ const ref = {
145
+ id: installed ? schedulerLogicalBindingId(id, installed.invocation) : id,
146
+ ...(installed?.target !== undefined ? { target: installed.target } : {}),
147
+ };
148
+ Object.defineProperty(ref, "nativeId", { value: id });
149
+ if (installed)
150
+ Object.defineProperty(ref, "invocation", { value: Object.freeze([...installed.invocation]) });
151
+ refs.push(ref);
219
152
  }
220
153
  return refs;
221
154
  },
155
+ listNativeArtifacts() {
156
+ return [...inspectLaunchdState({ exec, fsLike, agentsDir, plistPath, target, label }).artifacts];
157
+ },
158
+ inspectBindings() {
159
+ return inspectLaunchdState({ exec, fsLike, agentsDir, plistPath, target, label });
160
+ },
161
+ snapshotBindings(ids) {
162
+ return snapshotLaunchdBindings(ids, { exec, fsLike, agentsDir, plistPath, target, label });
163
+ },
164
+ restoreBindings(snapshot, expectedCurrent) {
165
+ restoreLaunchdBindings(snapshot, { exec, fsLike, agentsDir, plistPath, target, label, setEnableState }, expectedCurrent);
166
+ },
222
167
  expectedSignature(task, opts) {
223
- return normalizeSignature(buildPlistXml(task, [...(opts?.binding ?? akmArgv)], logDir, opts?.contextPath ?? defaultContextPath, opts?.target));
168
+ return launchdFingerprint(buildPlistXml(task, [...(opts?.binding ?? akmArgv)], logDir, opts?.contextPath ?? defaultContextPath, opts?.target), task.enabled, true);
224
169
  },
225
170
  };
226
171
  }
227
- function inspectInstalledLaunchdTask(id, raw, disabledLabels, exec) {
228
- const installed = extractPlistInvocation(raw);
229
- // An invocation that no longer parses (e.g. a pre-0.9 `tasks run` entry
230
- // surviving the scheduler-ABI respelling) is an orphan of its marker id,
231
- // not a hard failure: `list()` omits it so `akmTasksSync` treats the id as
232
- // "not present" and reinstalls it from the current task file.
233
- if (!installed)
234
- return undefined;
235
- const metadata = {
236
- ...(installed.target !== undefined ? { target: installed.target } : {}),
237
- binding: installed.binding,
238
- contextPath: installed.contextPath,
239
- };
240
- if (!disabledLabels)
241
- return { id, ...metadata };
242
- const jobLabel = `${LAUNCHD_LABEL_PREFIX}${id}`;
172
+ function installLaunchdBinding(task, opts, expected, context) {
173
+ const { exec, fsLike, agentsDir, logDir, akmArgv, defaultContextPath, plistPath, label, target, setEnableState } = context;
174
+ if (expected)
175
+ assertSchedulerExpectationIdentity(expected, task);
176
+ // Capture PATH at install time so launchd (which strips the environment
177
+ // aggressively) can find the same binaries the user sees interactively.
178
+ const xml = buildPlistXml(task, [...(opts?.binding ?? akmArgv)], logDir, opts?.contextPath ?? defaultContextPath, opts?.target);
179
+ const nativeId = schedulerBindingNativeId(task);
180
+ const file = plistPath(nativeId);
181
+ fsLike.ensureDir(agentsDir);
182
+ // launchd refuses to start a job when StandardOutPath/StandardErrorPath
183
+ // points at a non-existent directory; create it before reading or mutating
184
+ // launchd state so a local preparation failure has no scheduler side effect.
185
+ fsLike.ensureDir(logDir);
186
+ const inspectionContext = { exec, fsLike, agentsDir, plistPath, target, label };
187
+ const initialNamespace = inspectStableLaunchdNamespace([nativeId], inspectionContext);
188
+ const initialArtifact = expected
189
+ ? assertSchedulerNativeArtifactCardinality(initialNamespace.inspection.artifacts, nativeId, expected.state === "absent" ? 0 : 1)
190
+ : assertLaunchdDirectMutationOwner(initialNamespace.inspection.artifacts, nativeId, task);
191
+ if (expected)
192
+ assertSchedulerMutationArtifact(initialArtifact, expected);
193
+ const priorEntry = initialArtifact
194
+ ? initialNamespace.entries.find((entry) => entry.nativeId === initialArtifact.nativeId)
195
+ : undefined;
196
+ const previousPlist = priorEntry?.plist;
197
+ const previousEnabled = priorEntry?.enabled ?? true;
198
+ const tempFile = path.join(agentsDir, `.${nativeId}.${Date.now()}.tmp`);
199
+ fsLike.writeFile(tempFile, xml);
200
+ let bootoutCompleted = false;
201
+ let previousWasLoaded = false;
202
+ let fileReplaced = false;
203
+ let enableStateTouched = false;
243
204
  try {
244
- const loaded = exec.run(["launchctl", "print", `gui/${exec.uid()}/${jobLabel}`]);
245
- if (loaded.status !== 0)
246
- return { id, ...metadata };
205
+ if (expected) {
206
+ const finalInspection = inspectStableLaunchdNamespace([nativeId], inspectionContext).inspection;
207
+ const finalArtifact = assertSchedulerNativeArtifactCardinality(finalInspection.artifacts, nativeId, expected.state === "absent" ? 0 : 1);
208
+ assertSchedulerMutationArtifact(finalArtifact, expected);
209
+ }
210
+ else {
211
+ const finalArtifact = assertLaunchdDirectMutationOwner(inspectStableLaunchdNamespace([nativeId], inspectionContext).inspection.artifacts, nativeId, task);
212
+ if (finalArtifact?.nativeId !== initialArtifact?.nativeId ||
213
+ finalArtifact?.fingerprint !== initialArtifact?.fingerprint) {
214
+ throw new ConfigError(`launchd task ${JSON.stringify(nativeId)} changed while it was being prepared; refusing to replace an unverified owner.`, "INVALID_CONFIG_FILE");
215
+ }
216
+ }
217
+ const bootout = runOrThrow(exec, ["launchctl", "bootout", target(nativeId)], {
218
+ isOk: (r) => r.status === 0 || isServiceNotFoundResult(r),
219
+ message: (r) => `launchctl bootout failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`,
220
+ });
221
+ bootoutCompleted = true;
222
+ previousWasLoaded = previousPlist !== undefined && bootout.status === 0;
223
+ fsLike.replaceFile(tempFile, file);
224
+ fileReplaced = true;
225
+ // A disable override survives bootout and plist replacement. Clear it
226
+ // before bootstrap, then apply the desired state after registration.
227
+ enableStateTouched = true;
228
+ setEnableState(nativeId, true);
229
+ runOrThrow(exec, ["launchctl", "bootstrap", `gui/${exec.uid()}`, file], {
230
+ message: (r) => `launchctl bootstrap failed (exit ${r.status}): ${r.stderr || r.stdout || "no output"}.`,
231
+ hint: "Ensure `launchctl` is available; on macOS it is part of the base system.",
232
+ });
233
+ if (!task.enabled)
234
+ setEnableState(nativeId, false);
247
235
  }
248
- catch {
249
- return { id, ...metadata };
236
+ catch (err) {
237
+ if (!bootoutCompleted)
238
+ throw err;
239
+ const rollbackErrors = [];
240
+ let priorFileRestored = !fileReplaced;
241
+ if (fileReplaced) {
242
+ let replacementUnloaded = false;
243
+ try {
244
+ const rollbackBootout = exec.run(["launchctl", "bootout", target(nativeId)]);
245
+ replacementUnloaded = rollbackBootout.status === 0 || isServiceNotFoundResult(rollbackBootout);
246
+ if (!replacementUnloaded) {
247
+ rollbackErrors.push(new ConfigError(`launchctl bootout during rollback failed: ${rollbackBootout.stderr || rollbackBootout.stdout || "no output"}.`, "INVALID_CONFIG_FILE"));
248
+ }
249
+ }
250
+ catch (rollbackError) {
251
+ rollbackErrors.push(rollbackError);
252
+ }
253
+ if (replacementUnloaded) {
254
+ try {
255
+ if (previousPlist === undefined) {
256
+ if (fsLike.exists(file))
257
+ fsLike.removeFile(file);
258
+ }
259
+ else {
260
+ fsLike.writeFile(tempFile, previousPlist);
261
+ fsLike.replaceFile(tempFile, file);
262
+ }
263
+ priorFileRestored = true;
264
+ }
265
+ catch (rollbackError) {
266
+ rollbackErrors.push(rollbackError);
267
+ }
268
+ }
269
+ }
270
+ restoreLaunchdInstallState({
271
+ exec,
272
+ nativeId,
273
+ file,
274
+ previousPlist,
275
+ previousEnabled,
276
+ previousWasLoaded,
277
+ priorFileRestored,
278
+ enableStateTouched,
279
+ setEnableState,
280
+ rollbackErrors,
281
+ });
282
+ if (rollbackErrors.length > 0) {
283
+ const message = err instanceof Error ? err.message : String(err);
284
+ throw new AggregateError([err, ...rollbackErrors], `${message}; rollback for launchd task "${task.id}" was incomplete.`);
285
+ }
286
+ throw err;
287
+ }
288
+ finally {
289
+ if (fsLike.exists(tempFile))
290
+ fsLike.removeFile(tempFile);
291
+ }
292
+ }
293
+ function restoreLaunchdInstallState(input) {
294
+ const { exec, nativeId, file, previousPlist, previousEnabled, previousWasLoaded, priorFileRestored, enableStateTouched, setEnableState, rollbackErrors, } = input;
295
+ if (previousPlist !== undefined && previousWasLoaded && priorFileRestored) {
296
+ try {
297
+ setEnableState(nativeId, true);
298
+ const restore = exec.run(["launchctl", "bootstrap", `gui/${exec.uid()}`, file]);
299
+ if (restore.status !== 0) {
300
+ rollbackErrors.push(new ConfigError(`launchctl bootstrap during rollback failed: ${restore.stderr || restore.stdout || "no output"}.`, "INVALID_CONFIG_FILE"));
301
+ }
302
+ }
303
+ catch (rollbackError) {
304
+ rollbackErrors.push(rollbackError);
305
+ }
306
+ if (!previousEnabled) {
307
+ try {
308
+ setEnableState(nativeId, false);
309
+ }
310
+ catch (rollbackError) {
311
+ rollbackErrors.push(rollbackError);
312
+ }
313
+ }
314
+ }
315
+ else if (enableStateTouched) {
316
+ try {
317
+ setEnableState(nativeId, previousPlist === undefined || previousEnabled);
318
+ }
319
+ catch (rollbackError) {
320
+ rollbackErrors.push(rollbackError);
321
+ }
322
+ }
323
+ }
324
+ function snapshotLaunchdBindings(ids, context) {
325
+ const requestedKeys = new Set(ids.map(schedulerNativeArtifactKey));
326
+ const namespace = inspectStableLaunchdNamespace(ids, context);
327
+ const matchingEntries = namespace.entries.filter((entry) => requestedKeys.has(schedulerNativeArtifactKey(entry.nativeId)));
328
+ const matchedKeys = new Set(matchingEntries.map((entry) => schedulerNativeArtifactKey(entry.nativeId)));
329
+ const entries = [
330
+ ...matchingEntries.map((entry) => Object.freeze({
331
+ id: entry.nativeId,
332
+ ...(entry.plist !== undefined ? { plist: entry.plist } : {}),
333
+ loaded: entry.loaded,
334
+ enabled: entry.enabled,
335
+ })),
336
+ ...ids
337
+ .filter((id) => !matchedKeys.has(schedulerNativeArtifactKey(id)))
338
+ .map((id) => Object.freeze({ id, loaded: false, enabled: true })),
339
+ ];
340
+ const artifacts = matchingEntries.map((entry) => entry.artifact);
341
+ return Object.freeze({
342
+ kind: LAUNCHD_SNAPSHOT,
343
+ nativeIds: Object.freeze([...ids]),
344
+ artifacts: Object.freeze(artifacts),
345
+ entries: Object.freeze(entries),
346
+ });
347
+ }
348
+ function restoreLaunchdBindings(snapshot, context, expectedCurrent) {
349
+ if (!isLaunchdBindingSnapshot(snapshot)) {
350
+ throw new ConfigError("Invalid launchd scheduler snapshot.", "INVALID_CONFIG_FILE");
351
+ }
352
+ const preflightErrors = [];
353
+ const duplicateSnapshotKeys = new Set();
354
+ const requestedIdsByKey = new Map();
355
+ for (const nativeId of snapshot.nativeIds) {
356
+ const key = schedulerNativeArtifactKey(nativeId);
357
+ const priorId = requestedIdsByKey.get(key);
358
+ if (priorId !== undefined) {
359
+ duplicateSnapshotKeys.add(key);
360
+ preflightErrors.push(new ConfigError(`Launchd scheduler snapshot requests normalized duplicate artifacts ${JSON.stringify(priorId)} and ${JSON.stringify(nativeId)}; refusing restore.`, "INVALID_CONFIG_FILE"));
361
+ continue;
362
+ }
363
+ requestedIdsByKey.set(key, nativeId);
250
364
  }
365
+ const snapshotEntryIdsByKey = new Map();
366
+ for (const entry of snapshot.entries) {
367
+ const key = schedulerNativeArtifactKey(entry.id);
368
+ const priorId = snapshotEntryIdsByKey.get(key);
369
+ if (priorId !== undefined) {
370
+ if (duplicateSnapshotKeys.has(key))
371
+ continue;
372
+ duplicateSnapshotKeys.add(key);
373
+ preflightErrors.push(new ConfigError(`Launchd scheduler snapshot contains normalized duplicate artifacts ${JSON.stringify(priorId)} and ${JSON.stringify(entry.id)}; refusing restore.`, "INVALID_CONFIG_FILE"));
374
+ continue;
375
+ }
376
+ snapshotEntryIdsByKey.set(key, entry.id);
377
+ }
378
+ let rollbackInventory;
251
379
  try {
252
- const xml = raw.replace(/<!-- akm-enabled:(?:true|false) -->/, `<!-- akm-enabled:${!disabledLabels.has(jobLabel)} -->`);
253
- return {
254
- id,
255
- signature: normalizeSignature(xml),
256
- ...metadata,
257
- };
380
+ rollbackInventory = inspectStableLaunchdNamespace(snapshot.nativeIds, context).inspection;
258
381
  }
259
- catch {
260
- return { id, ...metadata };
382
+ catch (error) {
383
+ preflightErrors.push(error);
384
+ }
385
+ if (preflightErrors.length > 0) {
386
+ throw new AggregateError(preflightErrors, `Failed to preflight ${preflightErrors.length} launchd scheduler restore operation(s).`);
387
+ }
388
+ const expectationsById = new Map();
389
+ const expectationIdsByKey = new Map();
390
+ if (expectedCurrent) {
391
+ for (const expected of expectedCurrent) {
392
+ const key = schedulerNativeArtifactKey(expected.nativeId);
393
+ const priorExact = expectationsById.get(expected.nativeId);
394
+ const priorKeyId = expectationIdsByKey.get(key);
395
+ if (priorExact !== undefined || priorKeyId !== undefined) {
396
+ preflightErrors.push(new ConfigError(`Launchd rollback expectations contain duplicate normalized artifact ${JSON.stringify(expected.nativeId)}.`, "INVALID_CONFIG_FILE"));
397
+ continue;
398
+ }
399
+ expectationsById.set(expected.nativeId, expected);
400
+ expectationIdsByKey.set(key, expected.nativeId);
401
+ }
402
+ }
403
+ const plannedEntries = [];
404
+ const coveredExpectationIds = new Set();
405
+ if (rollbackInventory) {
406
+ for (const entry of snapshot.entries) {
407
+ if (duplicateSnapshotKeys.has(schedulerNativeArtifactKey(entry.id)))
408
+ continue;
409
+ try {
410
+ let current;
411
+ if (expectedCurrent) {
412
+ const expected = expectationsById.get(entry.id);
413
+ if (!expected) {
414
+ throw new ConfigError(`Missing launchd rollback expectation for ${JSON.stringify(entry.id)}.`, "INVALID_CONFIG_FILE");
415
+ }
416
+ coveredExpectationIds.add(entry.id);
417
+ current = assertSchedulerRollbackArtifactCardinality(rollbackInventory.artifacts, expected);
418
+ }
419
+ else {
420
+ const currentCount = rollbackInventory.artifacts.filter((artifact) => schedulerNativeArtifactKey(artifact.nativeId) === schedulerNativeArtifactKey(entry.id)).length;
421
+ current = assertSchedulerNativeArtifactCardinality(rollbackInventory.artifacts, entry.id, currentCount === 0 ? 0 : 1);
422
+ if (current !== undefined && current.nativeId !== entry.id) {
423
+ throw new ConfigError(`Launchd scheduler artifact ${JSON.stringify(entry.id)} changed to case- or suffix-equivalent spelling ${JSON.stringify(current.nativeId)} before restore.`, "INVALID_CONFIG_FILE");
424
+ }
425
+ }
426
+ if (entry.loaded && entry.plist === undefined) {
427
+ assertUnrestorableLaunchdSnapshotUnchanged(entry, rollbackInventory.artifacts);
428
+ }
429
+ plannedEntries.push(Object.freeze({
430
+ entry,
431
+ immediateExpectation: launchdRollbackExpectationFromArtifact(entry.id, current),
432
+ noOp: entry.loaded && entry.plist === undefined,
433
+ }));
434
+ }
435
+ catch (error) {
436
+ preflightErrors.push(error);
437
+ }
438
+ }
439
+ }
440
+ if (expectedCurrent) {
441
+ for (const expected of expectedCurrent) {
442
+ if (!coveredExpectationIds.has(expected.nativeId)) {
443
+ preflightErrors.push(new ConfigError(`Unexpected launchd rollback expectation for ${JSON.stringify(expected.nativeId)} is not covered by the snapshot.`, "INVALID_CONFIG_FILE"));
444
+ }
445
+ }
446
+ }
447
+ if (preflightErrors.length > 0) {
448
+ throw new AggregateError(preflightErrors, `Failed to preflight ${preflightErrors.length} launchd scheduler restore operation(s).`);
449
+ }
450
+ const errors = [];
451
+ for (const planned of plannedEntries) {
452
+ if (planned.noOp)
453
+ continue;
454
+ try {
455
+ const immediateInventory = inspectStableLaunchdNamespace(snapshot.nativeIds, context).inspection;
456
+ assertSchedulerRollbackArtifactCardinality(immediateInventory.artifacts, planned.immediateExpectation);
457
+ }
458
+ catch (error) {
459
+ errors.push(error);
460
+ continue;
461
+ }
462
+ restoreLaunchdBindingEntry(planned.entry, context, errors);
463
+ }
464
+ if (errors.length > 0) {
465
+ throw new AggregateError(errors, `Failed to completely restore ${errors.length} launchd scheduler operation(s).`);
261
466
  }
262
467
  }
263
- /**
264
- * Recover the bundle name embedded as a `--bundle <bundle>` pair in a plist's
265
- * `<ProgramArguments>`. Returns undefined for the primary/default form.
266
- */
267
- export function extractPlistTarget(xml) {
268
- return extractPlistInvocation(xml)?.target;
468
+ function launchdRollbackExpectationFromArtifact(nativeId, current) {
469
+ if (current === undefined) {
470
+ return Object.freeze({ nativeId, allowed: Object.freeze([{ state: "absent" }]) });
471
+ }
472
+ if (current.nativeId !== nativeId || current.fingerprint === undefined) {
473
+ throw new ConfigError(`Launchd scheduler artifact ${JSON.stringify(nativeId)} has no exact restorable fingerprint.`, "INVALID_CONFIG_FILE");
474
+ }
475
+ return Object.freeze({
476
+ nativeId,
477
+ allowed: Object.freeze([
478
+ Object.freeze({
479
+ state: "present",
480
+ ...(current.bindingId !== undefined ? { bindingId: current.bindingId } : {}),
481
+ ...(current.invocation !== undefined ? { invocation: current.invocation } : {}),
482
+ fingerprint: current.fingerprint,
483
+ }),
484
+ ]),
485
+ });
486
+ }
487
+ function assertUnrestorableLaunchdSnapshotUnchanged(entry, currentArtifacts) {
488
+ const current = assertSchedulerNativeArtifactCardinality(currentArtifacts, entry.id, 1);
489
+ const expectedFingerprint = launchdMissingPlistArtifact(entry.id, entry.enabled, entry.loaded).fingerprint;
490
+ if (current?.nativeId !== entry.id ||
491
+ current.invocation !== undefined ||
492
+ current.bindingId !== undefined ||
493
+ current.fingerprint !== expectedFingerprint) {
494
+ throw new ConfigError(`Loaded launchd artifact ${JSON.stringify(entry.id)} has no restorable plist and changed after its snapshot; refusing mutation.`, "INVALID_CONFIG_FILE");
495
+ }
496
+ }
497
+ function inspectStableLaunchdNamespace(seedIds, context) {
498
+ const first = captureLaunchdNamespacePass(seedIds, context);
499
+ const second = captureLaunchdNamespacePass(seedIds, context);
500
+ if (first.stabilityKey !== second.stabilityKey) {
501
+ throw new ConfigError("The launchd AKM service namespace changed while scheduler state was being stabilized.", "INVALID_CONFIG_FILE");
502
+ }
503
+ return second.namespace;
504
+ }
505
+ function captureLaunchdNamespacePass(seedIds, context) {
506
+ const domain = context.exec.run(["launchctl", "print", `gui/${context.exec.uid()}`]);
507
+ if (domain.status !== 0) {
508
+ throw new ConfigError(`launchctl failed to enumerate the loaded user domain during scheduler state inspection: ${domain.stderr || domain.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
509
+ }
510
+ const loadedLabels = parseLaunchdLoadedLabels(domain.stdout);
511
+ if (loadedLabels === undefined) {
512
+ throw new ConfigError("launchctl returned an unsafe, unsupported, or oversized loaded-service inventory during scheduler state inspection.", "INVALID_CONFIG_FILE");
513
+ }
514
+ const disabledLabels = readDisabledLabels(context.exec);
515
+ if (disabledLabels === undefined) {
516
+ throw new ConfigError("launchctl print-disabled failed during scheduler state inspection.", "INVALID_CONFIG_FILE");
517
+ }
518
+ const akmDisabledLabels = [...disabledLabels].filter((label) => label.startsWith(LAUNCHD_LABEL_PREFIX)).sort();
519
+ const plistEntries = [];
520
+ if (context.fsLike.exists(context.agentsDir)) {
521
+ for (const file of context.fsLike.list(context.agentsDir).sort()) {
522
+ if (!file.startsWith(LAUNCHD_LABEL_PREFIX) || !file.endsWith(".plist"))
523
+ continue;
524
+ const nativeId = file.slice(LAUNCHD_LABEL_PREFIX.length, -".plist".length);
525
+ plistEntries.push(Object.freeze([nativeId, context.fsLike.readFile(context.plistPath(nativeId))]));
526
+ }
527
+ }
528
+ const ids = new Set(seedIds);
529
+ for (const [nativeId] of plistEntries)
530
+ ids.add(nativeId);
531
+ for (const serviceLabel of loadedLabels)
532
+ ids.add(serviceLabel.slice(LAUNCHD_LABEL_PREFIX.length));
533
+ for (const serviceLabel of akmDisabledLabels)
534
+ ids.add(serviceLabel.slice(LAUNCHD_LABEL_PREFIX.length));
535
+ if (ids.size > MAX_LAUNCHD_AKM_NAMESPACE_ENTRIES) {
536
+ throw new ConfigError(`launchd AKM scheduler inventory exceeds ${MAX_LAUNCHD_AKM_NAMESPACE_ENTRIES} namespace entries.`, "INVALID_CONFIG_FILE");
537
+ }
538
+ const plistByNativeId = new Map(plistEntries);
539
+ const entries = [];
540
+ for (const nativeId of [...ids].sort()) {
541
+ const serviceLabel = context.label(nativeId);
542
+ const plist = plistByNativeId.get(nativeId);
543
+ const enabled = !disabledLabels.has(serviceLabel);
544
+ const loaded = loadedLabels.has(serviceLabel);
545
+ if (plist === undefined && enabled && !loaded)
546
+ continue;
547
+ const artifact = plist === undefined
548
+ ? launchdMissingPlistArtifact(nativeId, enabled, loaded)
549
+ : launchdArtifact(nativeId, plist, enabled, loaded);
550
+ entries.push(Object.freeze({ nativeId, ...(plist !== undefined ? { plist } : {}), enabled, loaded, artifact }));
551
+ }
552
+ const artifacts = entries.map((entry) => entry.artifact);
553
+ const installed = [];
554
+ for (const entry of entries) {
555
+ if (entry.plist === undefined)
556
+ continue;
557
+ const parsed = extractPlistInvocation(entry.plist);
558
+ if (!parsed)
559
+ continue;
560
+ installed.push(withInstalledInvocation({
561
+ id: schedulerLogicalBindingId(entry.nativeId, parsed.invocation),
562
+ ...(entry.loaded ? { signature: entry.artifact.fingerprint } : {}),
563
+ ...(parsed.target !== undefined ? { target: parsed.target } : {}),
564
+ binding: parsed.binding,
565
+ contextPath: parsed.contextPath,
566
+ }, parsed.invocation, entry.nativeId));
567
+ }
568
+ return Object.freeze({
569
+ namespace: Object.freeze({
570
+ inspection: Object.freeze({ installed: Object.freeze(installed), artifacts: Object.freeze(artifacts) }),
571
+ entries: Object.freeze(entries),
572
+ }),
573
+ stabilityKey: JSON.stringify({
574
+ loadedLabels: [...loadedLabels].sort(),
575
+ disabledLabels: akmDisabledLabels,
576
+ plistEntries,
577
+ }),
578
+ });
579
+ }
580
+ function restoreLaunchdBindingEntry(entry, context, errors) {
581
+ try {
582
+ runOrThrow(context.exec, ["launchctl", "bootout", context.target(entry.id)], {
583
+ isOk: (result) => result.status === 0 || isServiceNotFoundResult(result),
584
+ message: (result) => `launchctl bootout failed while restoring task "${entry.id}": ${result.stderr || result.stdout || "no output"}.`,
585
+ });
586
+ }
587
+ catch (error) {
588
+ errors.push(error);
589
+ return;
590
+ }
591
+ const file = context.plistPath(entry.id);
592
+ let priorFileRestored = false;
593
+ try {
594
+ if (entry.plist === undefined) {
595
+ if (context.fsLike.exists(file))
596
+ context.fsLike.removeFile(file);
597
+ }
598
+ else {
599
+ context.fsLike.ensureDir(context.agentsDir);
600
+ context.fsLike.writeFile(file, entry.plist);
601
+ }
602
+ priorFileRestored = true;
603
+ }
604
+ catch (error) {
605
+ errors.push(error);
606
+ }
607
+ if (entry.loaded && entry.plist !== undefined && priorFileRestored) {
608
+ let overrideCleared = false;
609
+ try {
610
+ // A disable override survives bootout. Clear it before bootstrap or
611
+ // launchd can refuse to register the exact prior loaded definition.
612
+ context.setEnableState(entry.id, true);
613
+ overrideCleared = true;
614
+ }
615
+ catch (error) {
616
+ errors.push(error);
617
+ }
618
+ if (overrideCleared) {
619
+ try {
620
+ runOrThrow(context.exec, ["launchctl", "bootstrap", `gui/${context.exec.uid()}`, file], {
621
+ message: (result) => `launchctl bootstrap failed while restoring task "${entry.id}": ${result.stderr || result.stdout || "no output"}.`,
622
+ });
623
+ }
624
+ catch (error) {
625
+ errors.push(error);
626
+ }
627
+ }
628
+ }
629
+ try {
630
+ // Apply the prior override last even when an earlier step failed; this is
631
+ // both the exact state ordering and the best available partial recovery.
632
+ context.setEnableState(entry.id, entry.enabled);
633
+ }
634
+ catch (error) {
635
+ errors.push(error);
636
+ }
637
+ }
638
+ function isLaunchdBindingSnapshot(value) {
639
+ return (typeof value === "object" &&
640
+ value !== null &&
641
+ value.kind === LAUNCHD_SNAPSHOT &&
642
+ Array.isArray(value.entries));
643
+ }
644
+ function inspectLaunchdState(context) {
645
+ return inspectStableLaunchdNamespace([], context).inspection;
646
+ }
647
+ function assertLaunchdDirectMutationOwner(artifacts, nativeId, task) {
648
+ const key = schedulerNativeArtifactKey(nativeId);
649
+ const count = artifacts.filter((artifact) => schedulerNativeArtifactKey(artifact.nativeId) === key).length;
650
+ const artifact = assertSchedulerNativeArtifactCardinality(artifacts, nativeId, count === 0 ? 0 : 1);
651
+ if (artifact)
652
+ assertSchedulerNativeArtifactOwner(artifact.nativeId, task, artifact.invocation);
653
+ return artifact;
654
+ }
655
+ function assertLaunchdDirectRemovalOwner(artifacts, nativeId) {
656
+ const key = schedulerNativeArtifactKey(nativeId);
657
+ const count = artifacts.filter((artifact) => schedulerNativeArtifactKey(artifact.nativeId) === key).length;
658
+ const artifact = assertSchedulerNativeArtifactCardinality(artifacts, nativeId, count === 0 ? 0 : 1);
659
+ if (!artifact)
660
+ return undefined;
661
+ if (artifact.nativeId !== nativeId || artifact.invocation === undefined) {
662
+ throw new ConfigError(`launchd artifact ${JSON.stringify(artifact.nativeId)} is not the exact proven requested owner ${JSON.stringify(nativeId)}; refusing direct removal.`, "INVALID_CONFIG_FILE");
663
+ }
664
+ return artifact;
665
+ }
666
+ function launchdRollbackExpectationForCurrent(nativeId, context, priorArtifact) {
667
+ const namespace = inspectStableLaunchdNamespace([nativeId], context);
668
+ const key = schedulerNativeArtifactKey(nativeId);
669
+ const count = namespace.inspection.artifacts.filter((artifact) => schedulerNativeArtifactKey(artifact.nativeId) === key).length;
670
+ const artifact = assertSchedulerNativeArtifactCardinality(namespace.inspection.artifacts, nativeId, count === 0 ? 0 : 1);
671
+ if (!artifact) {
672
+ return Object.freeze({ nativeId, allowed: Object.freeze([{ state: "absent" }]) });
673
+ }
674
+ if (artifact.fingerprint === undefined ||
675
+ artifact.invocation === undefined ||
676
+ priorArtifact?.invocation === undefined ||
677
+ !sameStringArray(artifact.invocation, priorArtifact.invocation)) {
678
+ throw new ConfigError(`launchd artifact ${JSON.stringify(nativeId)} has no exact transaction-owned rollback fingerprint.`, "INVALID_CONFIG_FILE");
679
+ }
680
+ return Object.freeze({
681
+ nativeId,
682
+ allowed: Object.freeze([
683
+ Object.freeze({
684
+ state: "present",
685
+ ...(artifact.bindingId !== undefined ? { bindingId: artifact.bindingId } : {}),
686
+ invocation: Object.freeze([...artifact.invocation]),
687
+ fingerprint: artifact.fingerprint,
688
+ }),
689
+ ]),
690
+ });
691
+ }
692
+ function sameStringArray(left, right) {
693
+ return left.length === right.length && left.every((value, index) => value === right[index]);
694
+ }
695
+ function errorMessage(error) {
696
+ return error instanceof Error ? error.message : String(error);
697
+ }
698
+ function launchdMissingPlistArtifact(nativeId, enabled, loaded) {
699
+ const artifact = { nativeId };
700
+ Object.defineProperty(artifact, "fingerprint", {
701
+ value: `launchd:missing-plist:enabled=${enabled}:loaded=${loaded}`,
702
+ });
703
+ return artifact;
704
+ }
705
+ function launchdArtifact(nativeId, raw, enabled, loaded) {
706
+ const parsed = extractPlistInvocation(raw);
707
+ const owner = parsed ? schedulerLogicalBindingOwner(nativeId, parsed.invocation) : undefined;
708
+ const artifact = parsed
709
+ ? {
710
+ nativeId,
711
+ ...(owner !== undefined ? { bindingId: owner } : {}),
712
+ invocation: Object.freeze([...parsed.invocation]),
713
+ }
714
+ : { nativeId };
715
+ if (enabled !== undefined && loaded !== undefined) {
716
+ Object.defineProperty(artifact, "fingerprint", { value: launchdFingerprint(raw, enabled, loaded) });
717
+ }
718
+ return artifact;
719
+ }
720
+ function launchdFingerprint(raw, enabled, loaded) {
721
+ const signed = raw.replace(/<!-- akm-enabled:(?:true|false) -->/, `<!-- akm-enabled:${enabled} -->`);
722
+ return `${normalizeSignature(signed)}:loaded=${loaded}`;
723
+ }
724
+ function withInstalledInvocation(ref, invocation, nativeId) {
725
+ Object.defineProperty(ref, "nativeId", { value: nativeId });
726
+ Object.defineProperty(ref, "invocation", { value: Object.freeze([...invocation]) });
727
+ return ref;
728
+ }
729
+ function launchdDefaultContextPath(options, scheduledContext) {
730
+ const pathEnv = options.envPath === false
731
+ ? undefined
732
+ : typeof options.envPath === "string"
733
+ ? options.envPath
734
+ : (process.env.PATH ?? "");
735
+ return schedulerContextPath(schedulerContextDescriptor(scheduledContext, pathEnv ?? ""));
736
+ }
737
+ function setLaunchdEnableState(exec, nativeTarget, enabled) {
738
+ const verb = enabled ? "enable" : "disable";
739
+ runOrThrow(exec, ["launchctl", verb, nativeTarget], {
740
+ message: (result) => `launchctl ${verb} failed: ${result.stderr || result.stdout || "no output"}.`,
741
+ });
269
742
  }
270
743
  export function extractPlistInvocation(xml) {
271
744
  const block = xml.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
272
745
  if (!block)
273
746
  return undefined;
274
747
  const args = [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => decodeXmlEntities(m[1]));
275
- return parseScheduledTaskArgv(args);
748
+ return parseScheduledBindingArgv(args);
276
749
  }
277
750
  function decodeXmlEntities(value) {
278
751
  return value
@@ -283,17 +756,21 @@ function decodeXmlEntities(value) {
283
756
  .replaceAll("&amp;", "&");
284
757
  }
285
758
  // ── XML builder (exported for tests) ────────────────────────────────────────
286
- export function buildPlistXml(task, akmArgv, logDir, contextPath, target) {
287
- const spec = parseSchedule(task.schedule, "launchd");
759
+ export function buildPlistXml(task, akmArgv, logDir, contextPath, _target) {
760
+ const spec = parseSchedule(task.cron, "launchd");
288
761
  const trigger = translateToLaunchd(spec);
289
- const invocation = buildScheduledTaskInvocation(akmArgv, task.id, contextPath, target);
762
+ const invocation = buildScheduledBindingInvocation(akmArgv, contextPath, task.invocation);
290
763
  const argv = invocation.argv;
291
764
  const programArgs = argv.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
292
- const logPath = path.join(logDir, `${task.id}.log`);
765
+ const nativeId = schedulerBindingNativeId(task);
766
+ const logPath = path.join(logDir, `${nativeId}.log`);
293
767
  const triggerXml = renderLaunchdTrigger(trigger);
768
+ const executionEvidence = task.executionEvidenceDigest === undefined
769
+ ? ""
770
+ : ` <!-- akm-workflow-evidence:${assertSchedulerExecutionEvidenceDigest(task.executionEvidenceDigest)} -->\n`;
294
771
  const xml = launchdTemplate
295
- .replace("<dict>\n", `<dict>\n <!-- akm-enabled:${task.enabled} -->\n`)
296
- .replace("{{LABEL}}", LAUNCHD_LABEL_PREFIX + escapeXml(task.id))
772
+ .replace("<dict>\n", `<dict>\n <!-- akm-enabled:${task.enabled} -->\n${executionEvidence}`)
773
+ .replace("{{LABEL}}", LAUNCHD_LABEL_PREFIX + escapeXml(nativeId))
297
774
  .replace("{{PROGRAM_ARGS}}", programArgs)
298
775
  .replaceAll("{{LOG_PATH}}", escapeXml(logPath))
299
776
  .replace("{{ENV_VARS}}", "")
@@ -340,6 +817,114 @@ function renderCalendar(calendar, indent) {
340
817
  function normalizeSignature(xml) {
341
818
  return xml.replace(/\r\n/g, "\n").trim();
342
819
  }
820
+ /** Parse only proven loaded-service labels from bounded launchctl domain/list output. */
821
+ export function parseLaunchdLoadedLabels(output) {
822
+ if (Buffer.byteLength(output, "utf8") > MAX_LAUNCHD_DOMAIN_OUTPUT_BYTES || hasUnsafeLaunchdControlCharacter(output)) {
823
+ return undefined;
824
+ }
825
+ const lines = output.replace(/\r\n?/gu, "\n").split("\n");
826
+ const labels = new Set();
827
+ let akmEntryCount = 0;
828
+ const add = (label) => {
829
+ if (!LAUNCHD_AKM_LABEL_RE.test(label))
830
+ return false;
831
+ akmEntryCount += 1;
832
+ if (akmEntryCount > MAX_LAUNCHD_AKM_NAMESPACE_ENTRIES || labels.has(label))
833
+ return false;
834
+ labels.add(label);
835
+ return true;
836
+ };
837
+ const firstContent = lines.findIndex((line) => line.trim() !== "");
838
+ if (firstContent >= 0 && /^PID\s+Status\s+Label$/iu.test(lines[firstContent].trim())) {
839
+ for (const line of lines.slice(firstContent + 1)) {
840
+ if (!line.trim())
841
+ continue;
842
+ const row = /^\s*(?:-|\d+)\s+-?\d+\s+(\S+)\s*$/u.exec(line);
843
+ if (!row?.[1])
844
+ return undefined;
845
+ if (row[1].startsWith(LAUNCHD_LABEL_PREFIX) && !add(row[1]))
846
+ return undefined;
847
+ }
848
+ return labels;
849
+ }
850
+ const firstLine = firstContent < 0 ? undefined : lines[firstContent];
851
+ if (firstLine === undefined || !/^\s*(?:gui|user)\/\d+\s*=\s*\{\s*$/u.test(firstLine))
852
+ return undefined;
853
+ let lastContent = lines.length - 1;
854
+ while (lastContent >= 0 && !lines[lastContent]?.trim())
855
+ lastContent -= 1;
856
+ const lastLine = lines[lastContent];
857
+ if (lastContent <= firstContent || lastLine === undefined || !/^\s*\}\s*$/u.test(lastLine))
858
+ return undefined;
859
+ let depth = 1;
860
+ let servicesSeen = false;
861
+ let insideServices = false;
862
+ for (let index = firstContent + 1; index <= lastContent; index += 1) {
863
+ const line = lines[index];
864
+ if (index === lastContent) {
865
+ if (insideServices || depth !== 1)
866
+ return undefined;
867
+ depth = 0;
868
+ continue;
869
+ }
870
+ const servicesBlock = /^\s*services\s*=\s*\{\s*$/u.test(line);
871
+ const emptyServicesBlock = /^\s*services\s*=\s*\{\s*\}\s*$/u.test(line);
872
+ if (servicesBlock || emptyServicesBlock) {
873
+ if (servicesSeen || insideServices || depth !== 1)
874
+ return undefined;
875
+ servicesSeen = true;
876
+ if (servicesBlock) {
877
+ insideServices = true;
878
+ depth += 1;
879
+ }
880
+ continue;
881
+ }
882
+ if (/^\s*services\s*=/u.test(line))
883
+ return undefined;
884
+ if (insideServices && depth === 2) {
885
+ if (!line.trim())
886
+ continue;
887
+ if (/^\s*\}\s*$/u.test(line)) {
888
+ insideServices = false;
889
+ depth -= 1;
890
+ continue;
891
+ }
892
+ const assignment = /^\s*-?\d+\s*=\s*"?([^"\s{}=]+)"?\s*$/u.exec(line);
893
+ const domainTable = /^\s*(?:-|\d+)\s+(?:-|-?\d+)\s+(\S+)\s*$/u.exec(line);
894
+ const dictionary = /^\s*"?([^"\s{}=]+)"?\s*=\s*\{\s*$/u.exec(line);
895
+ const candidate = assignment?.[1] ?? domainTable?.[1] ?? dictionary?.[1];
896
+ if (!candidate)
897
+ return undefined;
898
+ if (candidate.startsWith(LAUNCHD_LABEL_PREFIX) && !add(candidate))
899
+ return undefined;
900
+ if (dictionary)
901
+ depth += 1;
902
+ continue;
903
+ }
904
+ if (line.includes(LAUNCHD_LABEL_PREFIX))
905
+ return undefined;
906
+ depth += countCharacter(line, "{") - countCharacter(line, "}");
907
+ if (depth < 1 || (insideServices && depth < 2))
908
+ return undefined;
909
+ }
910
+ return servicesSeen && depth === 0 ? labels : undefined;
911
+ }
912
+ function countCharacter(value, needle) {
913
+ let count = 0;
914
+ for (const character of value)
915
+ if (character === needle)
916
+ count += 1;
917
+ return count;
918
+ }
919
+ function hasUnsafeLaunchdControlCharacter(output) {
920
+ for (let index = 0; index < output.length; index += 1) {
921
+ const code = output.charCodeAt(index);
922
+ if (code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || (code >= 127 && code <= 159)) {
923
+ return true;
924
+ }
925
+ }
926
+ return false;
927
+ }
343
928
  function readDisabledLabels(exec) {
344
929
  try {
345
930
  const result = exec.run(["launchctl", "print-disabled", `gui/${exec.uid()}`]);